├── src ├── components │ ├── mind-map │ │ ├── MapIndex.css │ │ ├── render │ │ │ ├── helpers │ │ │ │ ├── markdown.ts │ │ │ │ ├── getSizeFromNodeDate.ts │ │ │ │ ├── showdown.d.ts │ │ │ │ └── d3Helper.ts │ │ │ ├── node │ │ │ │ ├── interface.ts │ │ │ │ └── nodePreRenderForSize.ts │ │ │ ├── index.ts │ │ │ ├── model │ │ │ │ └── interface.ts │ │ │ ├── layout │ │ │ │ ├── flex-tree │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── separate-root.ts │ │ │ │ │ └── do-layout.ts │ │ │ │ ├── outline │ │ │ │ │ └── index.ts │ │ │ │ ├── interface.ts │ │ │ │ └── index.ts │ │ │ └── hooks │ │ │ │ ├── observable-hook.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── useGenSubNodesWithAI.ts │ │ │ │ ├── useRenderOption.ts │ │ │ │ ├── useNodeColor.ts │ │ │ │ └── useAutoColoringMindMap.tsx │ │ ├── MindMap.css │ │ ├── SizeMeasurer.tsx │ │ ├── Controllers │ │ │ ├── LinkModeControl.tsx │ │ │ ├── Controller.tsx │ │ │ ├── LayoutControl.tsx │ │ │ ├── ColorControl.tsx │ │ │ └── ScaleControl.tsx │ │ └── MapIndex.tsx │ ├── presentation │ │ ├── presentation-model │ │ │ ├── presentation-node.tsx │ │ │ └── presentation-page.tsx │ │ ├── presentation-view │ │ │ └── presentation-view.css │ │ ├── index.tsx │ │ ├── page-node-content │ │ │ └── page-node-content.tsx │ │ ├── page-view │ │ │ └── page-view.css │ │ ├── presentation-button │ │ │ └── presentation-button.tsx │ │ └── page-tree │ │ │ └── page-tree.tsx │ ├── LoadingView.tsx │ ├── outline │ │ ├── common.tsx │ │ ├── icons.tsx │ │ └── index.tsx │ └── state │ │ └── converter.ts ├── env.d.ts ├── assets │ └── favicon.ico ├── model │ ├── ot-doc │ │ ├── maybe.ts │ │ ├── algebra.ts │ │ ├── document.ts │ │ ├── timestamped.ts │ │ ├── singleton.ts │ │ ├── array.ts │ │ └── record.ts │ ├── data │ │ ├── behaviors │ │ │ ├── preset.ts │ │ │ ├── signatured.ts │ │ │ ├── eq.ts │ │ │ ├── test.ts │ │ │ └── readable.ts │ │ ├── higher-kinded-type.ts │ │ ├── struct.ts │ │ ├── op.ts │ │ └── behavior.ts │ ├── queue.ts │ ├── document-engine.ts │ ├── read.ts │ ├── observable.ts │ └── api.ts ├── global.css ├── App.css ├── router.tsx ├── biz │ ├── layout.tsx │ ├── store │ │ ├── index.ts │ │ └── files.ts │ ├── components │ │ ├── skeleton-block.tsx │ │ ├── popup.tsx │ │ └── modal.tsx │ ├── side-pane │ │ ├── head.tsx │ │ ├── file-tree │ │ │ ├── rename.tsx │ │ │ ├── index.tsx │ │ │ ├── file.tsx │ │ │ └── menu.tsx │ │ ├── index.tsx │ │ ├── home.tsx │ │ └── search.tsx │ ├── head │ │ ├── more.tsx │ │ ├── index.tsx │ │ ├── share.tsx │ │ ├── users.tsx │ │ └── file-meta.tsx │ └── floating │ │ ├── mindmap-theme.tsx │ │ └── index.tsx ├── base │ ├── classnames.ts │ ├── copy-text.ts │ ├── eventer.ts │ ├── styled.ts │ └── atom.readme.md ├── index.tsx └── App.tsx ├── office-addin ├── babel.config.json ├── assets │ ├── icon-16.png │ ├── icon-32.png │ └── icon-80.png ├── .eslintrc.json ├── src │ ├── commands │ │ ├── commands.html │ │ └── commands.ts │ ├── landing-page │ │ ├── landing-page.html │ │ └── landing-page.ts │ └── taskpane │ │ ├── taskpane.html │ │ ├── components │ │ └── message-container.ts │ │ └── helpers │ │ ├── MindMapGenHelper.ts │ │ ├── WordHelper.ts │ │ └── SignInHelper.ts ├── tsconfig.json ├── README.md ├── package.json └── manifest.xml ├── .husky └── pre-commit ├── mai-mind-map-se ├── bin │ └── www ├── server │ ├── storage │ │ ├── mysql.ts │ │ ├── pool.ts │ │ └── users.ts │ ├── controllers │ │ ├── auth.ts │ │ ├── auth.config.ts │ │ └── users.ts │ ├── loop │ │ └── index.ts │ ├── server.ts │ └── utils.ts └── package.json ├── .gitignore ├── tsconfig.json ├── README.md ├── biome.json ├── LICENSE ├── rsbuild.config.ts ├── package.json ├── SECURITY.md └── .github └── workflows ├── main_mai-mind-map.yml └── dev_dev-mai-mind-map.yml /src/components/mind-map/MapIndex.css: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /office-addin/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/mai-mind-map/HEAD/src/assets/favicon.ico -------------------------------------------------------------------------------- /office-addin/assets/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/mai-mind-map/HEAD/office-addin/assets/icon-16.png -------------------------------------------------------------------------------- /office-addin/assets/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/mai-mind-map/HEAD/office-addin/assets/icon-32.png -------------------------------------------------------------------------------- /office-addin/assets/icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/mai-mind-map/HEAD/office-addin/assets/icon-80.png -------------------------------------------------------------------------------- /src/components/mind-map/render/helpers/markdown.ts: -------------------------------------------------------------------------------- 1 | import showdown from './showdown'; 2 | 3 | export const markdown = new showdown.Converter(); 4 | -------------------------------------------------------------------------------- /mai-mind-map-se/bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const server = require('../server/server'); 4 | console.log('server', server); 5 | server.run(); 6 | -------------------------------------------------------------------------------- /office-addin/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "office-addins" 4 | ], 5 | "extends": [ 6 | "plugin:office-addins/recommended" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/model/ot-doc/maybe.ts: -------------------------------------------------------------------------------- 1 | // $: Constructor type 2 | // v: the value 3 | export type Maybe = { $: 'Nothing' } | { $: 'Just'; v: T }; 4 | 5 | export const nothing = (): Maybe => ({ $: 'Nothing' }); 6 | export const just = (v: T): Maybe => ({ $: 'Just', v }); 7 | -------------------------------------------------------------------------------- /src/components/mind-map/render/helpers/getSizeFromNodeDate.ts: -------------------------------------------------------------------------------- 1 | export function getSizeFromNodeDate(elId: string): [number, number] { 2 | const el = document.getElementById(elId); 3 | if (el) { 4 | return [el.offsetWidth, el.offsetHeight]; 5 | } 6 | return [50, 30]; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/presentation/presentation-model/presentation-node.tsx: -------------------------------------------------------------------------------- 1 | interface PresentationNode { 2 | id: string; 3 | text?: string; 4 | note?: string; 5 | images?: string[]; 6 | 7 | children?: PresentationNode[]; 8 | } 9 | 10 | export type { PresentationNode }; 11 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | 2 | html, body { 3 | margin: 0; 4 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif ; 5 | height: 100%; 6 | width: 100%; 7 | } 8 | 9 | * { 10 | box-sizing: border-box; 11 | } 12 | 13 | #root { 14 | width: 100%; 15 | height: 100vh; 16 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local 2 | .DS_Store 3 | *.local 4 | *.log* 5 | 6 | # Dist 7 | node_modules 8 | dist/ 9 | 10 | # IDE 11 | .vscode/* 12 | !.vscode/extensions.json 13 | .idea 14 | 15 | # Server 16 | mai-mind-map-se/.vscode/* 17 | mai-mind-map-se/**/*.js 18 | mai-mind-map-se/config*.json 19 | mai-mind-map-se/pnpm-lock.yaml 20 | -------------------------------------------------------------------------------- /src/components/mind-map/render/helpers/showdown.d.ts: -------------------------------------------------------------------------------- 1 | declare class Converter { 2 | constructor(option?: Record); 3 | makeHtml(md: string): string; 4 | makeMarkdown(html: string): string; 5 | } 6 | interface Showdown { 7 | Converter: typeof Converter; 8 | } 9 | 10 | declare const showdown: Showdown; 11 | export default showdown; 12 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | min-height: 100vh; 4 | line-height: 1.1; 5 | text-align: center; 6 | flex-direction: column; 7 | justify-content: center; 8 | } 9 | 10 | .content h1 { 11 | font-size: 3.6rem; 12 | font-weight: 700; 13 | } 14 | 15 | .content p { 16 | font-size: 1.2rem; 17 | font-weight: 400; 18 | opacity: 0.5; 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/components/mind-map/MindMap.css: -------------------------------------------------------------------------------- 1 | foreignObject.node-content { 2 | overflow: visible; 3 | border-radius: 5px; 4 | position: relative; 5 | outline: none; 6 | } 7 | 8 | /* foreignObject.dragging-item-over { 9 | box-shadow: 0 0 10px 5px rgba(0, 0, 0, 0.5); 10 | } */ 11 | 12 | 13 | .shadow-dragging { 14 | pointer-events: none; 15 | } 16 | 17 | 18 | .in-collapsed { 19 | display: none; 20 | } 21 | -------------------------------------------------------------------------------- /src/router.tsx: -------------------------------------------------------------------------------- 1 | import 'react'; 2 | 3 | import { createBrowserRouter } from 'react-router-dom'; 4 | import App from './App'; 5 | import Home from './Home'; 6 | 7 | export const router = createBrowserRouter([ 8 | { 9 | path: '/', 10 | element: , 11 | }, 12 | { 13 | path: '/edit', 14 | element: , 15 | }, 16 | { 17 | path: '/edit/:fileId', 18 | element: , 19 | }, 20 | ]); 21 | -------------------------------------------------------------------------------- /mai-mind-map-se/server/storage/mysql.ts: -------------------------------------------------------------------------------- 1 | import mysql from 'mysql'; 2 | import { readConfig } from '../utils'; 3 | 4 | const config = readConfig(); 5 | 6 | // Initialize pool 7 | const pool = mysql.createPool({ 8 | connectionLimit: config.DB_CONN_LIMIT, 9 | host: config.DB_HOST, 10 | port: config.DB_PORT, 11 | user: config.DB_USER, 12 | password: config.DB_SECRET, 13 | database: config.DB_NAME, 14 | debug: false 15 | }); 16 | 17 | module.exports = pool; 18 | -------------------------------------------------------------------------------- /src/components/mind-map/render/node/interface.ts: -------------------------------------------------------------------------------- 1 | export interface RawNode { 2 | id: string; 3 | payload: Mdata; 4 | children?: RawNode[]; 5 | isRoot?: boolean; 6 | } 7 | export interface SizedRawNode extends RawNode { 8 | content_size: [number, number]; 9 | children?: SizedRawNode[]; 10 | } 11 | export type GetSizeFromNodeDate = (elId: string) => [number, number]; 12 | export type IsNodeCollapsed = (data: Mdata) => boolean; 13 | -------------------------------------------------------------------------------- /src/components/mind-map/render/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as layoutFun, 3 | type Direction, 4 | type HierarchyOptions, 5 | type NodeInterface, 6 | type NodeLink, 7 | LayoutType, 8 | type FLEX_TREE, 9 | type OUTLINE, 10 | } from './layout'; 11 | export * from './node/interface'; 12 | export { default as prepareNodeSize } from './node/nodePreRenderForSize'; 13 | export * from './model/index'; 14 | export * from './helpers'; 15 | export * from './hooks/useRenderWithD3'; 16 | -------------------------------------------------------------------------------- /office-addin/src/commands/commands.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/mind-map/render/model/interface.ts: -------------------------------------------------------------------------------- 1 | export interface Payload 2 | extends Record { 3 | content: string; // content of the node 4 | collapsed?: boolean; // whether the node is collapsed 5 | hilight?: string; 6 | bold?: boolean; 7 | italic?: boolean; 8 | underline?: boolean; 9 | link?: string; 10 | } 11 | 12 | export function getHiLightColor(payload: D): string { 13 | return (payload as any as Payload)?.hilight || '#0172DC'; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/presentation/presentation-view/presentation-view.css: -------------------------------------------------------------------------------- 1 | .presentation-view { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100vw; 6 | height: 100vh; 7 | z-index: 9999; 8 | cursor: default; 9 | line-height: normal; 10 | background-color: #1F2329; 11 | } 12 | 13 | .presentation-control-bar { 14 | position: fixed; 15 | width: 100%; 16 | bottom: 10px; 17 | display: flex; 18 | flex-direction: row; 19 | justify-content: center; 20 | gap: 10px; 21 | } -------------------------------------------------------------------------------- /office-addin/src/landing-page/landing-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /office-addin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": ".", 5 | "esModuleInterop": true, 6 | "experimentalDecorators": true, 7 | "jsx": "react", 8 | "noEmitOnError": true, 9 | "outDir": "lib", 10 | "sourceMap": true, 11 | "target": "es5", 12 | "lib": [ 13 | "es2015", 14 | "dom" 15 | ] 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | "dist", 20 | "lib", 21 | "lib-amd" 22 | ], 23 | "ts-node": { 24 | "files": true 25 | } 26 | } -------------------------------------------------------------------------------- /src/components/LoadingView.tsx: -------------------------------------------------------------------------------- 1 | import { BeatLoader } from "react-spinners"; 2 | 3 | export const LoadingVeiw = () => { 4 | return ( 5 |
12 |
17 | 18 |
19 | 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/model/data/behaviors/preset.ts: -------------------------------------------------------------------------------- 1 | import { behavior } from '../behavior'; 2 | import { $Var } from '../higher-kinded-type'; 3 | import { mapStruct } from '../struct'; 4 | 5 | export type Preset = { preset: T }; 6 | 7 | const withPreset = (preset: T): Preset => ({ preset }); 8 | 9 | export default behavior({ 10 | $string: withPreset(''), 11 | $number: withPreset(0), 12 | $boolean: withPreset(false), 13 | $array: () => withPreset([]), 14 | $dict: () => withPreset({}), 15 | $struct: (stt) => withPreset(mapStruct(stt, ({ preset }) => preset)), 16 | }); 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["DOM", "ES2020"], 5 | "module": "ESNext", 6 | "jsx": "react-jsx", 7 | "noEmit": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "isolatedModules": true, 11 | "resolveJsonModule": true, 12 | "moduleResolution": "bundler", 13 | "useDefineForClassFields": true, 14 | "allowImportingTsExtensions": true, 15 | "baseUrl": "./", 16 | "paths": { 17 | "@root/*": ["src/*"], 18 | "@base/*": ["src/base/*"] 19 | } 20 | }, 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /office-addin/src/commands/commands.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. 3 | * See LICENSE in the project root for license information. 4 | */ 5 | 6 | /* global Office */ 7 | 8 | Office.onReady(() => { 9 | // If needed, Office.js is ready to be called. 10 | }); 11 | 12 | function navigateWebApp(event: Office.AddinCommands.Event) { 13 | Office.context.ui.displayDialogAsync("https://mai-mind-map.azurewebsites.net"); 14 | event.completed(); 15 | } 16 | 17 | // Register the function with Office. 18 | Office.actions.associate("navigateWebApp", navigateWebApp); 19 | -------------------------------------------------------------------------------- /src/components/mind-map/render/node/nodePreRenderForSize.ts: -------------------------------------------------------------------------------- 1 | import { GetSizeFromNodeDate, RawNode, SizedRawNode } from './interface'; 2 | 3 | export default function prepareNodeSize( 4 | node: RawNode, 5 | getSize: GetSizeFromNodeDate, 6 | elIdPrefix: string, 7 | ): SizedRawNode { 8 | const content_size = getSize(`${elIdPrefix}-${node.id}`); 9 | 10 | const children = node.children?.map((child) => { 11 | const childNode = prepareNodeSize(child, getSize, elIdPrefix); 12 | return childNode; 13 | }); 14 | return { 15 | ...node, 16 | children, 17 | content_size, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/presentation/index.tsx: -------------------------------------------------------------------------------- 1 | import PresentationButton from './presentation-button/presentation-button'; 2 | import { PresentationNode } from './presentation-model/presentation-node'; 3 | interface Node { 4 | id: string; 5 | payload: { content: string }; 6 | children?: Node[]; 7 | } 8 | 9 | function presentationNodeFromSampleData(node: Node): PresentationNode { 10 | const { 11 | id, 12 | payload: { content }, 13 | children, 14 | } = node; 15 | return { 16 | id, 17 | text: content, 18 | children: children?.map((c) => presentationNodeFromSampleData(c)), 19 | }; 20 | } 21 | 22 | export { PresentationButton, presentationNodeFromSampleData }; 23 | -------------------------------------------------------------------------------- /src/model/data/higher-kinded-type.ts: -------------------------------------------------------------------------------- 1 | import { $OpSign, $Var, Op } from './op'; 2 | 3 | // Type application (substitutes type variables with types) 4 | export type $ = T extends $OpSign 5 | ? Op 6 | : T extends $Var 7 | ? S 8 | : T extends undefined | null | boolean | string | number 9 | ? T 10 | : T extends Array 11 | ? $[] 12 | : T extends () => infer O 13 | ? () => $ 14 | : T extends (x: infer I) => infer O 15 | ? (x: $) => $ 16 | : T extends object 17 | ? { [K in keyof T]: $ } 18 | : T; 19 | 20 | export type { $Var }; 21 | -------------------------------------------------------------------------------- /mai-mind-map-se/server/controllers/auth.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { POST_LOGOUT_REDIRECT_URI, REDIRECT_URI } from './auth.config'; 3 | import { authProvider } from './auth.provider'; 4 | 5 | export const router = express.Router(); 6 | 7 | router.get('/signin', authProvider.login({ 8 | scopes: [], 9 | redirectUri: REDIRECT_URI, 10 | })); 11 | 12 | router.get('/acquireToken', authProvider.acquireToken({ 13 | scopes: ['User.Read'], 14 | redirectUri: REDIRECT_URI, 15 | successRedirect: '/users/id' 16 | })); 17 | 18 | router.post('/redirect', authProvider.handleRedirect()); 19 | 20 | router.get('/signout', authProvider.logout({ 21 | postLogoutRedirectUri: POST_LOGOUT_REDIRECT_URI 22 | })); 23 | -------------------------------------------------------------------------------- /src/biz/layout.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@root/base/styled'; 2 | 3 | const SLayoutPage = css` 4 | height: 100%; 5 | display: flex; 6 | align-items: stretch; 7 | `; 8 | 9 | const SLayoutSide = css` 10 | flex: 0 0 auto; 11 | `; 12 | 13 | const SLayoutMain = css` 14 | flex: 1 1 100%; 15 | display: flex; 16 | flex-direction: column; 17 | overflow: hidden; 18 | `; 19 | 20 | const SLayoutHead = css` 21 | flex: 0 0 auto; 22 | `; 23 | 24 | const SLayoutContent = css` 25 | flex: 1 1 100%; 26 | position: relative; 27 | `; 28 | 29 | export const LayoutStyle = { 30 | Page: SLayoutPage, 31 | Side: SLayoutSide, 32 | Main: SLayoutMain, 33 | Head: SLayoutHead, 34 | Content: SLayoutContent, 35 | }; 36 | -------------------------------------------------------------------------------- /src/model/data/behaviors/signatured.ts: -------------------------------------------------------------------------------- 1 | import { behavior } from '../behavior'; 2 | 3 | export type Signatured = { 4 | signature: string; 5 | }; 6 | 7 | const withSignature = (signature: string): Signatured => ({ signature }); 8 | 9 | export default behavior({ 10 | $string: withSignature('string'), 11 | $number: withSignature('number'), 12 | $boolean: withSignature('boolean'), 13 | $array: ({ signature: typeName }) => withSignature(`Array<${typeName}>`), 14 | $dict: ({ signature: typeName }) => withSignature(`Dict<${typeName}>`), 15 | $struct: (stt) => 16 | withSignature( 17 | `{ ${Object.keys(stt) 18 | .map((key) => `${key}: ${stt[key].signature}`) 19 | .join('; ')} }`, 20 | ), 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/mind-map/render/layout/flex-tree/index.ts: -------------------------------------------------------------------------------- 1 | import { NodeInterface } from '../interface'; 2 | import doTreeLayout from './do-layout'; 3 | import hierarchy, { 4 | HierarchyOptions, 5 | Node, 6 | Direction, 7 | completeCollapseItems, 8 | } from './hierarchy'; 9 | import nonLayeredTidyTree from './non-layered-tidy'; 10 | 11 | function layout(root: T, options: HierarchyOptions): NodeInterface { 12 | const re = doTreeLayout( 13 | hierarchy(root, options, undefined), 14 | options, 15 | nonLayeredTidyTree, 16 | ); 17 | // re.centering(); 18 | completeCollapseItems(re, root, options); 19 | return re; 20 | } 21 | 22 | export type { HierarchyOptions, Direction }; 23 | export { Node }; 24 | export default layout; 25 | -------------------------------------------------------------------------------- /src/base/classnames.ts: -------------------------------------------------------------------------------- 1 | type StrMap = { [cls: string]: boolean | null | undefined }; 2 | type Plain = string | boolean | null | undefined; 3 | type StrArr = Array; 4 | 5 | function concact(list: StrArr): string { 6 | const res: string[] = []; 7 | for (const item of list) { 8 | if (Array.isArray(item)) { 9 | const s = concact(item); 10 | if (s) res.push(s); 11 | } else if (typeof item === 'object') { 12 | for (const key in item) { 13 | if (item[key]) res.push(key); 14 | } 15 | } else if (typeof item === 'string') { 16 | if (item) res.push(item); 17 | } 18 | } 19 | return res.join(' '); 20 | } 21 | 22 | export default function (...args: StrArr): string { 23 | return concact(args); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/mind-map/render/layout/outline/index.ts: -------------------------------------------------------------------------------- 1 | import { NodeInterface } from '../interface'; 2 | 3 | export default function layout(root: T): NodeInterface { 4 | // todo 5 | return { 6 | id: 'root', 7 | x: 0, 8 | y: 0, 9 | width: 0, 10 | height: 0, 11 | depth: 0, 12 | vgap: 0, 13 | hgap: 0, 14 | children: [], 15 | collapsed: false, 16 | inSize: { width: 0, height: 0 }, 17 | data: root, 18 | nodes() { 19 | return []; 20 | }, 21 | links() { 22 | return []; 23 | }, 24 | touchedLinks() { 25 | return []; 26 | }, 27 | isRoot() { 28 | return true; 29 | }, 30 | hasAncestor(node: NodeInterface): boolean { 31 | return false; 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { RouterProvider } from 'react-router-dom'; 4 | import './global.css'; 5 | 6 | import { WithStore } from '@base/atom'; 7 | import { router } from './router'; 8 | import { FluentProvider, webLightTheme } from '@fluentui/react-components'; 9 | 10 | const rootEl = document.getElementById('root'); 11 | if (rootEl) { 12 | const root = ReactDOM.createRoot(rootEl); 13 | root.render( 14 | 15 | 16 | 19 | 20 | 21 | 22 | , 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/presentation/page-node-content/page-node-content.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useMemo } from 'react'; 2 | import { markdown } from '../../mind-map/render/helpers/markdown'; 3 | 4 | interface PageNodeContentProps { 5 | content: string; 6 | className?: string; 7 | style?: React.CSSProperties; 8 | } 9 | 10 | export const PageNodeContent = (props: PageNodeContentProps) => { 11 | const { content, className, style } = props; 12 | const contentHTML = useMemo(() => markdown.makeHtml(content), [content]); 13 | return ( 14 |
22 | ); 23 | }; -------------------------------------------------------------------------------- /src/components/mind-map/render/layout/interface.ts: -------------------------------------------------------------------------------- 1 | export interface NodeInterface { 2 | id: string; 3 | x: number; 4 | y: number; 5 | width: number; 6 | height: number; 7 | depth: number; 8 | vgap: number; 9 | hgap: number; 10 | children: NodeInterface[]; 11 | collapsed: boolean; 12 | inSize: { width: number; height: number }; 13 | side?: 'left' | 'right'; 14 | parent?: NodeInterface; 15 | data: T; 16 | nodes(): Iterable>; 17 | links(): NodeLink[]; 18 | touchedLinks(): NodeLink[]; 19 | isRoot(): boolean; 20 | hasAncestor(node: NodeInterface): boolean; 21 | inCollapsedItem: boolean; 22 | draggingX?: number; 23 | draggingY?: number; 24 | } 25 | 26 | export interface NodeLink { 27 | source: NodeInterface; 28 | target: NodeInterface; 29 | } 30 | -------------------------------------------------------------------------------- /src/biz/store/index.ts: -------------------------------------------------------------------------------- 1 | import { atom } from '@root/base/atom'; 2 | 3 | export { filesAtom } from './files'; 4 | 5 | export const showSidepaneAtom = atom(true); 6 | 7 | const search = new URLSearchParams(location.search); 8 | const DefaultView: 'outline' | 'mindmap' = 9 | search.get('view') === 'outline' ? 'outline' : 'mindmap'; 10 | export const viewModeAtom = atom(DefaultView, (get, set) => { 11 | function updateURL(view: string) { 12 | const url = new URL(location.href); 13 | url.searchParams.set('view', view); 14 | history.pushState(null, '', url); 15 | } 16 | 17 | function showMindmap() { 18 | set('mindmap'); 19 | updateURL('mindmap'); 20 | } 21 | 22 | function showOutline() { 23 | set('outline'); 24 | updateURL('outline'); 25 | } 26 | return { showMindmap, showOutline }; 27 | }); 28 | -------------------------------------------------------------------------------- /src/model/ot-doc/algebra.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from './maybe'; 2 | 3 | export type Constant = () => T; 4 | export type UnaryOperator = (a: T) => T; 5 | export type PartialUnaryOperator = (a: T) => Maybe; 6 | export type BinaryOperator = (a: T) => UnaryOperator; 7 | export type PartialBinaryOperator = (a: T) => PartialUnaryOperator; 8 | export type Predicate = (a: T) => boolean; 9 | export type Relation = (a: T) => Predicate; 10 | 11 | // Use $PascalCase to represent a type class 12 | export type $Eq = { 13 | equals: Relation; 14 | }; 15 | 16 | export const $eqPrime = (): $Eq => ({ 17 | equals: (a) => (b) => a === b, 18 | }); 19 | 20 | export type $Ord = { 21 | lessThan: Relation; 22 | }; 23 | 24 | export const $ordPrime = (): $Ord => ({ 25 | lessThan: (a) => (b) => a < b, 26 | }); 27 | -------------------------------------------------------------------------------- /mai-mind-map-se/server/controllers/auth.config.ts: -------------------------------------------------------------------------------- 1 | import {Configuration, LogLevel } from '@azure/msal-node'; 2 | import { readConfig } from "../utils"; 3 | 4 | const config = readConfig(); 5 | export const msalConfig: Configuration = { 6 | auth: { 7 | clientId: config.CLIENT_ID, 8 | authority: config.CLOUD_INSTANCE + 'common', 9 | clientSecret: config.CLIENT_SECRET 10 | }, 11 | system: { 12 | loggerOptions: { 13 | loggerCallback(loglevel: LogLevel, message: string, containsPii: boolean) { 14 | console.log(message); 15 | }, 16 | piiLoggingEnabled: false, 17 | logLevel: 3, 18 | } 19 | } 20 | } 21 | 22 | export const REDIRECT_URI = config.REDIRECT_URI; 23 | export const POST_LOGOUT_REDIRECT_URI = config.POST_LOGOUT_REDIRECT_URI; 24 | export const GRAPH_ME_ENDPOINT = config.GRAPH_API_ENDPOINT + "v1.0/me"; 25 | -------------------------------------------------------------------------------- /src/biz/components/skeleton-block.tsx: -------------------------------------------------------------------------------- 1 | import clsnames from "@base/classnames"; 2 | import { css, keyframes } from "@root/base/styled"; 3 | import { CSSProperties, createElement } from "react"; 4 | 5 | const KLoading = keyframes` 6 | 0% { 7 | background-position: 100% 50%; 8 | } 9 | 100% { 10 | background-position: 0 50%; 11 | } 12 | `; 13 | 14 | const SBox = css` 15 | background-image: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%); 16 | height: 1rem; 17 | background-size: 400% 100%; 18 | background-position: 100% 50%; 19 | animation: ${KLoading} 1.4s ease infinite; 20 | `; 21 | 22 | function SkeletonBlock(props: { 23 | className?: string; 24 | style?: CSSProperties; 25 | }) { 26 | const { className, style } = props; 27 | return createElement('div', { 28 | className: clsnames(SBox, className), 29 | style, 30 | }, null); 31 | } 32 | 33 | export default SkeletonBlock; 34 | -------------------------------------------------------------------------------- /src/components/mind-map/render/hooks/observable-hook.ts: -------------------------------------------------------------------------------- 1 | import { Mutable, Observable } from '@root/model/observable'; 2 | import { debounce } from 'lodash'; 3 | import { useCallback, useEffect, useState } from 'react'; 4 | 5 | export function useMutable( 6 | mut: Mutable, 7 | ): [T, (updater: (value: T) => T) => void] { 8 | const [value, setValue] = useState(mut.peek()); 9 | useEffect(() => { 10 | setValue(mut.peek()); 11 | return mut.observe(setValue); 12 | }, [mut]); 13 | return [value, mut.update]; 14 | } 15 | 16 | export function useObservable(ob: Observable): T { 17 | const [value, setValue] = useState(ob.peek()); 18 | const debouncedSetValue: React.Dispatch> = 19 | useCallback(debounce(setValue, 1), []); 20 | useEffect(() => { 21 | setValue(ob.peek()); 22 | return ob.observe(debouncedSetValue); 23 | }, [ob, debouncedSetValue]); 24 | return value; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/mind-map/render/helpers/d3Helper.ts: -------------------------------------------------------------------------------- 1 | import { D3DragEvent, DraggedElementBaseType, drag } from 'd3-drag'; 2 | 3 | export function preventDrag() { 4 | return drag() 5 | .on( 6 | 'start', 7 | (event: D3DragEvent) => { 8 | // event.sourceEvent.preventDefault(); 9 | event.sourceEvent.stopPropagation(); 10 | }, 11 | ) 12 | .on( 13 | 'drag', 14 | (event: D3DragEvent) => { 15 | // event.sourceEvent.preventDefault(); 16 | event.sourceEvent.stopPropagation(); 17 | }, 18 | ) 19 | .on( 20 | 'end', 21 | (event: D3DragEvent) => { 22 | // event.sourceEvent.preventDefault(); 23 | event.sourceEvent.stopPropagation(); 24 | }, 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MAI-MIND-MAP 2 | Turn every thing into mind map. 3 | Preview: https://mai-mind-map.azurewebsites.net/ 4 | 5 | ## Setup 6 | 7 | Install the dependencies: 8 | 9 | ```bash 10 | pnpm install 11 | ``` 12 | Please install VS-code Extension:`Biome` for better code lint 13 | 14 | 15 | ## Get Started 16 | 17 | Start the dev server: 18 | 19 | ```bash 20 | pnpm dev 21 | ``` 22 | 23 | Build the app for production: 24 | 25 | ```bash 26 | pnpm build 27 | ``` 28 | 29 | Preview the production build locally: 30 | 31 | ```bash 32 | pnpm preview 33 | ``` 34 | 35 | ## Service Server 36 | 37 | Install the dependencies: 38 | 39 | ```bash 40 | cd mai-mind-map-se && pnpm install 41 | ``` 42 | 43 | Start the server: 44 | 45 | ```bash 46 | pnpm build:server && pnpm start 47 | ``` 48 | 49 | Start the server for local debug 50 | ```bash 51 | pnpm build:server && pnpm start:2999 52 | # Reach out jianliwei/Emailxuri for the config.json to start server in local environment 53 | ``` 54 | -------------------------------------------------------------------------------- /src/components/mind-map/render/layout/index.ts: -------------------------------------------------------------------------------- 1 | import flexTreeLayout, { HierarchyOptions } from './flex-tree'; 2 | import { NodeInterface } from './interface'; 3 | import outlineLayout from './outline'; 4 | 5 | export { type Direction, type HierarchyOptions } from './flex-tree'; 6 | export * from './interface'; 7 | export type FLEX_TREE = 1; 8 | export type OUTLINE = 2; 9 | 10 | export enum LayoutType { 11 | FLEX_TREE = 1, 12 | OUTLINE = 2, 13 | } 14 | 15 | function layoutFun( 16 | layoutType: FLEX_TREE, 17 | ): (root: T, options: HierarchyOptions) => NodeInterface; 18 | function layoutFun(layoutType: OUTLINE): (root: T) => NodeInterface; 19 | function layoutFun(layoutType: FLEX_TREE | OUTLINE) { 20 | switch (layoutType) { 21 | case LayoutType.FLEX_TREE: 22 | return flexTreeLayout; 23 | case LayoutType.OUTLINE: 24 | return outlineLayout; 25 | default: 26 | return flexTreeLayout; 27 | } 28 | } 29 | 30 | export default layoutFun; 31 | -------------------------------------------------------------------------------- /src/base/copy-text.ts: -------------------------------------------------------------------------------- 1 | import { css } from './styled'; 2 | 3 | const HiddenStyle = css` 4 | position: fixed; 5 | left: -9999px; 6 | top: -9999px; 7 | z-index: -9999; 8 | `; 9 | function fallbackCopyTextToClipboard(text: string) { 10 | const textArea = document.createElement('textarea'); 11 | textArea.classList.add(HiddenStyle); 12 | textArea.value = text; 13 | document.body.appendChild(textArea); 14 | textArea.focus(); 15 | textArea.select(); 16 | 17 | try { 18 | return document.execCommand('copy'); 19 | } catch (err) { 20 | return false; 21 | } finally { 22 | document.body.removeChild(textArea); 23 | } 24 | } 25 | 26 | function copyTextToClipboard(text: string) { 27 | if (!navigator.clipboard) { 28 | return Promise.resolve(fallbackCopyTextToClipboard(text)); 29 | } 30 | return navigator.clipboard 31 | .writeText(text) 32 | .then(() => true) 33 | .catch((err) => fallbackCopyTextToClipboard(text)); 34 | } 35 | 36 | export default copyTextToClipboard; 37 | -------------------------------------------------------------------------------- /src/model/data/struct.ts: -------------------------------------------------------------------------------- 1 | import { $ } from './higher-kinded-type'; 2 | 3 | export type Dict = Record; 4 | export type AnyDict = Dict; 5 | export type $Struct = { [K in keyof S]: $ }; 6 | 7 | export const reduceStruct = , U>( 8 | t: T, 9 | f: (u: U, v: T[K], key: K) => U, 10 | u: U, 11 | ): U => Object.keys(t).reduce((m, k) => f(m, t[k], k), u); 12 | 13 | export const mapStruct = , F>( 14 | t: T, 15 | f: (v: T[K], key: K) => $, 16 | ): $Struct => 17 | reduceStruct( 18 | t, 19 | (m, v, k) => { 20 | m[k] = f(v, k); 21 | return m; 22 | }, 23 | {} as $Struct, 24 | ); 25 | 26 | export const reduceDict = reduceStruct as ( 27 | dict: Dict, 28 | f: (u: U, v: T, key: string) => U, 29 | u: U, 30 | ) => U; 31 | 32 | export const mapDict = mapStruct as ( 33 | dict: Dict, 34 | f: (t: T, key: string) => V, 35 | ) => Dict; 36 | -------------------------------------------------------------------------------- /src/model/ot-doc/document.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Constant, 3 | PartialBinaryOperator, 4 | PartialUnaryOperator, 5 | Relation, 6 | UnaryOperator, 7 | } from './algebra'; 8 | 9 | export type $Init = { 10 | initial: Constant; 11 | }; 12 | 13 | export const $init = (cp: Cp): $Init => ({ initial: () => cp }); 14 | 15 | export type $Comp = { 16 | compose: (op: Op) => PartialUnaryOperator; 17 | }; 18 | 19 | export type $Inv = { 20 | invert: UnaryOperator; 21 | }; 22 | 23 | export type $Idn = { 24 | identity: Constant; 25 | }; 26 | 27 | export const $idn = (op: Op): $Idn => ({ identity: () => op }); 28 | 29 | export type $Tran = { 30 | transform: PartialBinaryOperator; 31 | }; 32 | 33 | export type $BaseDoc = $Init & 34 | $Idn & 35 | $Comp & { 36 | cpEquals: Relation; 37 | opEquals: Relation; 38 | }; 39 | 40 | export type $InvDoc = $BaseDoc & $Inv; 41 | 42 | export type $FullDoc = $InvDoc & $Tran; 43 | -------------------------------------------------------------------------------- /src/biz/side-pane/head.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@root/base/styled"; 2 | import icons from "../components/icons"; 3 | import { SideHome } from "./home"; 4 | 5 | const SBox = css` 6 | display: flex; 7 | align-items: center; 8 | justify-content: space-between; 9 | padding: 0 8px; 10 | height: 50px; 11 | 12 | &>.fold-side { 13 | cursor: pointer; 14 | width: 18px; 15 | display: flex; 16 | align-items: center; 17 | } 18 | `; 19 | const SLeft = css` 20 | display: flex; 21 | align-items: center; 22 | gap: 6px; 23 | white-space: nowrap; 24 | font-size: 14px; 25 | font-weight: bold; 26 | `; 27 | 28 | export function SideHead(props: { 29 | showSide: (v: boolean) => void; 30 | }) { 31 | const { showSide } = props; 32 | 33 | return ( 34 |
35 |
36 | 37 | Ms Mind Map 38 |
39 |
showSide(false)}> 40 | {icons.fold} 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /office-addin/src/taskpane/taskpane.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Mind map add-in 12 | 13 | 14 | 15 | 16 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/model/data/behaviors/eq.ts: -------------------------------------------------------------------------------- 1 | import { Relation } from '@root/model/ot-doc/algebra'; 2 | import { behavior } from '../behavior'; 3 | import { $Var } from '../higher-kinded-type'; 4 | 5 | export type Eq = { eq: Relation }; 6 | 7 | const withEq = (f: Relation = () => () => false): Eq => ({ 8 | eq: (a) => (b) => a === b || f(a)(b), 9 | }); 10 | 11 | export default behavior({ 12 | $string: withEq(), 13 | $number: withEq(), 14 | $boolean: withEq(), 15 | $array: ({ eq }) => 16 | withEq((a) => (b) => { 17 | if (a.length !== b.length) return false; 18 | for (let i = 0; i < a.length; i += 1) if (!eq(a[i])(b[i])) return false; 19 | return true; 20 | }), 21 | $dict: ({ eq }) => 22 | withEq((a) => (b) => { 23 | for (const key in a) if (!(key in b) || !eq(a[key])(b[key])) return false; 24 | for (const key in b) if (!(key in a)) return false; 25 | return true; 26 | }), 27 | $struct: (stt) => 28 | withEq((a) => (b) => { 29 | for (const key in stt) if (!stt[key].eq(a[key])(b[key])) return false; 30 | return true; 31 | }), 32 | }); 33 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.0/schema.json", 3 | 4 | "files": { 5 | "ignore": ["office-addin/*"] 6 | }, 7 | 8 | "organizeImports": { 9 | "enabled": true 10 | }, 11 | "vcs": { 12 | "enabled": true, 13 | "clientKind": "git", 14 | "useIgnoreFile": true 15 | }, 16 | "formatter": { 17 | "indentStyle": "space", 18 | "include": [ 19 | "src/**/*.json", 20 | "src/**/*.ts", 21 | "src/**/*.tsc", 22 | "src/*.json", 23 | "src/*.ts", 24 | "src/*.tsc" 25 | ] 26 | }, 27 | "javascript": { 28 | "formatter": { 29 | "quoteStyle": "single" 30 | } 31 | }, 32 | "linter": { 33 | "enabled": true, 34 | "include": [ 35 | "src/**/*.json", 36 | "src/**/*.ts", 37 | "src/**/*.tsc", 38 | "src/*.json", 39 | "src/*.ts", 40 | "src/*.tsc" 41 | ], 42 | "rules": { 43 | "recommended": true, 44 | "style": { 45 | "useImportType": "off", 46 | "noUselessElse": "off" 47 | }, 48 | "suspicious": { 49 | "noExplicitAny": "off" 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /mai-mind-map-se/server/storage/pool.ts: -------------------------------------------------------------------------------- 1 | import { Pool, PoolConnection } from 'mysql'; 2 | const pool: Pool = require('./mysql'); 3 | 4 | interface QueryCallback { 5 | (error: Error | null, result?: { rows: any }): void; 6 | } 7 | 8 | /** 9 | * Executes a SQL query using a connection from the pool. 10 | * 11 | * @param query - The SQL query string to be executed. 12 | * @param callback - A callback function that handles the result of the query. 13 | * 14 | * @throws Will throw an error if there is an issue obtaining a connection or 15 | * executing the query. 16 | */ 17 | export function executeQuery(query: string, callback: QueryCallback): void { 18 | pool.getConnection((err: Error, connection: PoolConnection) => { 19 | if (err) { 20 | if (connection) connection.release(); 21 | throw err; 22 | } 23 | 24 | connection.query(query, (err: Error, rows: any) => { 25 | connection.release(); 26 | if (!err) { 27 | callback(null, { rows }); 28 | } else { 29 | callback(err); 30 | } 31 | }); 32 | 33 | connection.on('error', (err: Error) => { 34 | throw err; 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /office-addin/src/landing-page/landing-page.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. 3 | * See LICENSE in the project root for license information. 4 | */ 5 | 6 | import { css, customElement, FASTElement, html } from "@microsoft/fast-element"; 7 | 8 | const template = html`Signed in`; 9 | const styles = css``; 10 | 11 | 12 | 13 | @customElement({ 14 | name: "landing-page", 15 | template, 16 | styles, 17 | }) 18 | export class TaskPane extends FASTElement { 19 | connectedCallback(): void { 20 | Office.onReady( () => { 21 | console.log("Office is ready"); 22 | fetch(`${location.origin}/cookie/unrestrict`, { 23 | method: "GET" 24 | }).then((response) => { 25 | if(response.ok) { 26 | this.notifySignedIn(); 27 | } 28 | }) 29 | // Add any initialization code for your dialog here. 30 | }); 31 | } 32 | 33 | private async notifySignedIn() { 34 | const signInMessage = { 35 | success: true, 36 | id: "", 37 | name: "", 38 | email: "", 39 | }; 40 | Office.context.ui.messageParent(JSON.stringify(signInMessage)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/presentation/presentation-model/presentation-page.tsx: -------------------------------------------------------------------------------- 1 | import { PresentationNode } from '../presentation-model/presentation-node'; 2 | 3 | enum PresentationMode { 4 | NODE, 5 | TREE, 6 | } 7 | 8 | interface PresentationPage { 9 | mode: PresentationMode; 10 | node: PresentationNode; 11 | 12 | // for mode TREE 13 | child: PresentationNode | null; 14 | } 15 | 16 | class PresentationPageBase implements PresentationPage { 17 | mode: PresentationMode; 18 | node: PresentationNode; 19 | child: PresentationNode | null; 20 | 21 | constructor(node: PresentationNode, child: PresentationNode | null = null) { 22 | this.mode = child ? PresentationMode.TREE : PresentationMode.NODE; 23 | this.child = child; 24 | this.node = node; 25 | } 26 | 27 | toString(): string { 28 | if (this.mode === PresentationMode.NODE) { 29 | return `Node(${this.node.id})`; 30 | } else { 31 | return `Tree(${this.node.id}-${this.child ? this.child.id : ''})`; 32 | } 33 | } 34 | } 35 | 36 | export { 37 | PresentationPageBase, 38 | PresentationMode, 39 | } 40 | 41 | export type { 42 | PresentationPage, 43 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /src/components/mind-map/render/hooks/constants.ts: -------------------------------------------------------------------------------- 1 | import { Selection } from 'd3-selection'; 2 | import { type EditingNodeType } from '../../EditingNode'; 3 | import { Direction } from '../layout/flex-tree/hierarchy'; 4 | import { NodeInterface } from '../layout/interface'; 5 | import { SizedRawNode } from '../node/interface'; 6 | 7 | export enum LinkMode { 8 | CURVE = 1, 9 | LINE = 2, 10 | HYBRID = 3, 11 | } 12 | 13 | export enum ColorMode { 14 | DEFAULT = 1, 15 | COLORFUL = 2, 16 | } 17 | 18 | export interface RenderOptions { 19 | direction: Direction; 20 | scale: number; 21 | linkMode: LinkMode; 22 | colorMode: ColorMode; 23 | } 24 | 25 | export interface TreeState { 26 | dragging: boolean; 27 | direction: Direction; 28 | scale: number; 29 | linkMode: LinkMode; 30 | colorMode: ColorMode; 31 | moveNodeTo: (nodeId: string, targetId: string, index: number) => void; 32 | setPendingEditNode: (node: EditingNodeType | null) => void; 33 | } 34 | export interface Drawing { 35 | drawingGroup: Selection; 36 | dragGroup: Selection; 37 | nodeGroup: Selection; 38 | pathGroup: Selection; 39 | } 40 | -------------------------------------------------------------------------------- /src/components/mind-map/render/layout/flex-tree/separate-root.ts: -------------------------------------------------------------------------------- 1 | import hierarchy, { Node, HierarchyOptions } from './hierarchy'; 2 | 3 | function separate(root: Node, options: HierarchyOptions) { 4 | // separate into left and right trees 5 | const left = hierarchy(root.data, options, true); // root only 6 | const right = hierarchy(root.data, options, true); // root only 7 | // automatically 8 | const treeSize = root.children.length; 9 | const rightTreeSize = Math.round(treeSize / 2); 10 | // separate left and right tree by meta data 11 | const getSide = 12 | options.getSide || 13 | ((child, index) => { 14 | if (index < rightTreeSize) return 'right'; 15 | return 'left'; 16 | }); 17 | 18 | for (let i = 0; i < treeSize; i++) { 19 | const child = root.children[i]; 20 | const side = getSide(child, i); 21 | if (side === 'right') { 22 | right.children.push(child); 23 | } else { 24 | left.children.push(child); 25 | } 26 | } 27 | 28 | left.DFTraverse((node) => { 29 | if (!node.isRoot()) node.side = 'left'; 30 | }); 31 | 32 | right.DFTraverse((node) => { 33 | if (!node.isRoot()) node.side = 'right'; 34 | }); 35 | 36 | return { left, right }; 37 | } 38 | 39 | export default separate; 40 | -------------------------------------------------------------------------------- /src/model/queue.ts: -------------------------------------------------------------------------------- 1 | const HEAD_VALUE = Symbol(); 2 | 3 | type Item = { 4 | value: T | typeof HEAD_VALUE; 5 | prev: Item; 6 | next: Item; 7 | }; 8 | 9 | export type Queue = { 10 | isEmpty(): boolean; 11 | toArray(): T[]; 12 | enqueue(value: T): () => void; 13 | clear(): void; 14 | }; 15 | 16 | export function queue(): Queue { 17 | const head = { value: HEAD_VALUE } as Item; 18 | 19 | function circle(item: Item) { 20 | item.prev = item.next = item; 21 | } 22 | 23 | function clear() { 24 | circle(head); 25 | } 26 | 27 | function isEmpty(): boolean { 28 | return head.prev === head; 29 | } 30 | 31 | function toArray(): T[] { 32 | const arr: T[] = []; 33 | let iter = head.next; 34 | while (iter.value !== HEAD_VALUE) { 35 | arr.push(iter.value); 36 | iter = iter.next; 37 | } 38 | return arr; 39 | } 40 | 41 | function enqueue(value: T): () => void { 42 | const item = { value, prev: head.prev, next: head }; 43 | head.prev = head.prev.next = item; 44 | return () => { 45 | item.prev.next = item.next; 46 | item.next.prev = item.prev; 47 | circle(item); 48 | }; 49 | } 50 | 51 | circle(head); 52 | 53 | return { isEmpty, toArray, clear, enqueue }; 54 | } 55 | -------------------------------------------------------------------------------- /src/model/data/behaviors/test.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorBuilder } from '../behavior'; 2 | import { $d, $s, updatePrim } from '../op'; 3 | import editable from './editable'; 4 | import eq from './eq'; 5 | import preset from './preset'; 6 | import readable, { readData } from './readable'; 7 | import signatured from './signatured'; 8 | 9 | const { $string, $number, $boolean, $array, $dict, $struct } = 10 | BehaviorBuilder.mixin(preset) 11 | .mixin(signatured) 12 | .mixin(readable) 13 | .mixin(eq) 14 | .mixin(editable) 15 | .build(); 16 | 17 | const myDocType = $struct({ 18 | foo: $dict($string), 19 | bar: $array($number), 20 | tic: $boolean, 21 | }); 22 | 23 | console.log('Format', myDocType.signature); 24 | 25 | const content = { bar: [1, 2, '123'] }; 26 | console.log('Read', JSON.stringify(content)); 27 | const readDoc = readData(myDocType); 28 | const doc = readDoc(content, console.log); 29 | 30 | console.log('Result', JSON.stringify(doc)); 31 | 32 | const p = updatePrim(Date.now()); 33 | console.log( 34 | 'Composed', 35 | JSON.stringify( 36 | myDocType.update( 37 | $s({ 38 | foo: $d({ Hello: p('World') }), 39 | bar: (_) => ({ i: [{ i: 1, a: [3, 4, 5] }], d: [] }), 40 | tic: p(true), 41 | }), 42 | )(doc), 43 | ), 44 | ); 45 | -------------------------------------------------------------------------------- /office-addin/README.md: -------------------------------------------------------------------------------- 1 | ## Guidance (before public release) 2 | 3 | ### Prerequisite 4 | * Word (browser version) 5 | * Nodejs >= v20.x.xx 6 | 7 | ```shell 8 | npm install -g pnpm 9 | ``` 10 | 11 | 12 | ### Run in local 13 | under `./office-addin` 14 | ```shell 15 | 16 | pnpm install 17 | 18 | npm run start:dev # Load Addin in local office from dev env 19 | # or 20 | npm run start:prod # Load Addin in local office from prod env 21 | ``` 22 | 23 | 24 | ### Manual Installation on Web Office 25 | 26 | 1. Download https://mai-mind-map.azurewebsites.net/addin/manifest.xml or https://dev-mai-mind-map.azurewebsites.net/addin/manifest-dev.xml 27 | 28 | ![image](https://github.com/user-attachments/assets/c8fa6598-3c08-4f10-a44e-aeb6561de0c7) 29 | 2. In **My Add-ins** -> **Manage My Add-ins**, click **Upload My Add-in** 30 | 31 | ![image](https://github.com/user-attachments/assets/4d580d87-77b5-4675-91b0-0cdf2e2e3b57) 32 | 3. Upload downloaded `manifest.xml` in step 1. 33 | 34 | ![image](https://github.com/user-attachments/assets/047312d6-c9a5-43cd-a0c2-0913bf615dc4) 35 | 4. If successfully installed, there should be an action button on top right of toolbar. Click to open the add-in task panel. 36 | 37 | ![image](https://github.com/user-attachments/assets/fb36bf6e-85bd-43d2-8a0f-e937db49c0f3) 38 | 39 | -------------------------------------------------------------------------------- /src/components/presentation/page-view/page-view.css: -------------------------------------------------------------------------------- 1 | .presentation-view-component { 2 | position: absolute; 3 | left: 0px; 4 | right: 0px; 5 | top: 0px; 6 | bottom: 0px; 7 | } 8 | 9 | .presentation-view-main-area { 10 | position: absolute; 11 | left: 0px; 12 | right: 0px; 13 | top: 0px; 14 | bottom: 0px; 15 | 16 | display: flex; 17 | flex-direction: row; 18 | align-items: center; 19 | justify-content: center; 20 | } 21 | 22 | .presentation-view-main-title { 23 | color: white; 24 | 25 | text-align: center; 26 | 27 | p { 28 | margin: 0px; 29 | } 30 | } 31 | 32 | .presentation-view-main-description { 33 | color: #646a73; 34 | 35 | text-align: center; 36 | } 37 | 38 | .presentation-view-main-img { 39 | object-fit: contain; 40 | } 41 | 42 | .presentation-view-tree-skeleton { 43 | position: absolute; 44 | width: 100%; 45 | height: 100%; 46 | left: 0px; 47 | right: 0px; 48 | top: 0px; 49 | bottom: 0px; 50 | } 51 | 52 | .presentation-view-tree-content { 53 | display: grid; 54 | overflow: scroll; 55 | scrollbar-width: none; 56 | } 57 | 58 | .presentation-view-tree-item-title { 59 | color: white; 60 | overflow: hidden; 61 | scrollbar-width: none; 62 | 63 | p { 64 | margin: 0px; 65 | } 66 | } -------------------------------------------------------------------------------- /src/model/ot-doc/timestamped.ts: -------------------------------------------------------------------------------- 1 | import { $Eq, $Ord, $eqPrime, $ordPrime } from './algebra'; 2 | import { $FullDoc, $Init, $init } from './document'; 3 | import { $fullDocGww, Update } from './singleton'; 4 | 5 | // t: timestamp 6 | // v: value 7 | export type Timestamped = { t: number; v: T }; 8 | 9 | export const $eqTimestamped = ({ equals }: $Eq): $Eq> => ({ 10 | equals: (a) => (b) => a.t === b.t && equals(a.v)(b.v), 11 | }); 12 | 13 | export const $ordTimestamped = ({ 14 | lessThan, 15 | }: $Ord): $Ord> => ({ 16 | lessThan: (a) => (b) => a.t < b.t || (a.t === b.t && lessThan(a.v)(b.v)), 17 | }); 18 | 19 | export const $fullDocLww = ( 20 | cls: $Eq & $Init & $Ord, 21 | ): $FullDoc, Update>> => 22 | $fullDocGww({ 23 | ...$init({ t: Number.POSITIVE_INFINITY, v: cls.initial() }), 24 | ...$eqTimestamped(cls), 25 | ...$ordTimestamped(cls), 26 | }); 27 | 28 | export const $fullDocLwwString = $fullDocLww({ 29 | ...$init(''), 30 | ...$eqPrime(), 31 | ...$ordPrime(), 32 | }); 33 | 34 | export const $fullDocLwwNumber = $fullDocLww({ 35 | ...$init(0), 36 | ...$eqPrime(), 37 | ...$ordPrime(), 38 | }); 39 | 40 | export const $fullDocLwwBoolean = $fullDocLww({ 41 | ...$init(false), 42 | ...$eqPrime(), 43 | ...$ordPrime(), 44 | }); 45 | -------------------------------------------------------------------------------- /src/base/eventer.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) Microsoft Corporation. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | interface Maper { 6 | [key: string]: T; 7 | } 8 | type Listen = (data: T) => void; 9 | 10 | export class Eventer> { 11 | // biome-ignore lint/complexity/noBannedTypes: 12 | private lisenters = {} as { [K in keyof M]: Set }; 13 | 14 | public on(key: K, lisenter: Listen) { 15 | let set = this.lisenters[key]; 16 | if (set === undefined) { 17 | set = new Set(); 18 | this.lisenters[key] = set; 19 | } 20 | 21 | set.add(lisenter); 22 | return () => this.off(key, lisenter); 23 | } 24 | 25 | public off(key: K, lisenter?: Listen) { 26 | const set = this.lisenters[key]; 27 | if (set === undefined) return; 28 | if (lisenter === undefined) set.clear(); 29 | else set.delete(lisenter); 30 | } 31 | 32 | public emit(key: K, data: M[K]) { 33 | const set = this.lisenters[key]; 34 | if (set === undefined) return; 35 | // Todo: maybe implement stoping bubble 36 | // biome-ignore lint/complexity/noForEach: 37 | set.forEach((call) => call(data)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /mai-mind-map-se/server/loop/index.ts: -------------------------------------------------------------------------------- 1 | import { OnBehalfOfCredential } from '@azure/identity'; 2 | import { Client } from '@microsoft/microsoft-graph-client'; 3 | import 'isomorphic-fetch'; 4 | import { readConfig } from '../utils'; 5 | 6 | const config = readConfig(); 7 | 8 | export const createLoopDocument = async (accessToken: string) => { 9 | try { 10 | const oboCredential = new OnBehalfOfCredential({ 11 | tenantId: config.TENANT_ID, 12 | clientId: config.CLIENT_ID, 13 | clientSecret: config.CLIENT_SECRET, 14 | userAssertionToken: accessToken, 15 | }); 16 | 17 | const client = Client.initWithMiddleware({ 18 | authProvider: { 19 | getAccessToken: async () => { 20 | const tokenResponse = await oboCredential.getToken(['Files.ReadWrite.All']); 21 | return tokenResponse.token; 22 | } 23 | } 24 | }); 25 | 26 | const loopDoc = { 27 | name: 'New Loop Document', 28 | file: { 29 | '@microsoft.graph.conflictBehavior': 'rename', 30 | content: 'Your Loop document content here' 31 | } 32 | }; 33 | 34 | const response = await client.api('/me/drive/root/children').post(loopDoc); 35 | 36 | console.log("Loop Document Uploaded to OneDrive:", response); 37 | 38 | } catch (error) { 39 | console.error("Error creating Loop document in OneDrive:", error); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /mai-mind-map-se/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mai-mind-map", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node ./bin/www", 8 | "start:2999": "cross-env PORT=2999 node ./bin/www", 9 | "build:server": "tsc -p server-tsconfig.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1", 15 | "dependencies": { 16 | "@azure/identity": "4.4.1", 17 | "@azure/msal-node": "2.14.0", 18 | "@azure/storage-blob": "12.24.0", 19 | "@microsoft/microsoft-graph-client": "3.0.7", 20 | "axios": "1.7.7", 21 | "cookie-parser": "1.4.6", 22 | "cors": "2.8.5", 23 | "express": "4.20.0", 24 | "express-session": "1.18.0", 25 | "isomorphic-fetch": "3.0.0", 26 | "marked": "14.1.2", 27 | "mysql": "2.18.1", 28 | "uuid": "10.0.0" 29 | }, 30 | "devDependencies": { 31 | "@types/cookie-parser": "1.4.7", 32 | "@types/cors": "2.8.17", 33 | "@types/express": "4.17.21", 34 | "@types/express-session": "1.18.0", 35 | "@types/isomorphic-fetch": "0.0.39", 36 | "@types/mysql": "2.15.26", 37 | "@types/uuid": "10.0.0", 38 | "cross-env": "^7.0.3", 39 | "typescript": "5.5.4" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /office-addin/src/taskpane/components/message-container.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. 3 | * See LICENSE in the project root for license information. 4 | */ 5 | 6 | import { attr, css, customElement, FASTElement, html, when } from "@microsoft/fast-element"; 7 | import { provideFluentDesignSystem, fluentProgressRing } from "@fluentui/web-components"; 8 | 9 | provideFluentDesignSystem().register(fluentProgressRing()); 10 | 11 | const template = html` 12 |
13 |
${when((x) => x.showLoading, html``)}
14 |
15 |
16 | `; 17 | 18 | const styles = css` 19 | :host { 20 | display: block; 21 | } 22 | 23 | #container { 24 | display: flex; 25 | align-items: center; 26 | gap: 10px; 27 | } 28 | 29 | #spinner-container { 30 | width: fit-content; 31 | flex-grow: 0; 32 | flex-shrink: 0; 33 | } 34 | 35 | #message-container { 36 | width: 100%; 37 | flex-grow: 1; 38 | } 39 | `; 40 | 41 | @customElement({ 42 | name: "message-container", 43 | template, 44 | styles, 45 | }) 46 | export class MessageContainer extends FASTElement { 47 | @attr showLoading: boolean = false; 48 | } 49 | -------------------------------------------------------------------------------- /src/biz/head/more.tsx: -------------------------------------------------------------------------------- 1 | import clsnames from "@base/classnames"; 2 | import { css } from "@root/base/styled"; 3 | import { useCallback, useState } from "react"; 4 | import icons from "../components/icons"; 5 | import Popup from "../components/popup"; 6 | 7 | const SMore = css` 8 | display: flex; 9 | align-items: center; 10 | height: 28px; 11 | width: 28px; 12 | padding: 6px; 13 | border-radius: 4px; 14 | cursor: pointer; 15 | 16 | &:hover, &.more-active { 17 | background-color: rgba(0,0,0,0.04); 18 | } 19 | `; 20 | const SPannel = css` 21 | height: 400px; 22 | width: 200px; 23 | `; 24 | 25 | function Pannel(props: { 26 | position: [x: number, y: number]; 27 | hide: () => void; 28 | }) { 29 | const { hide, position: [right, top] } = props; 30 | return ( 31 | 32 |
33 |
34 |
35 | ); 36 | } 37 | 38 | export function More() { 39 | const [popup, setPopup] = useState<[number, number] | null>(null); 40 | const hide = useCallback(() => setPopup(null), []); 41 | return <> 42 |
{ 45 | const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); 46 | setPopup([window.innerWidth - rect.right, rect.bottom + 4]); 47 | }} 48 | > 49 | {icons.more} 50 |
51 | {popup && ( 52 | 53 | )} 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/biz/side-pane/file-tree/rename.tsx: -------------------------------------------------------------------------------- 1 | import { useChange } from "@root/base/atom"; 2 | import { css } from "@root/base/styled"; 3 | import { filesAtom } from "@root/biz/store"; 4 | import { FileInfo } from "@root/model/api"; 5 | import { useEffect, useRef, useState } from "react"; 6 | 7 | 8 | const SRename = css` 9 | flex: 1 1 100%; 10 | border: 1px solid #ccc; 11 | border-radius: 2px; 12 | padding: 4px 6px; 13 | min-width: 200px; 14 | &:focus { 15 | outline: none; 16 | box-shadow: rgba(0, 106, 254, 0.12) 0px 0px 0px 2px; 17 | border-color: rgba(63,133,255,1); 18 | } 19 | `; 20 | 21 | export function Rename(props: { 22 | file: FileInfo; 23 | exit: VoidFunction; 24 | }) { 25 | const { id, title = 'Untitled' } = props.file; 26 | const [text, edit] = useState(title); 27 | const ref = useRef(null); 28 | const actions = useChange(filesAtom)[1]; 29 | 30 | useEffect(() => { 31 | ref.current?.focus(); 32 | }, []); 33 | 34 | return ( 35 | edit(e.target.value)} 40 | onBlur={() => { 41 | if (text !== title) { 42 | actions.update(id, { title: text }); 43 | } 44 | props.exit(); 45 | }} 46 | onKeyDown={(e) => { 47 | if (e.key === 'Escape') { 48 | props.exit(); 49 | } else if (e.key === 'Enter') { 50 | (e.target as HTMLInputElement).blur(); 51 | } 52 | }} 53 | /> 54 | ); 55 | } 56 | 57 | -------------------------------------------------------------------------------- /src/biz/side-pane/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "@root/base/atom"; 2 | import { css } from "@root/base/styled" 3 | import { useEffect, useState } from "react"; 4 | import { useNavigate, useParams } from "react-router-dom"; 5 | import { filesAtom, showSidepaneAtom } from "../store"; 6 | import { FileTree } from "./file-tree"; 7 | import { SideHead } from "./head"; 8 | import { Search } from "./search"; 9 | 10 | const SBox = css` 11 | width: 300px; 12 | height: 100%; 13 | border-right: 1px solid #eaeaea; 14 | display: flex; 15 | flex-direction: column; 16 | background-color: rgba(250,251,251,1); 17 | &>div { 18 | flex: 0 0 auto; 19 | } 20 | `; 21 | 22 | export function SidePane() { 23 | const [{ files, loading }, ,actions] = useAtom(filesAtom); 24 | const [sidepaneVisible, showSide] = useAtom(showSidepaneAtom); 25 | const [filter, setFilter] = useState(''); 26 | const { fileId } = useParams(); 27 | const navigate = useNavigate(); 28 | 29 | useEffect(() => { 30 | actions.fetchFilesOnce(navigate, fileId); 31 | }, []); 32 | 33 | if (!sidepaneVisible) return; 34 | 35 | const filteredFiles = filter 36 | ? files.filter(({ id, title = 'Untitled' }) => { 37 | if (id === fileId) return true; 38 | return title.toLocaleLowerCase().includes(filter.toLocaleLowerCase()); 39 | }) 40 | : files; 41 | 42 | return ( 43 |
44 | 45 | 46 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/model/ot-doc/singleton.ts: -------------------------------------------------------------------------------- 1 | import { $Eq, $Ord } from './algebra'; 2 | import { $FullDoc, $Init, $InvDoc, $idn } from './document'; 3 | import { just, nothing } from './maybe'; 4 | 5 | // f: update from value 6 | // t: update to value 7 | export type Update
= { f: A; t: A } | null; 8 | 9 | // Eq a => Eq (Update a) 10 | export const $eqUpdate = ({ equals }: $Eq): $Eq> => ({ 11 | equals: (a) => (b) => { 12 | if (a === b) { 13 | return true; 14 | } 15 | if (!a || !b) { 16 | return false; 17 | } 18 | return equals(a.f)(b.f) && equals(a.t)(b.t); 19 | }, 20 | }); 21 | 22 | // Eq a, Initial a => $InvDoc a (Update a) 23 | export const $invDocUpdate = ({ 24 | equals, 25 | initial, 26 | }: $Eq & $Init): $InvDoc> => ({ 27 | initial, 28 | ...$idn(null), 29 | compose: (op) => 30 | op ? (v) => (equals(v)(op.f) ? just(op.t) : nothing()) : just, 31 | invert: (op) => (op ? { f: op.t, t: op.f } : null), 32 | cpEquals: equals, 33 | opEquals: $eqUpdate({ equals }).equals, 34 | }); 35 | 36 | // Greater Write Win 37 | // Eq a, Initial a, Ord a => $FullDoc a (Update a) 38 | export const $fullDocGww = ({ 39 | equals, 40 | initial, 41 | lessThan, 42 | }: $Eq & $Init & $Ord): $FullDoc> => ({ 43 | ...$invDocUpdate({ equals, initial }), 44 | transform: (opA) => (opB) => { 45 | if (!opA) return just(null); 46 | if (!opB) return just(opA); 47 | const { f: fA, t: tA } = opA; 48 | const { f: fB, t: tB } = opB; 49 | if (!equals(fA)(fB)) return nothing(); 50 | return just(lessThan(tA)(tB) ? { f: tA, t: tB } : null); 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /src/base/styled.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { compile, stringify } from 'stylis'; 3 | 4 | const container = document.createElement('style'); 5 | document.head.append(container); 6 | // biome-ignore lint/style/noNonNullAssertion: 7 | const sheet = container.sheet!; 8 | 9 | function uuid() { 10 | return `s-${Math.round((Math.random() + 1) * Date.now()).toString(36)}`; 11 | } 12 | 13 | function insert(head: string, body: string) { 14 | sheet.insertRule(`${head} {${body}}`); 15 | } 16 | 17 | function compose(list: string[], templates: Array) { 18 | let body = list[0]; 19 | for (let i = 0, len = templates.length; i < len; i += 1) { 20 | body += templates[i] + list[i + 1]; 21 | } 22 | return body; 23 | } 24 | 25 | function apply(cssStr: string) { 26 | const rules = compile(cssStr); 27 | for (let i = 0, len = rules.length; i < len; i += 1) { 28 | const rule = stringify(rules[i], i, rules, stringify); 29 | if (rule) { 30 | const index = sheet.cssRules.length; 31 | sheet.insertRule(rule, index); 32 | } 33 | } 34 | } 35 | 36 | export function css(list: any, ...templates: Array) { 37 | const id = uuid(); 38 | const body = compose(list, templates); 39 | apply(`.${id} {${body}}`); 40 | return id; 41 | } 42 | 43 | export function keyframes(list: any, ...templates: Array) { 44 | const id = uuid(); 45 | insert(`@keyframes ${id}`, compose(list, templates)); 46 | return id; 47 | } 48 | 49 | export function istyled(cls: string) { 50 | return (list: any, ...templates: Array) => { 51 | insert(cls, compose(list, templates)); 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/biz/floating/mindmap-theme.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@root/base/styled"; 2 | import { ControllerMountDiv } from "@root/components/mind-map/Controllers/Controller"; 3 | import { useEffect, useRef, useState } from "react"; 4 | import icons from "../components/icons"; 5 | 6 | const SPanel = css` 7 | position: absolute; 8 | top: -7px; 9 | left: 42px; 10 | padding: 12px 16px; 11 | background-color: white; 12 | border-radius: 6px; 13 | border: 1px solid rgba(0, 0, 0, 0.05); 14 | box-shadow: rgba(0, 16, 32, 0.2) 0px 0px 1px 0px, rgba(0, 16, 32, 0.12) 0px 4px 24px 0px; 15 | `; 16 | 17 | 18 | export function MindMapTheme(props: { className?: string }) { 19 | const { className } = props; 20 | const [active, setActive] = useState(false); 21 | const ref = useRef(null); 22 | 23 | useEffect(() => { 24 | const el = ref.current!; 25 | let timer = 0; 26 | const show = () => { 27 | clearTimeout(timer); 28 | setActive(true); 29 | }; 30 | const hide = () => { 31 | timer = window.setTimeout(() => setActive(false), 100); 32 | }; 33 | el.addEventListener('mouseenter', show); 34 | el.addEventListener('mouseleave', hide); 35 | return () => { 36 | el.removeEventListener('mouseenter', show); 37 | el.removeEventListener('mouseleave', hide); 38 | }; 39 | }, []); 40 | 41 | return ( 42 |
47 | {icons.theme} 48 | {active && ( 49 |
50 | 51 |
52 | )} 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/mind-map/render/hooks/useGenSubNodesWithAI.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useState } from 'react'; 2 | import { MindMapState } from '../../../state/mindMapState'; 3 | 4 | export function useGenSubNodesWithAI() { 5 | const treeState = useContext(MindMapState); 6 | const [nodesGenerating, setNodesGenerating] = useState>( 7 | new Set(), 8 | ); 9 | 10 | const generateNodeWithAI = useCallback( 11 | (id: string, CurrentSection: string) => { 12 | setNodesGenerating((oldVal) => { 13 | const newVal = new Set(oldVal); 14 | newVal.add(id); 15 | return newVal; 16 | }); 17 | const cp = treeState?.outputCP() || {}; 18 | const data = { 19 | mindMapContent: JSON.stringify(cp), 20 | CurrentSection, 21 | }; 22 | fetch('/api/genSubNodes', { 23 | method: 'POST', 24 | headers: { 25 | 'Content-Type': 'application/json', 26 | }, 27 | body: JSON.stringify(data), 28 | }) 29 | .then((r) => r.json()) 30 | .then((json) => { 31 | if (json?.suggestions?.length) { 32 | console.log(json.suggestions); 33 | for (const suggestion of json.suggestions) { 34 | treeState?.addNode(id, 0, suggestion ?? ''); 35 | } 36 | } 37 | }) 38 | .finally(() => { 39 | setNodesGenerating((oldVal) => { 40 | const newVal = new Set(oldVal); 41 | newVal.delete(id); 42 | return newVal; 43 | }); 44 | }); 45 | }, 46 | [treeState], 47 | ); 48 | return { 49 | generateNodeWithAI, 50 | nodesGenerating, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/components/outline/common.tsx: -------------------------------------------------------------------------------- 1 | import { uuid } from '@base/atom'; 2 | 3 | import { Payload } from '@root/components/mind-map/render/model/interface'; 4 | import { RawNode } from '@root/components/mind-map/render/node/interface'; 5 | 6 | const TextAreaPrefix = uuid(); 7 | export function getTextAreaId(id: string) { 8 | return TextAreaPrefix + '-' + id; 9 | } 10 | 11 | export const INDENT = 24; 12 | 13 | export function focusTextArea(id: string, at?: number) { 14 | const textarea = document.getElementById( 15 | getTextAreaId(id), 16 | ) as HTMLTextAreaElement; 17 | if (!textarea) return; 18 | textarea.focus(); 19 | const total = textarea.value.length; 20 | if (at === undefined) at = total; 21 | else if (at < 0) at = total + at; 22 | textarea.setSelectionRange(at, at); 23 | } 24 | 25 | export interface OutlineNode { 26 | id: string; 27 | payload: Payload; 28 | children?: string[]; 29 | } 30 | 31 | export function handleSourceData(sourceData: RawNode) { 32 | const all: Record = {}; 33 | const child2Parent: Record = {}; 34 | 35 | const handle = (node: RawNode, parent: string) => { 36 | const { id, children } = node; 37 | all[id] = { 38 | id, 39 | payload: Object.assign({}, node.payload), 40 | children: children?.map((c) => { 41 | handle(c, id); 42 | return c.id; 43 | }), 44 | }; 45 | child2Parent[id] = parent; 46 | }; 47 | 48 | const RootId = sourceData.id; 49 | sourceData.children?.forEach((c) => handle(c, RootId)); 50 | all[RootId] = { 51 | id: RootId, 52 | payload: sourceData.payload, 53 | children: sourceData.children?.map((c) => c.id), 54 | }; 55 | console.log('all', all); 56 | return { all, child2Parent, RootId }; 57 | } 58 | -------------------------------------------------------------------------------- /src/biz/side-pane/home.tsx: -------------------------------------------------------------------------------- 1 | import clsnames from "@root/base/classnames"; 2 | import { css } from "@root/base/styled"; 3 | import { useCallback, useState } from "react"; 4 | import icons from "../components/icons"; 5 | import Popup from "../components/popup"; 6 | 7 | const SHome = css` 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | width: 28px; 12 | height: 28px; 13 | padding: 6px; 14 | box-sizing: border-box; 15 | border-radius: 4px; 16 | box-shadow: rgba(98, 123, 161, 0.28) 0px 0px 1px 0px, rgba(98, 123, 161, 0.08) 0px 2px 8px 0px; 17 | background-color: rgba(255, 255, 255, 1); 18 | color: rgba(23, 26, 29, 0.94); 19 | font-size: 20px; 20 | line-height: 20px; 21 | user-select: none; 22 | cursor: pointer; 23 | 24 | &:hover, &.more-active { 25 | background-color: rgba(0,0,0,0.04); 26 | } 27 | `; 28 | const SPannel = css` 29 | height: 400px; 30 | width: 200px; 31 | `; 32 | 33 | function Pannel(props: { 34 | position: [x: number, y: number]; 35 | hide: () => void; 36 | }) { 37 | const { hide, position: [left, top] } = props; 38 | return ( 39 | 40 |
41 |
42 |
43 | ); 44 | } 45 | 46 | export function SideHome() { 47 | const [popup, setPopup] = useState<[number, number] | null>(null); 48 | const hide = useCallback(() => setPopup(null), []); 49 | return <> 50 |
{ 53 | const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); 54 | setPopup([rect.left, rect.bottom]); 55 | }} 56 | > 57 | {icons.home} 58 |
59 | {popup && ( 60 | 61 | )} 62 | 63 | } 64 | -------------------------------------------------------------------------------- /office-addin/src/taskpane/helpers/MindMapGenHelper.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. 3 | * See LICENSE in the project root for license information. 4 | */ 5 | 6 | /* global URL, Headers, fetch */ 7 | export type MindMapUUID = string; 8 | export enum DocumentType { 9 | DOCX = "docx", 10 | PPT = "pptx", 11 | } 12 | interface IDocument { 13 | type: DocumentType; 14 | title: string; 15 | content: string; 16 | } 17 | 18 | interface IApiParameter { 19 | from: DocumentType; 20 | to: "markdown"; 21 | title: string; 22 | content: string; 23 | } 24 | 25 | interface IApiResponse { 26 | id: MindMapUUID; 27 | } 28 | 29 | export class MindMapGenHelper { 30 | private static readonly GENERATOR_URL = new URL(`${location.origin}/api/gen`); 31 | 32 | static async fromDocument(doc: IDocument): Promise { 33 | const { id } = await MindMapGenHelper.callApi({ 34 | from: doc.type, 35 | to: "markdown", 36 | title: doc.title, 37 | content: doc.content, 38 | }); 39 | 40 | return id; 41 | } 42 | 43 | private static async callApi(parameters: IApiParameter): Promise { 44 | const headers = new Headers(); 45 | headers.set("Content-Type", "application/json"); 46 | 47 | const response = await fetch(MindMapGenHelper.GENERATOR_URL, { 48 | headers, 49 | body: JSON.stringify(parameters), 50 | method: "POST", 51 | credentials: "include", 52 | }); 53 | 54 | const responseBody = await response.json(); 55 | 56 | if (response.ok && responseBody.id) { 57 | return responseBody; 58 | } else { 59 | throw new Error( 60 | `API call failed. HTTP status: ${response.status}. Response from server: ${JSON.stringify(responseBody)}` 61 | ); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/biz/side-pane/search.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@root/base/styled"; 2 | import { useMemo, useState } from "react"; 3 | import icons from "../components/icons"; 4 | 5 | const SBox = css` 6 | display: flex; 7 | align-items: center; 8 | 9 | background-color: rgba(23,26,29,0.04); 10 | border: 1px solid transparent; 11 | border-radius: 4px; 12 | margin: 8px 8px; 13 | padding: 4px 4px 4px 8px; 14 | gap: 4px; 15 | transition: 200ms; 16 | 17 | &:hover { 18 | border-color: rgba(63,133,255,1); 19 | } 20 | &:focus-within { 21 | box-shadow: rgba(0, 106, 254, 0.12) 0px 0px 0px 2px; 22 | border-color: rgba(63,133,255,1); 23 | } 24 | 25 | .search-icon { 26 | flex: 0 0 auto; 27 | display: block; 28 | height: 16px; 29 | width: 16px; 30 | } 31 | .search-input { 32 | flex: 1 1 100%; 33 | padding: 4px 6px; 34 | border: none; 35 | outline: none; 36 | background-color: transparent; 37 | 38 | &::placeholder { 39 | color: #ccc; 40 | font-weight: lighter; 41 | } 42 | } 43 | `; 44 | 45 | 46 | export function Search(props: { 47 | text: string; 48 | commit: (result: string) => void; 49 | }) { 50 | const { commit, text } = props; 51 | const [value, setValue] = useState(text); 52 | 53 | const handle = useMemo(() => { 54 | let timer = 0; 55 | return (v: string) => { 56 | clearTimeout(timer); 57 | setValue(v); 58 | timer = window.setTimeout(() => commit(v), 200); 59 | }; 60 | }, [commit, setValue]); 61 | 62 | return ( 63 |
64 | {icons.search} 65 | { 70 | handle(e.target.value); 71 | }} 72 | /> 73 |
74 | ); 75 | } 76 | 77 | -------------------------------------------------------------------------------- /src/components/mind-map/SizeMeasurer.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@base/styled'; 2 | import { useLayoutEffect, useMemo, useRef } from 'react'; 3 | import { NodeContent } from './NodeContent'; 4 | import { RawNode, SizedRawNode, prepareNodeSize } from './render'; 5 | import { expandTreeToArray } from './render/model'; 6 | import { Payload } from './render/model/interface'; 7 | 8 | const sizeMeasurerClass = css` 9 | position: absolute; 10 | z-index: -1000; 11 | left: 0; 12 | top: 0px; 13 | visibility: hidden; 14 | pointer-events: none; 15 | height: 20px; 16 | overflow: hidden; 17 | `; 18 | 19 | const idPrefix = 'mnc'; 20 | 21 | export function SizeMeasurer(props: { 22 | root: RawNode; 23 | onSize: (root: SizedRawNode) => void; 24 | }) { 25 | const { root, onSize } = props; 26 | const el = useRef(null); 27 | const nodeList = expandTreeToArray(root); 28 | useLayoutEffect(() => { 29 | if (!el.current?.children.length) { 30 | return; 31 | } 32 | const sizedDate = prepareNodeSize( 33 | root, 34 | (elId) => { 35 | const el = document.getElementById(elId); 36 | if (el) { 37 | const rect = el.getBoundingClientRect(); 38 | return [rect.width, rect.height]; 39 | } 40 | return [113, 30]; 41 | }, 42 | idPrefix, 43 | ); 44 | onSize(sizedDate); 45 | }, [root, onSize]); 46 | 47 | return ( 48 |
49 | {nodeList.map((node) => { 50 | return ( 51 | 58 | ); 59 | })} 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /mai-mind-map-se/server/controllers/users.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, NextFunction } from 'express'; 2 | import { Session } from 'express-session'; 3 | import { createLoopDocument } from '../loop'; 4 | import { GetUserByLocalAccountID } from '../storage/users'; 5 | import { handleError, PERMISSION_DENIED } from '../utils'; 6 | import { getUID } from '../storage/users'; 7 | export const router = express.Router(); 8 | 9 | export interface CustomSession extends Session { 10 | account?: { 11 | idTokenClaims?: any; 12 | localAccountId: string; 13 | name: string; 14 | username: string; 15 | }; 16 | isAuthenticated?: boolean; 17 | accessToken?: string; 18 | } 19 | 20 | export function isAuthenticated(req: Request, res: Response, next: NextFunction): void { 21 | if (!(req.session as CustomSession)?.isAuthenticated) { 22 | return res.redirect( `/auth/signin?targetUrl=${encodeURIComponent(req.originalUrl)}`); 23 | } 24 | next(); 25 | } 26 | 27 | router.get('/id', 28 | isAuthenticated, 29 | async function (req: Request, res: Response, next: NextFunction) { 30 | const accessToken: string = (req.session as CustomSession)?.accessToken!; 31 | createLoopDocument(accessToken); 32 | res.send({}); 33 | } 34 | ); 35 | 36 | router.get('/profile', 37 | async function (req, res) { 38 | const uid = await getUID(req); 39 | if (uid === undefined) { 40 | res.status(401).send({ message: PERMISSION_DENIED }); 41 | return; 42 | } 43 | try { 44 | const result = await GetUserByLocalAccountID( 45 | (req.session as CustomSession).account?.localAccountId!) 46 | res.send({ 47 | uid: result.rows[0].id, 48 | name: (req.session as CustomSession).account?.name, 49 | email: (req.session as CustomSession).account?.username, 50 | }); 51 | } catch (err: unknown) { 52 | res.send({ message: handleError(err) }); 53 | } 54 | } 55 | ); 56 | -------------------------------------------------------------------------------- /src/model/ot-doc/array.ts: -------------------------------------------------------------------------------- 1 | import { $Eq, $eqPrime } from './algebra'; 2 | import { $BaseDoc, $InvDoc, $idn, $init } from './document'; 3 | import { just, nothing } from './maybe'; 4 | import { $eqPartialStt, $eqStt } from './struct'; 5 | 6 | export type ArrayOplet = { idx: number; arr: T[] }; 7 | export type ArrayOp = Partial<{ 8 | del: ArrayOplet[]; 9 | ins: ArrayOplet[]; 10 | }>; 11 | 12 | export const $eqArr = ({ equals }: $Eq): $Eq => ({ 13 | equals: (arrA) => (arrB) => { 14 | if (arrA === arrB) return true; 15 | if (arrA.length !== arrB.length) return false; 16 | for (let i = 0; i < arrA.length; i += 1) 17 | if (!equals(arrA[i])(arrB[i])) return false; 18 | return true; 19 | }, 20 | }); 21 | 22 | export const $baseDocArr = (cls: $Eq): $BaseDoc> => { 23 | const cpEquals = $eqArr(cls).equals; 24 | const $eqOplet = $eqArr( 25 | $eqStt({ idx: $eqPrime(), arr: $eqArr(cls) }), 26 | ); 27 | return { 28 | ...$init([]), 29 | ...$idn({}), 30 | cpEquals, 31 | opEquals: $eqPartialStt({ del: $eqOplet, ins: $eqOplet }).equals, 32 | compose: 33 | ({ del = [], ins = [] }) => 34 | (cp) => { 35 | const cpR = [...cp]; 36 | for (const { idx, arr } of del) { 37 | if (!cpEquals(arr)(cpR.slice(idx, idx + arr.length))) 38 | return nothing(); 39 | cpR.splice(idx, arr.length); 40 | } 41 | for (const { idx, arr } of ins) { 42 | if (idx > cpR.length || idx < 0) return nothing(); 43 | cpR.splice(idx, 0, ...arr); 44 | } 45 | return just(cpR); 46 | }, 47 | }; 48 | }; 49 | 50 | export const $invDocArr = (cls: $Eq): $InvDoc> => ({ 51 | ...$baseDocArr(cls), 52 | invert: ({ del, ins }) => { 53 | const op: ArrayOp = {}; 54 | if (ins) op.del = [...ins].reverse(); 55 | if (del) op.ins = [...del].reverse(); 56 | return op; 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /src/model/data/op.ts: -------------------------------------------------------------------------------- 1 | import { Dict, mapStruct } from './struct'; 2 | 3 | declare const symVar: unique symbol; 4 | // A type for representing type variables 5 | export type $Var = { [symVar]: typeof symVar }; 6 | 7 | declare const symOp: unique symbol; 8 | export type $OpSign = { [symOp]: typeof symOp }; 9 | export type Prim = string | number | boolean; 10 | 11 | /** 12 | * Update a primitive value 13 | * @param o the old value 14 | * @param n to value 15 | */ 16 | 17 | export type PrimOp = { 18 | o?: T; 19 | n?: T; 20 | t: number; 21 | }; 22 | 23 | /** 24 | * Update a segment in an array, could be deletion or insertion 25 | * @param i index 26 | * @param a array of values 27 | */ 28 | 29 | export type ArrayOplet = { i: number; a: T[] }; 30 | 31 | /** 32 | * Update array 33 | * @param d deletions 34 | * @param i insertions 35 | */ 36 | export type ArrayOp = { d: ArrayOplet[]; i: ArrayOplet[] }; 37 | 38 | export type Op = T extends $Var 39 | ? $OpSign 40 | : T extends string 41 | ? PrimOp 42 | : T extends number 43 | ? PrimOp 44 | : T extends boolean 45 | ? PrimOp 46 | : T extends Array 47 | ? ArrayOp 48 | : T extends object 49 | ? Partial<{ [K in keyof T]: Op }> 50 | : never; 51 | 52 | export type Updater = (v: T) => Op; 53 | 54 | export const $s = 55 | ( 56 | stt: { 57 | [K in keyof T]: Updater; 58 | }, 59 | ): Updater => 60 | (t: T) => 61 | mapStruct(stt, (f: any, key) => f(t[key])) as Op; 62 | 63 | export const $d = $s as (dict: Dict>) => Updater>; 64 | 65 | type Gen = T extends string 66 | ? string 67 | : T extends number 68 | ? number 69 | : T extends boolean 70 | ? boolean 71 | : never; 72 | 73 | export const updatePrim = 74 | (t: number) => 75 | (n: T) => 76 | (o: Gen): PrimOp> => ({ t, o, n: n as any }); 77 | -------------------------------------------------------------------------------- /src/model/document-engine.ts: -------------------------------------------------------------------------------- 1 | import { Observable, mutable } from './observable'; 2 | import { $InvDoc } from './ot-doc/document'; 3 | 4 | export type DocumentEngine = { 5 | model: Observable; 6 | load: (cp: Cp) => void; 7 | apply: (updater: (cp: Cp) => Op | undefined) => void; 8 | undo: () => void; 9 | redo: () => void; 10 | canUndo: () => boolean; 11 | canRedo: () => boolean; 12 | }; 13 | 14 | export const documentEngine = ( 15 | { initial, compose, invert, identity }: $InvDoc, 16 | initialCp?: Cp, 17 | ): DocumentEngine => { 18 | const undoStack: Op[] = []; 19 | const redoStack: Op[] = []; 20 | // biome-ignore lint/correctness/noEmptyPattern: 21 | const {} = documentEngine; 22 | const { update, ...observable } = mutable(initialCp ?? initial()); 23 | const apply_ = (updater: (cp: Cp) => Op): void => { 24 | update((cp) => { 25 | const op = updater(cp); 26 | const mCp = compose(op)(cp); 27 | if (mCp.$ === 'Nothing') return cp; 28 | return mCp.v; 29 | }); 30 | }; 31 | 32 | return { 33 | model: observable, 34 | load: (cp) => { 35 | update(() => cp); 36 | undoStack.splice(0, undoStack.length); 37 | redoStack.splice(0, redoStack.length); 38 | }, 39 | apply: (updater) => 40 | apply_((cp) => { 41 | const op = updater(cp); 42 | if (!op) return identity(); 43 | undoStack.push(op); 44 | redoStack.splice(0, redoStack.length); 45 | return op; 46 | }), 47 | undo: () => 48 | apply_(() => { 49 | const op = undoStack.pop(); 50 | if (!op) return identity(); 51 | redoStack.push(op); 52 | return invert(op); 53 | }), 54 | redo: () => 55 | apply_(() => { 56 | const op = redoStack.pop(); 57 | if (!op) return identity(); 58 | undoStack.push(op); 59 | return op; 60 | }), 61 | canUndo: () => undoStack.length > 0, 62 | canRedo: () => redoStack.length > 0, 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/mind-map/Controllers/LinkModeControl.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@base/styled'; 2 | import { FC } from 'react'; 3 | import { Curve, HybridLines, RoundedFoldingLine } from '../../icons/icons'; 4 | import { LinkMode } from '../render/hooks/constants'; 5 | 6 | const SLinkModeControl = css` 7 | display: flex; 8 | align-items: center; 9 | line-height: 20px; 10 | gap: 10px; 11 | margin: 5px 0 15px; 12 | & > .label { 13 | width: 80px; 14 | text-align: right; 15 | } 16 | & > .link-mode-item { 17 | background-color: aliceblue; 18 | padding: 1px 10px; 19 | cursor: pointer; 20 | border: 1px solid #1893ff; 21 | transform-origin: center; 22 | display: flex; 23 | padding: 5px; 24 | &:hover { 25 | background-color: #badfff; 26 | } 27 | &.active { 28 | background-color: #1893ff; 29 | color: white; 30 | } 31 | } 32 | `; 33 | 34 | export const LinkModeControl: FC<{ 35 | linkMode: LinkMode; 36 | setLinkMode: (val: LinkMode) => void; 37 | }> = (props) => { 38 | const { linkMode, setLinkMode } = props; 39 | return ( 40 |
41 | LinkMode: 42 | {( 43 | [ 44 | { c: , k: LinkMode.CURVE, desc: 'Curve' }, 45 | { 46 | c: , 47 | k: LinkMode.LINE, 48 | desc: 'Rounded folding line', 49 | }, 50 | { c: , k: LinkMode.HYBRID, desc: 'Hybrid lines' }, 51 | ] as const 52 | ).map((d) => { 53 | return ( 54 |
{ 61 | setLinkMode(d.k); 62 | }} 63 | > 64 | {d.c} 65 |
66 | ); 67 | })} 68 |
69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /office-addin/src/taskpane/helpers/WordHelper.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. 3 | * See LICENSE in the project root for license information. 4 | */ 5 | 6 | import { MindMapUUID } from "./MindMapGenHelper"; 7 | 8 | /* global Office, Word */ 9 | 10 | export class WordHelper { 11 | static async getDocumentTitle(): Promise { 12 | const filename = WordHelper.getFilenameFromUrl(Office.context.document.url); 13 | const defaultTitle = `word_${Math.round((Math.random() + 1) * Date.now()).toString(36)}`; 14 | 15 | return Word.run(async (context) => { 16 | const properties = context.document.properties; 17 | properties.load("title"); 18 | await context.sync(); 19 | return properties.title || filename || defaultTitle; 20 | }); 21 | } 22 | 23 | static async getDocumentContent(): Promise { 24 | return Word.run(async (context) => { 25 | const body: Word.Body = context.document.body; 26 | body.load("text"); 27 | await context.sync(); 28 | return body.text; 29 | }); 30 | } 31 | 32 | static async getDocumentSelectedContent() { 33 | return new Promise((resolve, reject) => { 34 | Office.context.document.getSelectedDataAsync( 35 | Office.CoercionType.Text, 36 | async (asyncResult: Office.AsyncResult) => { 37 | if (asyncResult.status === Office.AsyncResultStatus.Failed) { 38 | reject(asyncResult.error.message); 39 | } else { 40 | resolve(asyncResult.value); 41 | } 42 | } 43 | ); 44 | }); 45 | } 46 | 47 | static async showMindMapDialog(mindMapUuid: MindMapUUID) { 48 | return Office.context.ui.displayDialogAsync(`${location.origin}/edit/${mindMapUuid}`); 49 | } 50 | 51 | static getFilenameFromUrl(documentUrl: string): string { 52 | return documentUrl.substring( 53 | Math.max(documentUrl.lastIndexOf("\\"), documentUrl.lastIndexOf("/")) + 1, 54 | documentUrl.lastIndexOf(".") 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/biz/components/popup.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@root/base/styled"; 2 | import { useEffect, useState } from "react"; 3 | import ReactDOM from "react-dom"; 4 | 5 | const SPopup = css` 6 | position: absolute; 7 | z-index: 1000; 8 | border: 1px solid rgba(23,26,29,0.14); 9 | box-shadow: 0 3px 6px -4px rgba(0,0,0,.12),0 6px 16px 0 rgba(0,0,0,.08),0 9px 28px 8px rgba(0,0,0,.05); 10 | background-color: rgb(255, 255, 255); 11 | border-radius: 4px; 12 | padding: 4px 0; 13 | min-width: 100px; 14 | transition: 200ms; 15 | transform-origin: top; 16 | `; 17 | 18 | const SAnimStart = css` 19 | transform: translateY(-4px) scaleY(0.9); 20 | opacity: 0; 21 | `; 22 | 23 | const container = document.createElement('div'); 24 | document.body.append(container); 25 | 26 | export interface PopupPosition { 27 | left?: number; 28 | top?: number; 29 | right?: number; 30 | bottom?: number; 31 | } 32 | 33 | function contain(self: HTMLElement, target: HTMLElement) { 34 | let node: HTMLElement | null = target; 35 | while (node) { 36 | if (node === self) return true; 37 | node = node.parentElement; 38 | } 39 | return false; 40 | } 41 | 42 | function bind(call: VoidFunction) { 43 | function handle(e: MouseEvent) { 44 | if (contain(container, e.target as HTMLElement)) return; 45 | window.requestAnimationFrame(call); 46 | } 47 | // use capture to avoid bubble bug 48 | document.addEventListener('click', handle, true); 49 | return () => document.removeEventListener('click', handle, true); 50 | } 51 | 52 | const NOOP = () => {}; 53 | function Popup(props: { 54 | position: PopupPosition; 55 | hide?: () => void; 56 | children?: React.ReactNode; 57 | }) { 58 | const { position, hide = NOOP, children } = props; 59 | const [cls, setCls] = useState(`${SPopup} ${SAnimStart}`); 60 | useEffect(() => { 61 | setCls(SPopup); 62 | return bind(hide); 63 | }, []); 64 | 65 | return ReactDOM.createPortal( 66 |
e.stopPropagation()}> 70 | {children} 71 |
, 72 | container, 73 | ); 74 | } 75 | 76 | 77 | export default Popup; 78 | -------------------------------------------------------------------------------- /src/components/mind-map/MapIndex.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useCallback, useContext, useState } from 'react'; 2 | 3 | import { Controller } from './Controllers/Controller'; 4 | 5 | import { Direction, Payload } from './render'; 6 | 7 | import { MindMapState } from '../state/mindMapState'; 8 | import { 9 | MindMap, 10 | addNode, 11 | delNode, 12 | getExampleSourceData, 13 | modifyNodeContent, 14 | moveNodeTo, 15 | toggleCollapseNode, 16 | } from './MindMap'; 17 | import { LinkMode } from './render/hooks/constants'; 18 | import { useRenderOption } from './render/hooks/useRenderOption'; 19 | 20 | import './MapIndex.css'; 21 | 22 | function isNodeCollapsed(data: Payload): boolean { 23 | return data.collapsed || false; 24 | } 25 | 26 | export function MindMapView() { 27 | const { 28 | dir, 29 | setDir, 30 | scale, 31 | setScale, 32 | linkMode, 33 | setLinkMode, 34 | colorMode, 35 | setColorMode, 36 | } = useRenderOption(); 37 | const treeState = useContext(MindMapState); 38 | 39 | return treeState ? ( 40 | 41 | 51 | 72 | 73 | ) : null; 74 | } 75 | -------------------------------------------------------------------------------- /src/model/read.ts: -------------------------------------------------------------------------------- 1 | export type Read = (v: unknown) => T; 2 | 3 | export const readPrime = 4 | (dft: T): Read => 5 | (v) => 6 | typeof v === typeof dft ? (v as T) : dft; 7 | 8 | export const readString = readPrime(''); 9 | export const readNumber = readPrime(0); 10 | export const readBoolean = readPrime(false); 11 | 12 | type ReadStt> = { 13 | [K in keyof T]: Read; 14 | }; 15 | 16 | export const readStruct = 17 | >(readStt: ReadStt): Read => 18 | (v) => 19 | typeof v === 'object' && v 20 | ? Object.keys(readStt).reduce((m: T | undefined, key: keyof T) => { 21 | if (m) { 22 | const value = readStt[key]((v as Record)[key]); 23 | if (value === undefined) return; 24 | m[key] = value; 25 | } 26 | return m; 27 | }, {} as T) 28 | : undefined; 29 | 30 | export const readPartial = 31 | >(readStt: ReadStt): Read> => 32 | (v) => 33 | typeof v === 'object' && v 34 | ? Object.keys(readStt).reduce( 35 | (m: Partial, key: keyof T) => { 36 | const value = readStt[key]((v as Record)[key]); 37 | if (value !== undefined) m[key] = value; 38 | return m; 39 | }, 40 | {} as Partial, 41 | ) 42 | : {}; 43 | 44 | export const readRecord = 45 | (read: Read): Read> => 46 | (v) => 47 | typeof v === 'object' && v 48 | ? Object.keys(v).reduce( 49 | (m, key) => { 50 | const value = read((v as Record)[key]); 51 | if (value !== undefined) m[key] = value; 52 | return m; 53 | }, 54 | {} as Record, 55 | ) 56 | : {}; 57 | 58 | export const readArray = 59 | (read: Read): Read => 60 | (v) => 61 | Array.isArray(v) 62 | ? v.reduce((m, elem: unknown) => { 63 | const value = read(elem); 64 | if (value !== undefined) m.push(value); 65 | return m; 66 | }, [] as T[]) 67 | : []; 68 | -------------------------------------------------------------------------------- /rsbuild.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from '@rsbuild/core'; 3 | import { pluginReact } from '@rsbuild/plugin-react'; 4 | 5 | function normalizePort(val) { 6 | const port = Number.parseInt(val, 10); 7 | 8 | if (Number.isNaN(port)) { 9 | // named pipe 10 | return val; 11 | } 12 | 13 | if (port >= 0) { 14 | // port number 15 | return port; 16 | } 17 | 18 | return false; 19 | } 20 | 21 | const IS_PROD = process.env.BUILD_CONFIG === 'prod'; 22 | export default defineConfig({ 23 | source: { 24 | define: { 25 | __IS_PROD__: IS_PROD, 26 | __IS_DEV__: !IS_PROD, 27 | }, 28 | }, 29 | plugins: [pluginReact()], 30 | html: { 31 | favicon: path.resolve(__dirname, './src/assets/favicon.ico'), 32 | title: 'MAI mind map', 33 | }, 34 | server: { 35 | port: normalizePort(process.env.PORT || '3000'), 36 | historyApiFallback: { 37 | rewrites: [ 38 | { from: /^\/edit$/, to: '/index.html' }, 39 | { from: /^\/edit\/.*/, to: '/index.html' }, 40 | ], 41 | }, 42 | // for local server (start local server with listening prot:2999) 43 | proxy: { 44 | '/api': { 45 | target: 'http://localhost:2999', 46 | changeOrigin: true, 47 | secure: false, 48 | }, 49 | "/auth": { 50 | target: 'http://localhost:2999', 51 | changeOrigin: true, 52 | secure: false, 53 | }, 54 | }, 55 | // for dev server 56 | // proxy: { 57 | // '/api': { 58 | // target: 'https://dev-mai-mind-map.azurewebsites.net', 59 | // changeOrigin: true, 60 | // secure: false, 61 | // }, 62 | // "/auth/signin": { 63 | // target: 'https://dev-mai-mind-map.azurewebsites.net/', 64 | // changeOrigin: true, 65 | // secure: false, 66 | // }, 67 | // }, 68 | // for prod server 69 | // proxy: { 70 | // '/api': { 71 | // target: 'https://mai-mind-map.azurewebsites.net', 72 | // changeOrigin: true, 73 | // secure: false, 74 | // }, 75 | // "/auth/signin": { 76 | // target: 'https://mai-mind-map.azurewebsites.net/', 77 | // changeOrigin: true, 78 | // secure: false, 79 | // }, 80 | // } 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mai-mind-map", 3 | "private": true, 4 | "version": "1.0.0", 5 | "scripts": { 6 | "build": "rsbuild build && npm run cp", 7 | "check": "biome check", 8 | "check:write": "biome check --write && npm run format", 9 | "dev": "rsbuild dev --open", 10 | "format": "biome format --write", 11 | "preview": "rsbuild preview", 12 | "cp": "cpx \"dist/**/*.*\" mai-mind-map-se/dist", 13 | "prepare": "husky && husky install", 14 | "test": "echo \"No test yet\"" 15 | }, 16 | "dependencies": { 17 | "@fluentui/react-components": "^9.55.1", 18 | "d3-drag": "^2.0.0", 19 | "d3-ease": "^3.0.1", 20 | "d3-hierarchy": "^3.1.2", 21 | "d3-interpolate": "^2.0.1", 22 | "d3-path": "^3.1.0", 23 | "d3-scale": "^3.3.0", 24 | "d3-scale-chromatic": "^2.0.0", 25 | "d3-selection": "^2.0.0", 26 | "d3-shape": "^2.1.0", 27 | "d3-transition": "^2.0.0", 28 | "d3-zoom": "^2.0.0", 29 | "express": "^4.19.2", 30 | "lodash": "^4.17.21", 31 | "react": "^18.3.1", 32 | "react-dom": "^18.3.1", 33 | "react-markdown": "^9.0.1", 34 | "react-router-dom": "^6.26.2", 35 | "react-spinners": "^0.14.1", 36 | "stylis": "^4.3.4" 37 | }, 38 | "devDependencies": { 39 | "@biomejs/biome": "^1.8.3", 40 | "@rsbuild/core": "1.0.1-rc.4", 41 | "@rsbuild/plugin-react": "1.0.1-rc.4", 42 | "@types/d3-color": "^3.1.3", 43 | "@types/d3-drag": "^2.0.0", 44 | "@types/d3-ease": "^2.0.0", 45 | "@types/d3-hierarchy": "^3.1.7", 46 | "@types/d3-path": "^3.1.0", 47 | "@types/d3-scale": "^3.2.2", 48 | "@types/d3-scale-chromatic": "^2.0.0", 49 | "@types/d3-selection": "^2.0.0", 50 | "@types/d3-shape": "^2.0.0", 51 | "@types/d3-transition": "^2.0.0", 52 | "@types/d3-zoom": "^2.0.0", 53 | "@types/express": "^4.17.21", 54 | "@types/jest": "^29.5.13", 55 | "@types/lodash": "^4.17.7", 56 | "@types/node": "^22.5.4", 57 | "@types/react": "^18.3.5", 58 | "@types/react-dom": "^18.3.0", 59 | "cpx": "^1.5.0", 60 | "d3-color": "^3.1.0", 61 | "husky": "^8.0.0", 62 | "typescript": "^5.5.2" 63 | }, 64 | "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1" 65 | } 66 | -------------------------------------------------------------------------------- /src/components/mind-map/Controllers/Controller.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@base/styled'; 2 | import { atom, useAtom, useChange } from '@root/base/atom'; 3 | import { FC, Fragment, useContext, useEffect, useRef } from 'react'; 4 | import { createPortal } from 'react-dom'; 5 | import { Direction } from '../render'; 6 | import { ColorMode, LinkMode } from '../render/hooks/constants'; 7 | import { ColorModeControl } from './ColorControl'; 8 | import { LayoutControl } from './LayoutControl'; 9 | import { LinkModeControl } from './LinkModeControl'; 10 | import { ScaleControl } from './ScaleControl'; 11 | 12 | interface ControllerProps { 13 | dir: Direction; 14 | serDir: (dir: Direction) => void; 15 | scale: number; 16 | setScale: (scale: number) => void; 17 | linkMode: LinkMode; 18 | setLinkMode: (linkMode: LinkMode) => void; 19 | colorMode: ColorMode; 20 | setColorMode: (colorMode: ColorMode) => void; 21 | } 22 | 23 | export const STreeViewController = css``; 24 | 25 | const controllerPortalAtom = atom(null); 26 | 27 | export function ControllerMountDiv(props: { className?: string }) { 28 | const setPortal = useChange(controllerPortalAtom); 29 | const ref = useRef(null); 30 | useEffect(() => { 31 | setPortal(ref.current); 32 | return () => setPortal(null); 33 | }, []); 34 | const cls = props.className ? `${STreeViewController} ${props.className}` : STreeViewController; 35 | return
; 36 | }; 37 | 38 | export const Controller: FC = (props) => { 39 | const { dir, serDir, scale, setScale } = props; 40 | const portal = useAtom(controllerPortalAtom)[0]; 41 | return portal 42 | ? createPortal( 43 | 44 | 45 | 49 | 53 | {/* */} 54 | , 55 | portal, 56 | ) 57 | : null; 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/mind-map/Controllers/LayoutControl.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@base/styled'; 2 | import { FC } from 'react'; 3 | import { MindMap, MindMapH } from '../../icons/icons'; 4 | import { Direction } from '../render'; 5 | 6 | const SDirections = css` 7 | display: flex; 8 | align-items: center; 9 | line-height: 20px; 10 | gap: 10px; 11 | margin: 5px 0 15px; 12 | & > .label { 13 | width: 80px; 14 | text-align: right; 15 | } 16 | & > .dir-item { 17 | background-color: aliceblue; 18 | padding: 1px 10px; 19 | cursor: pointer; 20 | border: 1px solid #1893ff; 21 | transform-origin: center; 22 | display: flex; 23 | padding: 5px; 24 | &.RL { 25 | transform: rotateY(180deg); 26 | } 27 | &.TB { 28 | transform: rotate(90deg); 29 | } 30 | &.BT { 31 | transform: rotate(270deg); 32 | } 33 | &.H { 34 | transform: rotate(90deg); 35 | } 36 | &:hover { 37 | background-color: #badfff; 38 | } 39 | &.active { 40 | background-color: #1893ff; 41 | color: white; 42 | } 43 | } 44 | `; 45 | 46 | export const LayoutControl: FC<{ 47 | direction: Direction; 48 | setDirection: (val: Direction) => void; 49 | }> = (props) => { 50 | const { direction, setDirection } = props; 51 | return ( 52 |
53 | Layout: 54 | {( 55 | [ 56 | { c: , k: 'LR', desc: 'Left to right' }, 57 | { c: , k: 'RL', desc: 'Right to left' }, 58 | { c: , k: 'TB', desc: 'Top to bottom' }, 59 | { c: , k: 'BT', desc: 'Bottom to top' }, 60 | { c: , k: 'H', desc: 'Horizontal' }, 61 | { c: , k: 'V', desc: 'Vertical' }, 62 | ] as const 63 | ).map((d) => { 64 | return ( 65 |
{ 70 | setDirection(d.k); 71 | }} 72 | > 73 | {d.c} 74 |
75 | ); 76 | })} 77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/biz/store/files.ts: -------------------------------------------------------------------------------- 1 | import { atom } from '@root/base/atom'; 2 | import { 3 | FileInfo, 4 | createDocument, 5 | deleteDocument, 6 | updateDocumentName, 7 | } from '@root/model/api'; 8 | import { NavigateFunction } from 'react-router-dom'; 9 | 10 | interface State { 11 | loading: boolean; 12 | login: boolean | undefined; 13 | files: FileInfo[]; 14 | } 15 | 16 | const DefaultState: State = { 17 | loading: false, 18 | login: undefined, 19 | files: [] as FileInfo[], 20 | }; 21 | 22 | export const filesAtom = atom(DefaultState, (get, set) => { 23 | function refresh(loading = true) { 24 | set({ ...get(), loading }); 25 | fetch('/api/list') 26 | .then((r) => { 27 | if (r.status === 401) { 28 | set({ ...get(), loading: false, login: false }); 29 | return; 30 | } 31 | return r.json(); 32 | }) 33 | .then((r) => { 34 | if (r) { 35 | set({ files: r.list, loading: false, login: true }); 36 | } 37 | }) 38 | .catch((e) => { 39 | console.error(e); 40 | set({ ...get(), loading: false }); 41 | }); 42 | } 43 | 44 | function update(id: string, data: Partial) { 45 | const state = get(); 46 | const files = state.files.map((f) => { 47 | if (f.id === id && data.title) { 48 | if (data.title !== f.title) { 49 | updateDocumentName(id, data.title || '').then(() => { 50 | refresh(/*loading=*/ false); 51 | }); 52 | } 53 | return { ...f, ...data }; 54 | } else { 55 | return f; 56 | } 57 | }); 58 | set({ ...state, files }); 59 | } 60 | 61 | function remove(id: string) { 62 | return deleteDocument(id).then(() => { 63 | const state = get(); 64 | const files = state.files.filter((f) => f.id !== id); 65 | set({ ...state, files }); 66 | return files[0]; 67 | }); 68 | } 69 | 70 | let fetched = false; 71 | async function fetchFilesOnce() { 72 | if (fetched) return; 73 | fetched = true; 74 | await refresh(); 75 | } 76 | async function createDoc(navigate: NavigateFunction) { 77 | const id = await createDocument(); 78 | navigate(`/edit/${id}`); 79 | setTimeout(() => { 80 | refresh(/*loading=*/ false); 81 | }, 500); 82 | } 83 | 84 | return { 85 | refresh, 86 | update, 87 | fetchFilesOnce, 88 | createDoc, 89 | remove, 90 | }; 91 | }); 92 | -------------------------------------------------------------------------------- /src/components/mind-map/render/hooks/useRenderOption.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { Direction } from '../layout/flex-tree/hierarchy'; 3 | import { ColorMode, LinkMode } from './constants'; 4 | 5 | interface OptionStore { 6 | direction: Direction; 7 | scale: number; 8 | linkMode: LinkMode; 9 | colorMode: ColorMode; 10 | } 11 | 12 | const storeKey = 'mind_map_option'; 13 | 14 | const defaultOption: OptionStore = { 15 | direction: 'H', 16 | scale: 1, 17 | linkMode: LinkMode.HYBRID, 18 | colorMode: ColorMode.COLORFUL, 19 | }; 20 | function loadOption(): OptionStore { 21 | let option: OptionStore = Object.assign({}, defaultOption); 22 | const optionBase64 = localStorage.getItem(storeKey); 23 | if (optionBase64) { 24 | try { 25 | option = JSON.parse(atob(optionBase64)); 26 | } catch (e) {} 27 | if ( 28 | option.direction && 29 | option.scale && 30 | option.linkMode && 31 | option.colorMode 32 | ) { 33 | return option; 34 | } 35 | option = Object.assign({}, defaultOption); 36 | return option; 37 | } 38 | return option; 39 | } 40 | 41 | const optionStore = loadOption(); 42 | let direction = optionStore.direction; 43 | let scaleValue = optionStore.scale; 44 | let linkModeValue = optionStore.linkMode; 45 | let colorModeValue = optionStore.colorMode; 46 | function saveOption() { 47 | const option = { 48 | direction, 49 | scale: scaleValue, 50 | linkMode: linkModeValue, 51 | colorMode: colorModeValue, 52 | }; 53 | localStorage.setItem(storeKey, btoa(JSON.stringify(option))); 54 | } 55 | 56 | export function useRenderOption() { 57 | const [dir, setDir] = useState(direction); 58 | const [scale, setScale] = useState(scaleValue); 59 | const [linkMode, setLinkMode] = useState(linkModeValue); 60 | const [colorMode, setColorMode] = useState(colorModeValue); 61 | 62 | useEffect(() => { 63 | direction = dir; 64 | saveOption(); 65 | }, [dir]); 66 | useEffect(() => { 67 | scaleValue = scale; 68 | saveOption(); 69 | }, [scale]); 70 | useEffect(() => { 71 | linkModeValue = linkMode; 72 | saveOption(); 73 | }, [linkMode]); 74 | useEffect(() => { 75 | colorModeValue = colorMode; 76 | saveOption(); 77 | }, [colorMode]); 78 | 79 | return { 80 | dir, 81 | setDir, 82 | scale, 83 | setScale, 84 | linkMode, 85 | setLinkMode, 86 | colorMode, 87 | setColorMode, 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/model/data/behaviors/readable.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorDef } from '../behavior'; 2 | import { $Var } from '../higher-kinded-type'; 3 | import { $Struct, Dict, mapDict, mapStruct } from '../struct'; 4 | import { Preset } from './preset'; 5 | import { Signatured } from './signatured'; 6 | 7 | export type Readable = ( 8 | raise: (path: string) => (message: string) => void, 9 | ) => (u: unknown) => T; 10 | 11 | export type Read = { 12 | read: Readable; 13 | }; 14 | 15 | export const readData = 16 | ({ read }: Read) => 17 | (u: unknown, onError: (message: string) => void = () => {}) => 18 | read((path) => (message) => onError(`${message}, at $${path}`))(u); 19 | 20 | const withRead = (read: Readable): Read => ({ read }); 21 | 22 | const withReadPrim = ({ 23 | preset, 24 | signature, 25 | }: Preset & Signatured): Read => 26 | withRead((raise) => (u) => { 27 | if (typeof u === typeof preset) { 28 | return u as T; 29 | } 30 | raise('')(`requires ${signature}`); 31 | return preset; 32 | }); 33 | 34 | const readable: BehaviorDef = { 35 | $string: withReadPrim, 36 | $number: withReadPrim, 37 | $boolean: withReadPrim, 38 | $array: 39 | ({ preset, signature }) => 40 | ({ read }) => 41 | withRead((raise) => (u) => { 42 | if (!Array.isArray(u)) { 43 | raise('')(`requires ${signature}`); 44 | return preset; 45 | } 46 | return u.map((e, i) => read((path) => raise(`[${i}]${path}`))(e)); 47 | }), 48 | $dict: 49 | ({ preset, signature }) => 50 | ({ read }) => 51 | withRead((raise) => (u) => { 52 | if (typeof u !== 'object' || !u) { 53 | raise('')(`requires ${signature}`); 54 | return preset; 55 | } 56 | return mapDict(u as Dict, (v, key) => 57 | read((path) => raise(`.${key}${path}`))(v), 58 | ); 59 | }), 60 | $struct: 61 | ({ preset, signature }) => 62 | (stt) => 63 | withRead((raise) => (u) => { 64 | if (typeof u !== 'object' || !u) { 65 | raise('')(`requires ${signature}`); 66 | return preset; 67 | } 68 | return mapStruct(stt, ({ read }, key) => 69 | read((path) => raise(`.${key as string}${path}`))( 70 | (u as $Struct)[key], 71 | ), 72 | ); 73 | }), 74 | }; 75 | 76 | export default readable; 77 | -------------------------------------------------------------------------------- /mai-mind-map-se/server/storage/users.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { OkPacket } from 'mysql'; 3 | import { CustomSession } from '../controllers/users'; 4 | import { handleError } from '../utils'; 5 | import { executeQuery } from './pool'; 6 | 7 | export async function getUID(req: Request): Promise { 8 | const accountID = (req.session as CustomSession).account?.localAccountId; 9 | if (accountID === undefined) { 10 | return undefined; 11 | } 12 | const result = await GetUserByLocalAccountID(accountID); 13 | if (result.rows.length === 0) { 14 | return undefined; 15 | } 16 | return result.rows[0].id; 17 | } 18 | 19 | /** 20 | * Retrieves a user by their local account ID. 21 | * 22 | * @param localAccountID - The local account ID of the user to retrieve. 23 | * @returns A promise that resolves to an object containing the rows of the 24 | * query result. 25 | * @throws Will throw an error if the query fails. 26 | */ 27 | export function GetUserByLocalAccountID(localAccountID: string): 28 | Promise<{ rows: any }> { 29 | return new Promise((resolve, reject) => { 30 | executeQuery(`SELECT id FROM users WHERE 31 | local_account_id = "${localAccountID}";`, (err, result) => { 32 | if (result) { 33 | resolve(result); 34 | } else { 35 | reject(handleError(err)); 36 | } 37 | }); 38 | }); 39 | } 40 | 41 | /** 42 | * Adds a new user to the database. 43 | * 44 | * @param account - An object containing user account details. 45 | * @param account.name - The name of the user. 46 | * @param account.username - The email of the user. 47 | * @param account.homeAccountId - The home account ID of the user. 48 | * @param account.localAccountId - The local account ID of the user. 49 | * @param account.tenantId - The tenant ID of the user. 50 | * @returns A promise that resolves with the result of the database insertion. 51 | */ 52 | export function AddUser(account: any): Promise<{ rows: OkPacket }> { 53 | return new Promise((resolve, reject) => { 54 | executeQuery(`INSERT INTO users 55 | (name, email, home_account_id, local_account_id, tenant_id) 56 | VALUES ( 57 | "${account.name}", 58 | "${account.username}", 59 | "${account.homeAccountId}", 60 | "${account.localAccountId}", 61 | "${account.tenantId}" 62 | );`, (err, result) => { 63 | if (result) { 64 | resolve(result); 65 | } else { 66 | reject(handleError(err)); 67 | } 68 | }); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /src/biz/floating/index.tsx: -------------------------------------------------------------------------------- 1 | import clsnames from "@base/classnames"; 2 | import { useAtom } from "@root/base/atom"; 3 | import { css } from "@root/base/styled"; 4 | import icons from "../components/icons"; 5 | import { viewModeAtom } from "../store"; 6 | import { MindMapTheme } from "./mindmap-theme"; 7 | 8 | const SBox = css` 9 | position: absolute; 10 | left: 20px; 11 | top: 20px; 12 | display: flex; 13 | flex-direction: column; 14 | align-items: stretch; 15 | gap: 10px; 16 | `; 17 | const SToolBox = css` 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | gap: 6px; 22 | width: 42px; 23 | padding: 6px 0; 24 | background-color: white; 25 | border-radius: 6px; 26 | border: 1px solid rgba(0, 0, 0, 0.05); 27 | box-shadow: rgba(0, 16, 32, 0.2) 0px 0px 1px 0px, rgba(0, 16, 32, 0.12) 0px 4px 24px 0px; 28 | `; 29 | const STool = css` 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | width: 100%; 34 | height: 28px; 35 | width: 28px; 36 | padding: 4px; 37 | border-radius: 4px; 38 | cursor: pointer; 39 | &:hover, &.active { 40 | background-color: rgba(0, 0, 0, 0.05); 41 | } 42 | `; 43 | 44 | function MindMapViewTools() { 45 | return ( 46 | <> 47 | 48 |
{icons.comment}
49 |
{icons.move}
50 |
{icons.focus}
51 | 52 | ); 53 | } 54 | 55 | function OutlineViewTools() { 56 | // Todo: add more tool in the future 57 | return ( 58 | <> 59 |
{icons.comment}
60 | 61 | ); 62 | } 63 | 64 | export function FloatingTools() { 65 | const [view, , viewActions] = useAtom(viewModeAtom); 66 | 67 | const isMindmap = view === 'mindmap'; 68 | const isOutline = view === 'outline'; 69 | 70 | return ( 71 |
72 |
73 |
viewActions.showMindmap()} 76 | > 77 | {icons.mindmapView} 78 |
79 |
viewActions.showOutline()} 82 | > 83 | {icons.outlineView} 84 |
85 |
86 | 87 |
88 | {isMindmap ? : } 89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/biz/head/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "@root/base/atom"; 2 | import { css } from "@root/base/styled" 3 | import { Presenter } from "@root/components/icons/icons"; 4 | import { PresentationButton, presentationNodeFromSampleData } from "@root/components/presentation"; 5 | import { MindMapStateType } from "@root/components/state/mindMapState"; 6 | import icons from "../components/icons"; 7 | import { showSidepaneAtom } from "../store"; 8 | import { FileMeta } from "./file-meta"; 9 | import { Generate } from "./generate"; 10 | import { More } from "./more"; 11 | import { Share } from "./share"; 12 | import { Users } from "./users"; 13 | 14 | const SBox = css` 15 | border-bottom: 1px solid #eaeaea; 16 | display: flex; 17 | align-items: stretch; 18 | justify-content: space-between; 19 | padding: 0 12px; 20 | height: 50px; 21 | `; 22 | 23 | const SLeft = css` 24 | flex: 0 0 auto; 25 | display: flex; 26 | align-items: center; 27 | gap: 8px; 28 | 29 | &>.fold-side { 30 | cursor: pointer; 31 | width: 18px; 32 | display: flex; 33 | align-items: center; 34 | transform: rotate(180deg); 35 | } 36 | `; 37 | 38 | const SRight = css` 39 | flex: 0 0 auto; 40 | display: flex; 41 | align-items: center; 42 | gap: 6px; 43 | padding: 6px 0; 44 | `; 45 | 46 | const SHeadBtn = css` 47 | display: flex; 48 | align-items: center; 49 | padding: 3px 4px; 50 | border: 1px solid #ccc; 51 | border-radius: 4px; 52 | font-size: 14px; 53 | gap: 4px; 54 | cursor: pointer; 55 | &:hover { 56 | border-color: #2370ff; 57 | } 58 | `; 59 | 60 | interface HeadProps { 61 | treeState: MindMapStateType; 62 | } 63 | 64 | export function Header(props: HeadProps) { 65 | const { treeState } = props; 66 | const [sidepaneVisible, showSide] = useAtom(showSidepaneAtom); 67 | 68 | return ( 69 |
70 |
71 | {!sidepaneVisible && ( 72 |
showSide(true)}> 73 | {icons.fold} 74 |
75 | )} 76 | 77 |
78 |
79 | 80 | 81 | 85 | 86 | Presenter 87 | 88 | 89 | 90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/biz/components/modal.tsx: -------------------------------------------------------------------------------- 1 | import classnames from "@base/classnames"; 2 | import { css } from "@base/styled"; 3 | import { CSSProperties, createElement, useEffect, useState } from "react"; 4 | import ReactDOM from "react-dom"; 5 | import { createRoot } from "react-dom/client"; 6 | 7 | const Duration = 100; 8 | 9 | const SOverlay = css` 10 | z-index: 1000000; 11 | position: fixed; 12 | inset: 0; 13 | background-color: rgba(0, 0, 0, 0.2); 14 | transition: ${Duration}ms; 15 | `; 16 | 17 | const SContent = css` 18 | position: absolute; 19 | left: 50%; 20 | top: 50%; 21 | transform: translate(-50%, -50%); 22 | border: 1px solid rgba(17, 31, 44, 0.12); 23 | box-shadow: rgba(0, 0, 0, 0.1) 1px 3px 8px 0px; 24 | background-color: rgb(255, 255, 255); 25 | border-radius: 4px; 26 | padding: 4px 0; 27 | min-width: 100px; 28 | transition: ${Duration}ms; 29 | `; 30 | 31 | const SOverlayInit = css` 32 | opacity: 0; 33 | `; 34 | 35 | const SContentInit = css` 36 | opacity: 0; 37 | transform: translate(-50%, calc(-50% - 8px)); 38 | `; 39 | 40 | const container = document.createElement('div'); 41 | document.body.append(container); 42 | const NOOP = () => {}; 43 | 44 | interface Props { 45 | style?: CSSProperties; 46 | className?: string; 47 | overlayBg?: string; 48 | hide?: () => void; 49 | children?: React.ReactNode; 50 | } 51 | 52 | function Content(props: Props) { 53 | const { hide = NOOP, className, style, overlayBg, children } = props; 54 | const [anime, setAnime] = useState(2); 55 | 56 | useEffect(() => { 57 | setAnime(1); 58 | const timer = window.setTimeout(() => setAnime(0), Duration); 59 | return () => window.clearTimeout(timer); 60 | }, []); 61 | 62 | return ( 63 |
1 && SOverlayInit)} 65 | style={{ backgroundColor: overlayBg }} 66 | onClick={(e) => { 67 | e.stopPropagation(); 68 | requestAnimationFrame(hide); 69 | }}> 70 |
0 && SContentInit)} 72 | style={style} 73 | onClick={e => e.stopPropagation()}> 74 | {children} 75 |
76 |
77 | ); 78 | } 79 | 80 | function Modal(props: Props) { 81 | return ReactDOM.createPortal( 82 | createElement(Content, props), 83 | container, 84 | ); 85 | } 86 | 87 | function show(children: React.ReactNode) { 88 | const root = createRoot(container); 89 | const hide = () => root.unmount(); 90 | root.render( 91 | {children} 92 | ); 93 | return hide; 94 | } 95 | 96 | Modal.show = show; 97 | 98 | export default Modal; 99 | -------------------------------------------------------------------------------- /src/components/mind-map/render/hooks/useNodeColor.ts: -------------------------------------------------------------------------------- 1 | import { HSLColor, hsl } from 'd3-color'; 2 | import { FC, Fragment, useEffect, useMemo, useState } from 'react'; 3 | 4 | import { NodeInterface } from '../layout'; 5 | import { Payload } from '../model/interface'; 6 | import { SizedRawNode } from '../node/interface'; 7 | import { ColorMode } from './constants'; 8 | 9 | export function useNodeColor( 10 | node: NodeInterface> | null, 11 | colorMode: ColorMode, 12 | ) { 13 | const { bgColor, textColor } = useMemo(() => { 14 | if (!node) { 15 | return { 16 | bgColor: '#fff', 17 | textColor: '#212429', 18 | }; 19 | } 20 | const color = node.data.payload.hilight; 21 | 22 | let cHSL: HSLColor | undefined; 23 | if (color) { 24 | cHSL = hsl(color); 25 | } 26 | 27 | if (node.depth === 0) { 28 | let textColor = '#fff'; 29 | let bgColor = '#0172DC'; 30 | if ( 31 | colorMode === ColorMode.COLORFUL && 32 | color && 33 | cHSL && 34 | !Number.isNaN(cHSL.l) 35 | ) { 36 | bgColor = color; 37 | const l = cHSL.l; 38 | if (l > 0.75) { 39 | textColor = '#212429'; 40 | } 41 | } 42 | return { 43 | bgColor, 44 | textColor, 45 | }; 46 | } else if (node.depth === 1) { 47 | let textColor = '#212429'; 48 | let bgColor = '#ecf2fb'; 49 | if ( 50 | colorMode === ColorMode.COLORFUL && 51 | color && 52 | cHSL && 53 | !Number.isNaN(cHSL.l) 54 | ) { 55 | bgColor = color; 56 | const l = cHSL.l; 57 | if (l < 0.75) { 58 | textColor = '#fff'; 59 | } 60 | } 61 | 62 | return { 63 | bgColor, 64 | textColor, 65 | }; 66 | } else { 67 | let textColor = '#212429'; 68 | if ( 69 | colorMode === ColorMode.COLORFUL && 70 | color && 71 | cHSL && 72 | !Number.isNaN(cHSL.l) 73 | ) { 74 | let newL = cHSL.l - 0.3; 75 | if (newL < 0) { 76 | newL = 0; 77 | } 78 | cHSL.l = newL; 79 | textColor = cHSL.toString(); 80 | } 81 | 82 | return { 83 | bgColor: '#fff', 84 | textColor, 85 | }; 86 | } 87 | }, [node, colorMode]); 88 | const cssVarStyle = useMemo(() => { 89 | return { 90 | '--bg-color': bgColor, 91 | '--text-color': textColor, 92 | }; 93 | }, [bgColor, textColor]) as React.CSSProperties; 94 | 95 | return { 96 | cssVarStyle, 97 | bgColor, 98 | textColor, 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /src/biz/side-pane/file-tree/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@root/base/styled"; 2 | import { FileInfo } from "@root/model/api"; 3 | import { useNavigate, useParams } from "react-router-dom"; 4 | import { BeatLoader } from "react-spinners"; 5 | import icons from "../../components/icons"; 6 | import { File } from "./file"; 7 | 8 | 9 | const SBox = css` 10 | padding: 8px; 11 | flex: 1 1 100% !important; 12 | overflow: hidden; 13 | 14 | display: flex; 15 | flex-direction: column; 16 | `; 17 | 18 | const SLoadPlace = css` 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | height: 200px; 23 | `; 24 | 25 | const SHead = css` 26 | flex: 0 0 auto; 27 | display: flex; 28 | align-items: center; 29 | justify-content: space-between; 30 | padding: 8px 0; 31 | font-size: 16px; 32 | .head-left { 33 | display: flex; 34 | align-items: center; 35 | gap: 6px; 36 | font-size: 16px; 37 | &>svg { 38 | width: 18px; 39 | } 40 | } 41 | .head-right { 42 | .add-new-file { 43 | display: flex; 44 | align-items: center; 45 | padding: 4px; 46 | border-radius: 4px; 47 | cursor: pointer; 48 | &:hover { 49 | background-color: #ebebeb; 50 | } 51 | &>svg { 52 | width: 18px; 53 | } 54 | } 55 | } 56 | `; 57 | const SBody = css` 58 | flex: 1 1 auto; 59 | overflow-y: auto; 60 | 61 | &::-webkit-scrollbar { 62 | width: 4px; 63 | } 64 | &::-webkit-scrollbar-thumb { 65 | background: transparent; 66 | border-radius: 25px; 67 | } 68 | &:hover::-webkit-scrollbar-thumb { 69 | background: #c9c9c9; 70 | } 71 | `; 72 | 73 | 74 | export function FileTree(props: { 75 | files: FileInfo[]; 76 | loading: boolean; 77 | }) { 78 | const { files, loading } = props; 79 | const { fileId } = useParams(); 80 | const navigate = useNavigate(); 81 | 82 | if (loading) { 83 | return ( 84 |
85 | 86 |
87 | ); 88 | } 89 | 90 | return ( 91 |
92 |
93 |
94 | {icons.catalog} 95 | Catalog 96 |
97 |
98 |
navigate(`/edit`)}>{icons.add}
99 |
100 |
101 |
102 | {files.map((f) => { 103 | const active = f.id === fileId; 104 | return ; 105 | })} 106 |
107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /office-addin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "office-addin-taskpane", 3 | "version": "0.0.1", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/OfficeDev/Office-Addin-TaskPane.git" 7 | }, 8 | "license": "MIT", 9 | "config": { 10 | "app_to_debug": "word", 11 | "app_type_to_debug": "desktop", 12 | "dev_server_port": 3000 13 | }, 14 | "scripts": { 15 | "build": "webpack --mode production", 16 | "build:dev": "webpack --mode development", 17 | "dev-server": "webpack serve --mode development", 18 | "lint": "office-addin-lint check", 19 | "lint:fix": "office-addin-lint fix", 20 | "prettier": "office-addin-lint prettier", 21 | "signin": "office-addin-dev-settings m365-account login", 22 | "signout": "office-addin-dev-settings m365-account logout", 23 | "start": "office-addin-debugging start manifest.xml --no-debug", 24 | "start:dev": "npm run build && office-addin-debugging start ../mai-mind-map-se/dist/addin/manifest-dev.xml --no-debug", 25 | "start:prop": "npm run build && office-addin-debugging start ../mai-mind-map-se/dist/addin/manifest.xml --no-debug", 26 | "start:desktop": "office-addin-debugging start manifest.xml desktop", 27 | "start:web": "office-addin-debugging start manifest.xml web", 28 | "stop": "office-addin-debugging stop manifest.xml", 29 | "validate": "office-addin-manifest validate manifest.xml", 30 | "watch": "webpack --mode development --watch" 31 | }, 32 | "dependencies": { 33 | "@fluentui/web-components": "^2.6.1", 34 | "@microsoft/fast-element": "^2.0.0", 35 | "core-js": "^3.36.0", 36 | "regenerator-runtime": "^0.14.1" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.24.0", 40 | "@babel/preset-env": "^7.25.4", 41 | "@types/office-js": "^1.0.377", 42 | "@types/office-runtime": "^1.0.35", 43 | "babel-loader": "^9.1.3", 44 | "copy-webpack-plugin": "^12.0.2", 45 | "eslint-plugin-office-addins": "^3.0.2", 46 | "file-loader": "^6.2.0", 47 | "html-loader": "^5.0.0", 48 | "html-webpack-plugin": "^5.6.0", 49 | "office-addin-cli": "^1.6.3", 50 | "office-addin-debugging": "^5.1.4", 51 | "office-addin-dev-certs": "^1.13.3", 52 | "office-addin-lint": "^2.3.3", 53 | "office-addin-manifest": "^1.13.4", 54 | "office-addin-prettier-config": "^1.2.1", 55 | "os-browserify": "^0.3.0", 56 | "process": "^0.11.10", 57 | "source-map-loader": "^5.0.0", 58 | "ts-loader": "^9.5.1", 59 | "typescript": "^5.4.2", 60 | "webpack": "^5.90.3", 61 | "webpack-cli": "^5.1.4", 62 | "webpack-dev-server": "5.0.3" 63 | }, 64 | "prettier": "office-addin-prettier-config", 65 | "browserslist": [ 66 | "last 2 versions", 67 | "safari >= 16", 68 | "ie 11" 69 | ] 70 | } -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/main_mai-mind-map.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: Build and deploy Node.js app to Azure Web App - mai-mind-map 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js version 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | 24 | - name: npm install, build, and test 25 | run: | 26 | npm install -g pnpm 27 | pnpm install 28 | pnpm run build --if-present 29 | npm run test --if-present 30 | cd mai-mind-map-se 31 | pnpm install 32 | pnpm run build:server 33 | cd .. 34 | cd office-addin 35 | pnpm install 36 | pnpm run build 37 | cd .. 38 | 39 | - name: Zip artifact for deployment 40 | run: | 41 | cd mai-mind-map-se 42 | zip release.zip ./* -r 43 | cd .. 44 | 45 | - name: Upload artifact for deployment job 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: node-app 49 | path: ./mai-mind-map-se/release.zip 50 | 51 | deploy: 52 | runs-on: ubuntu-latest 53 | needs: build 54 | environment: 55 | name: 'Production' 56 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 57 | permissions: 58 | id-token: write #This is required for requesting the JWT 59 | 60 | steps: 61 | - name: Download artifact from build job 62 | uses: actions/download-artifact@v4 63 | with: 64 | name: node-app 65 | 66 | - name: Unzip artifact for deployment 67 | env: 68 | APP_CONFIG: ${{ secrets.APP_CONFIG }} 69 | shell: bash 70 | run: | 71 | unzip release.zip 72 | echo "$APP_CONFIG" > config.json 73 | 74 | - name: Login to Azure 75 | uses: azure/login@v2 76 | with: 77 | client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_61F9B8B7CA6745E290DC921D21EDAAFA }} 78 | tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_6D0DF6CC7437476FBB33533634A99E0B }} 79 | subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_56F7BDBFFDB245B797A79A1F92E77DA5 }} 80 | 81 | - name: 'Deploy to Azure Web App' 82 | id: deploy-to-webapp 83 | uses: azure/webapps-deploy@v3 84 | with: 85 | app-name: 'mai-mind-map' 86 | slot-name: 'Production' 87 | package: . 88 | -------------------------------------------------------------------------------- /.github/workflows/dev_dev-mai-mind-map.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: 【DEV】Build and deploy Node.js app to Azure Web App - dev-mai-mind-map 5 | 6 | on: 7 | push: 8 | branches: 9 | - dev 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js version 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | 24 | - name: npm install, build, and test 25 | run: | 26 | npm install -g pnpm 27 | pnpm install 28 | pnpm run build --if-present 29 | npm run test --if-present 30 | cd mai-mind-map-se 31 | pnpm install 32 | pnpm run build:server 33 | cd .. 34 | cd office-addin 35 | pnpm install 36 | pnpm run build 37 | cd .. 38 | 39 | - name: Zip artifact for deployment 40 | run: | 41 | cd mai-mind-map-se 42 | zip release.zip ./* -r 43 | cd .. 44 | 45 | - name: Upload artifact for deployment job 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: node-app 49 | path: ./mai-mind-map-se/release.zip 50 | 51 | deploy: 52 | runs-on: ubuntu-latest 53 | needs: build 54 | environment: 55 | name: 'Production' 56 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 57 | permissions: 58 | id-token: write #This is required for requesting the JWT 59 | 60 | steps: 61 | - name: Download artifact from build job 62 | uses: actions/download-artifact@v4 63 | with: 64 | name: node-app 65 | 66 | - name: Unzip artifact for deployment 67 | env: 68 | APP_CONFIG_DEV: ${{ secrets.APP_CONFIG_DEV }} 69 | shell: bash 70 | run: | 71 | unzip release.zip 72 | echo "$APP_CONFIG_DEV" > config.json 73 | 74 | - name: Login to Azure 75 | uses: azure/login@v2 76 | with: 77 | client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_939CCDD984A44AC88D225CF69F9EF117 }} 78 | tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_22C8C080D66243088E4E5EC4568FDA22 }} 79 | subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_A1BE5DF37E694CC4B34D2F278563FC5D }} 80 | 81 | - name: 'Deploy to Azure Web App' 82 | id: deploy-to-webapp 83 | uses: azure/webapps-deploy@v3 84 | with: 85 | app-name: 'dev-mai-mind-map' 86 | slot-name: 'Production' 87 | package: . 88 | -------------------------------------------------------------------------------- /src/components/outline/icons.tsx: -------------------------------------------------------------------------------- 1 | 2 | const bold = ( 3 | 4 | 5 | 6 | 7 | ); 8 | 9 | const italic = ( 10 | 11 | 12 | 13 | ); 14 | 15 | const underline = ( 16 | 17 | 18 | 19 | ); 20 | 21 | const clearFormat = ( 22 | 23 | 24 | 25 | 26 | ); 27 | 28 | export const icons = { 29 | bold, 30 | italic, 31 | underline, 32 | clearFormat, 33 | }; 34 | -------------------------------------------------------------------------------- /mai-mind-map-se/server/server.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import cors from 'cors'; 3 | import express, { 4 | type Request, 5 | type Response, 6 | type Application, 7 | type NextFunction, 8 | } from 'express'; 9 | import session from 'express-session'; 10 | import {router as userRouter} from './controllers/users' 11 | import {docsRouter} from './controllers/docs' 12 | import {router as authRouter} from './controllers/auth' 13 | 14 | declare module 'express-session' { 15 | interface SessionData { 16 | isAuthenticated: boolean; 17 | account: { name: string; username: string }; 18 | } 19 | } 20 | import cookieParser from 'cookie-parser'; 21 | import { readConfig } from './utils'; 22 | import { isAuthenticated } from './controllers/users'; 23 | 24 | function normalizePort(val: string): number { 25 | const port = Number.parseInt(val, 10); 26 | 27 | if (Number.isNaN(port)) { 28 | // named pipe 29 | return 3000; 30 | } 31 | 32 | if (port >= 0) { 33 | // port number 34 | return port; 35 | } 36 | 37 | return 3000; 38 | } 39 | 40 | export function run() { 41 | const app: Application = express(); 42 | const port = normalizePort(process.env.PORT || '3000'); 43 | app.use(cors()); 44 | app.use(session({ 45 | secret: readConfig().EXPRESS_SESSION_SECRET, 46 | resave: false, 47 | saveUninitialized: false, 48 | cookie: { 49 | httpOnly: true, 50 | secure: false, 51 | } 52 | })); 53 | app.use(express.json()); 54 | app.use(cookieParser()); 55 | app.use(express.urlencoded({ extended: false })); 56 | 57 | app.get('/', (req: Request, res: Response) => { 58 | res.sendFile(path.resolve(__dirname, '../dist/index.html')); 59 | }); 60 | app.get('/edit', isAuthenticated, (req: Request, res: Response) => { 61 | res.sendFile(path.resolve(__dirname, '../dist/index.html')); 62 | }); 63 | app.get('/edit/:id', isAuthenticated, (req: Request, res: Response) => { 64 | res.sendFile(path.resolve(__dirname, '../dist/index.html')); 65 | }); 66 | app.get('/favicon.ico', (req: Request, res: Response) => { 67 | res.sendFile(path.resolve(__dirname, '../dist/favicon.ico')); 68 | }); 69 | app.use('/static', express.static(path.resolve(__dirname, '../dist/static'))); 70 | 71 | app.use(express.raw({ type: '*/*', limit: '10mb' })); 72 | app.use('/api', docsRouter); 73 | app.use('/users', userRouter); 74 | app.use('/auth', authRouter); 75 | app.use('/cookie/unrestrict', (req: Request, res: Response) => { 76 | res.cookie('connect.sid', req.cookies['connect.sid'], { httpOnly: true, secure: true, path:'/', sameSite: 'none' }); 77 | res.send({}); 78 | }); 79 | 80 | app.use('/addin', express.static(path.resolve(__dirname, '../dist/addin'))); 81 | 82 | app.listen(port, () => { 83 | console.log(`Server is Fire at http://localhost:${port}`); 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/outline/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@root/base/styled'; 2 | import { MindMapState } from '@root/components/state/mindMapState'; 3 | import React, { useMemo, useReducer, Fragment, useContext } from 'react'; 4 | import { INDENT } from './common'; 5 | import Treeline from './tree-line'; 6 | import { ViewModel } from './view-model'; 7 | 8 | /** 9 | * ---------------------------------------------------------------------------------------------------- 10 | * Styles for this file (install `vscode-style-components` ext for better dev) 11 | * ---------------------------------------------------------------------------------------------------- 12 | */ 13 | const SBody = css` 14 | max-width: 800px; 15 | margin: 0 auto; 16 | `; 17 | const SDocTitle = css` 18 | padding: 12px 0; 19 | margin: 0 0 12px; 20 | font-weight: normal; 21 | font-size: 20px; 22 | border-bottom: 1px dashed #ccc; 23 | `; 24 | const SSection = css` 25 | position: relative; 26 | & > .vline { 27 | position: absolute; 28 | top: 0; 29 | height: 100%; 30 | width: 1px; 31 | border-left: 1px dashed #ccc; 32 | } 33 | `; 34 | 35 | /** 36 | * ------------------------------------------------------------------------------------------ 37 | * Component to render the outline view for the tree 38 | * ------------------------------------------------------------------------------------------ 39 | */ 40 | export function OutlineView() { 41 | const mindMapState = useContext(MindMapState); 42 | // const forceUpdate = useReducer((x) => x + 1, 0)[1]; 43 | const view = useMemo(() => { 44 | console.log('create view model'); 45 | return new ViewModel(mindMapState); 46 | }, [mindMapState]); 47 | 48 | // @ts-ignore leave this for debug 49 | window._vm_ = view; 50 | 51 | /** 52 | * ① we need to make the tree here to make sure the re-render after data changed 53 | * ② Treeline is a memo component, so most of them won't re-render in a change loop 54 | */ 55 | function makeTree(depth: number, list?: string[]): React.ReactNode { 56 | if (!list || list.length === 0) return null; 57 | const children = list.map((id, i) => { 58 | const node = view.all[id]; 59 | return ( 60 | 61 | 62 | {!node.payload.collapsed && makeTree(depth + 1, node.children)} 63 | 64 | ); 65 | }); 66 | return ( 67 |
68 | {depth > 0 && ( 69 |
70 | )} 71 | {children} 72 |
73 | ); 74 | } 75 | 76 | return ( 77 |
78 |

The root is {view.root.payload.content}

79 | {makeTree(0, view.root.children)} 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/model/data/behavior.ts: -------------------------------------------------------------------------------- 1 | import { $ } from './higher-kinded-type'; 2 | import { $Struct, AnyDict, Dict } from './struct'; 3 | 4 | /** 5 | * Behavior 6 | */ 7 | export type Behavior = { 8 | $string: $; 9 | $number: $; 10 | $boolean: $; 11 | $array: (elm: $) => $; 12 | $dict: (val: $) => $>; 13 | $struct: (struct: $Struct) => $; 14 | }; 15 | 16 | /** 17 | * Behavior Definition 18 | */ 19 | export type BehaviorDef< 20 | Type extends AnyDict, 21 | Base extends AnyDict = AnyDict, 22 | > = { 23 | $string: (u: $) => $; 24 | $number: (u: $) => $; 25 | $boolean: (u: $) => $; 26 | $array: (u: $) => (elm: $) => $; 27 | $dict: ( 28 | u: $>, 29 | ) => (val: $) => $>; 30 | $struct: ( 31 | u: $, 32 | ) => (stt: $Struct) => $; 33 | }; 34 | 35 | const define = ( 36 | { $string, $number, $boolean, $array, $dict, $struct }: Behavior, 37 | def: BehaviorDef, 38 | ) => 39 | ({ 40 | $string: Object.assign({}, $string, def.$string($string)), 41 | $number: Object.assign({}, $number, def.$number($number)), 42 | $boolean: Object.assign({}, $boolean, def.$boolean($boolean)), 43 | $array: (elm: $) => { 44 | const bhv = $array(elm); 45 | return Object.assign({}, bhv, def.$array(bhv)(elm)); 46 | }, 47 | $dict: (val: $) => { 48 | const bhv = $dict(val); 49 | return Object.assign({}, bhv, def.$dict(bhv)(val)); 50 | }, 51 | $struct: (stt: $Struct) => { 52 | const bhv = $struct(stt); 53 | return Object.assign({}, bhv, def.$struct(bhv)(stt)); 54 | }, 55 | }) as Behavior; 56 | 57 | type Builder = { 58 | mixin: (def: BehaviorDef) => Builder; 59 | build: () => Behavior; 60 | }; 61 | 62 | const builder = (bhv: Behavior): Builder => ({ 63 | mixin: (def) => builder(define(bhv, def)), 64 | build: () => bhv, 65 | }); 66 | 67 | export const behavior = (bhv: Behavior) => 68 | ({ 69 | $string: () => bhv.$string, 70 | $number: () => bhv.$number, 71 | $boolean: () => bhv.$boolean, 72 | $array: () => bhv.$array, 73 | $dict: () => bhv.$dict, 74 | $struct: () => bhv.$struct, 75 | }) as BehaviorDef; 76 | 77 | export const BehaviorBuilder = builder({ 78 | $string: {}, 79 | $number: {}, 80 | $boolean: {}, 81 | $array: () => ({}), 82 | $dict: () => ({}), 83 | $struct: () => ({}), 84 | }); 85 | -------------------------------------------------------------------------------- /src/components/mind-map/render/hooks/useAutoColoringMindMap.tsx: -------------------------------------------------------------------------------- 1 | import { Payload } from '@root/components/mind-map/render/model/interface'; 2 | import { RawNode } from '@root/components/mind-map/render/node/interface'; 3 | import { MindMapStateType } from '@root/components/state/mindMapState'; 4 | import { hsl } from 'd3-color'; 5 | import { debounce } from 'lodash'; 6 | import { useEffect } from 'react'; 7 | 8 | const rootColor = '#212429'; 9 | function shuffle(array: T[]): T[] { 10 | for (let i = array.length - 1; i > 0; i--) { 11 | const j = Math.floor(Math.random() * (i + 1)); 12 | [array[i], array[j]] = [array[j], array[i]]; 13 | } 14 | return array; 15 | } 16 | 17 | export const supportColors = [ 18 | '#E7A400', 19 | '#E67505', 20 | '#EB4824', 21 | '#E82C41', 22 | '#D7257D', 23 | '#B91CBF', 24 | '#9529C2', 25 | '#6D37CD', 26 | '#4A43CB', 27 | '#2052CB', 28 | '#0067BF', 29 | '#0C74A1', 30 | '#1A7F7C', 31 | '#288A56', 32 | '#379539', 33 | '#63686E', 34 | '#666666', 35 | ]; 36 | 37 | const colors = shuffle([...supportColors]); 38 | 39 | function ColoringNode( 40 | node: RawNode, 41 | color: string, 42 | modifyNodePayload: (nodeId: string, payload: Payload) => void, 43 | ) { 44 | if (node.payload.hilight !== color) { 45 | modifyNodePayload( 46 | node.id, 47 | Object.assign({}, node.payload, { hilight: color }), 48 | ); 49 | } 50 | node.children?.forEach((child, ind) => { 51 | ColoringNode(child, color, modifyNodePayload); 52 | }); 53 | } 54 | 55 | function ColoringMindMap(tresState: MindMapStateType | null) { 56 | if (!tresState) return; 57 | 58 | // Coloring root node 59 | const root = tresState.mindMapData; 60 | const modifyNodePayload = tresState.modifyNodePayload; 61 | if (!root.payload.hilight) { 62 | modifyNodePayload( 63 | root.id, 64 | Object.assign({}, root.payload, { hilight: rootColor }), 65 | ); 66 | } 67 | 68 | // Coloring sub nodes 69 | const colorSet = new Set(colors); 70 | root.children?.forEach((node) => { 71 | if (node.payload.hilight) { 72 | colorSet.delete(node.payload.hilight); 73 | ColoringNode(node, node.payload.hilight, modifyNodePayload); 74 | } 75 | }); 76 | 77 | root.children?.forEach((node) => { 78 | if (!node.payload.hilight) { 79 | if (colorSet.size > 0) { 80 | const color = colorSet.values().next().value; 81 | colorSet.delete(color); 82 | ColoringNode(node, color, modifyNodePayload); 83 | } else { 84 | const randomColor = hsl(Math.random() * 360, 0.8, 0.6).toString(); 85 | ColoringNode(node, randomColor, modifyNodePayload); 86 | } 87 | } 88 | }); 89 | } 90 | const debounceColoringMindMap = debounce(ColoringMindMap, 1000); 91 | 92 | export function useAutoColoringMindMap(tresState: MindMapStateType | null) { 93 | useEffect(() => { 94 | debounceColoringMindMap(tresState); 95 | }, [tresState]); 96 | } 97 | -------------------------------------------------------------------------------- /src/components/presentation/presentation-button/presentation-button.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import { PresentationNode } from '../presentation-model/presentation-node'; 4 | import PresentationViewComponent from '../presentation-view/presentation-view'; 5 | 6 | interface PresentationButtonProps { 7 | rootNode: PresentationNode; 8 | className?: string; 9 | id?: string; 10 | style?: React.CSSProperties; 11 | children?: React.ReactNode; 12 | } 13 | 14 | interface PresentationButtonState { 15 | fullscreen: boolean; 16 | } 17 | 18 | class PresentationButton extends Component< 19 | PresentationButtonProps, 20 | PresentationButtonState 21 | > { 22 | constructor(props: PresentationButtonProps) { 23 | super(props); 24 | this.state = { 25 | fullscreen: false, 26 | }; 27 | } 28 | 29 | componentDidMount(): void { 30 | document.addEventListener('fullscreenchange', this.handleFullscreenChange); 31 | } 32 | 33 | componentWillUnmount(): void { 34 | document.removeEventListener( 35 | 'fullscreenchange', 36 | this.handleFullscreenChange, 37 | ); 38 | } 39 | 40 | private handleFullscreenChange = () => { 41 | this.setState({ 42 | fullscreen: !!document.fullscreenElement, 43 | }); 44 | }; 45 | 46 | private enterFullscreen = () => { 47 | this.setState( 48 | { 49 | fullscreen: true, 50 | }, 51 | () => { 52 | const fullscreenView = document.getElementById( 53 | 'presentation-fullscreen-view', 54 | ); 55 | if (!fullscreenView) { 56 | return; 57 | } 58 | fullscreenView.requestFullscreen && 59 | fullscreenView.requestFullscreen().catch((err) => { 60 | console.error( 61 | `Error attempting to enable full-screen mode: ${err.message} (${err.name})`, 62 | ); 63 | }); 64 | }, 65 | ); 66 | }; 67 | 68 | private exitFullscreen = () => { 69 | const fullscreenView = document.getElementById( 70 | 'presentation-fullscreen-view', 71 | ); 72 | if (!fullscreenView) { 73 | return; 74 | } 75 | document.exitFullscreen && document.exitFullscreen(); 76 | this.setState({ 77 | fullscreen: false, 78 | }); 79 | }; 80 | 81 | render() { 82 | const { className, id, style, rootNode } = this.props; 83 | const { fullscreen } = this.state; 84 | return ( 85 |
91 | {this.props.children} 92 | 93 | {fullscreen && ( 94 | 99 | )} 100 |
101 | ); 102 | } 103 | } 104 | 105 | export default PresentationButton; 106 | -------------------------------------------------------------------------------- /src/components/presentation/page-tree/page-tree.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | interface PageTreeLinePoint { 4 | x: number; 5 | y: number; 6 | } 7 | 8 | interface PageTreeComponentProps { 9 | beginPoint: PageTreeLinePoint; 10 | endPoints: PageTreeLinePoint[]; 11 | lineWidth: number; 12 | color: string; 13 | style?: React.CSSProperties; 14 | } 15 | 16 | interface PageTreeComponentState { 17 | 18 | } 19 | 20 | class PageTreeComponent extends Component { 21 | render() { 22 | const { beginPoint, endPoints, lineWidth, color, style } = this.props; 23 | 24 | const beginX = beginPoint.x; 25 | const beginY = beginPoint.y; 26 | return ( 27 | 36 | 42 | { 43 | endPoints.map((endPoint, index) => { 44 | const control1X = (beginPoint.x + endPoint.x) / 2; 45 | const control1Y = beginPoint.y; 46 | 47 | const control2X = (beginPoint.x + endPoint.x) / 2; 48 | const control2Y = endPoint.y; 49 | 50 | const endX = endPoint.x; 51 | const endY = endPoint.y; 52 | if (endX < beginX) { 53 | return ; 56 | } 57 | return ( 58 | 61 | 67 | 73 | 74 | ); 75 | }) 76 | } 77 | 78 | 79 | ); 80 | } 81 | } 82 | 83 | export { 84 | PageTreeComponent, 85 | } 86 | 87 | export type { 88 | PageTreeLinePoint, 89 | } -------------------------------------------------------------------------------- /src/components/mind-map/Controllers/ColorControl.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@base/styled'; 2 | import { Payload } from '@root/components/mind-map/render/model/interface'; 3 | import { RawNode } from '@root/components/mind-map/render/node/interface'; 4 | import { MindMapState } from '@root/components/state/mindMapState'; 5 | import { MindMapStateType } from '@root/components/state/mindMapState'; 6 | import { throttle } from 'lodash'; 7 | import { FC, Fragment, useContext } from 'react'; 8 | import { ColorMode } from '../render/hooks/constants'; 9 | 10 | const SColorModeControlBox = css` 11 | margin: 5px 0 15px; 12 | `; 13 | const SColorModeControl = css` 14 | display: flex; 15 | align-items: center; 16 | line-height: 20px; 17 | gap: 10px; 18 | margin-bottom: 10px; 19 | & > .label { 20 | width: 80px; 21 | text-align: right; 22 | } 23 | & > .mode-item { 24 | background-color: aliceblue; 25 | padding: 1px 10px; 26 | cursor: pointer; 27 | border: 1px solid #1893ff; 28 | transform-origin: center; 29 | display: flex; 30 | padding: 5px; 31 | &:hover { 32 | background-color: #badfff; 33 | } 34 | &.active { 35 | background-color: #1893ff; 36 | color: white; 37 | } 38 | } 39 | `; 40 | const SColorPane = css` 41 | background: linear-gradient(90deg, red, yellow, green, cyan, blue, magenta); 42 | height: 20px; 43 | `; 44 | const SDefaultPane = css` 45 | background: linear-gradient(90deg, #0172dc, #ecf2fb, #fff); 46 | height: 20px; 47 | `; 48 | 49 | const SColoringBtn = css` 50 | margin-top: 5px; 51 | display: inline-block; 52 | padding: 5px 10px; 53 | border: 1px solid #1893ff; 54 | cursor: pointer; 55 | background-color: aliceblue; 56 | &:hover { 57 | background-color: #badfff; 58 | } 59 | `; 60 | 61 | 62 | export const ColorModeControl: FC<{ 63 | colorMode: ColorMode; 64 | setColorMode: (val: ColorMode) => void; 65 | }> = (props) => { 66 | const { colorMode, setColorMode } = props; 67 | const treeState = useContext(MindMapState); 68 | return ( 69 |
70 |
71 | ColorMode: 72 | {( 73 | [ 74 | { 75 | c: 'Default', 76 | k: ColorMode.DEFAULT, 77 | desc: 'System default color', 78 | }, 79 | { 80 | c: 'Colorful', 81 | k: ColorMode.COLORFUL, 82 | desc: 'Various colors', 83 | }, 84 | ] as const 85 | ).map((d) => { 86 | return ( 87 |
{ 94 | setColorMode(d.k); 95 | }} 96 | > 97 | {d.c} 98 |
99 | ); 100 | })} 101 |
102 |
103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import { useEffect } from 'react'; 3 | import { useParams, useNavigate, useLocation } from 'react-router-dom'; 4 | import { useAtom } from './base/atom'; 5 | import { filesAtom } from './biz/store'; 6 | import { FloatingTools } from './biz/floating'; 7 | import { Header } from './biz/head'; 8 | import { LayoutStyle } from './biz/layout'; 9 | import { SidePane } from './biz/side-pane'; 10 | import { viewModeAtom } from './biz/store'; 11 | import { LoadingVeiw } from './components/LoadingView'; 12 | import { MindMapView } from './components/mind-map/MapIndex'; 13 | import { useAutoColoringMindMap } from './components/mind-map/render/hooks/useAutoColoringMindMap'; 14 | import { OutlineView } from './components/outline'; 15 | import { MindMapState, useMindMapState } from './components/state/mindMapState'; 16 | import { 17 | useId, 18 | useToastController, 19 | Toaster, 20 | ToastTitle, 21 | Toast 22 | } from "@fluentui/react-components"; 23 | 24 | const App = () => { 25 | const { fileId: id } = useParams(); 26 | const { treeState, loadState } = useMindMapState(id || ''); 27 | useAutoColoringMindMap(treeState); 28 | const [view] = useAtom(viewModeAtom); 29 | 30 | // for create new doc start 31 | const [, , { createDoc, refresh }] = useAtom(filesAtom); 32 | const navigate = useNavigate(); 33 | const reactLocation = useLocation(); 34 | const toasterId = useId("toaster"); 35 | const { dispatchToast } = useToastController(toasterId); 36 | 37 | useEffect(() => { 38 | const controller = new AbortController(); 39 | const signal = controller.signal; 40 | 41 | if (reactLocation.pathname === '/edit') { 42 | createDoc(navigate); 43 | } 44 | 45 | if (/\/edit\/.+/.test(reactLocation.pathname)) { 46 | document.addEventListener('keydown', (event: KeyboardEvent) => { 47 | if (event.ctrlKey && event.key === 's') { 48 | event.preventDefault(); 49 | treeState.saveDocument(() => { 50 | refresh(); 51 | dispatchToast( 52 | 53 | save success! 54 | , 55 | { position: "top-end", intent: "success" } 56 | ); 57 | }); 58 | } 59 | }, { signal }); 60 | } 61 | 62 | return () => { 63 | controller.abort(); 64 | } 65 | }, [createDoc, navigate, reactLocation]); 66 | // for create new doc end 67 | 68 | function renderContent() { 69 | if (loadState.type === 'loaded') { 70 | return ( 71 | <> 72 | {view === 'mindmap' ? : } 73 | 74 | 75 | ); 76 | } 77 | return ; 78 | } 79 | 80 | return ( 81 | 82 | 83 |
84 |
85 | 86 |
87 |
88 |
89 |
90 |
91 |
{renderContent()}
92 |
93 |
94 |
95 | ); 96 | }; 97 | 98 | export default App; 99 | -------------------------------------------------------------------------------- /src/biz/head/share.tsx: -------------------------------------------------------------------------------- 1 | import clsnames from "@base/classnames"; 2 | import copyText from "@base/copy-text"; 3 | import { css } from "@root/base/styled"; 4 | import { useCallback, useState } from "react"; 5 | import icons from "../components/icons"; 6 | import Popup from "../components/popup"; 7 | 8 | const SShare = css` 9 | display: flex; 10 | align-items: center; 11 | height: 28px; 12 | width: 28px; 13 | padding: 6px; 14 | border-radius: 4px; 15 | cursor: pointer; 16 | 17 | &:hover, &.share-active { 18 | background-color: rgba(0,0,0,0.04); 19 | } 20 | `; 21 | const SPopup = css` 22 | padding: 12px 16px; 23 | width: 420px; 24 | h1 { 25 | all: unset; 26 | font-size: 18px; 27 | font-weight: 500; 28 | } 29 | .share-url { 30 | margin: 12px 0; 31 | white-space: nowrap; 32 | overflow: hidden; 33 | text-overflow: ellipsis; 34 | color: #828282; 35 | border: 1px solid #ececec; 36 | border-radius: 4px; 37 | background-color: #eef1f4; 38 | padding: 4px 8px; 39 | } 40 | .share-bottom { 41 | display: flex; 42 | align-items: center; 43 | justify-content: space-between; 44 | .share-desc { 45 | font-size: 14px; 46 | color: #555555; 47 | } 48 | } 49 | .share-actions { 50 | display: flex; 51 | justify-content: flex-end; 52 | gap: 8px; 53 | 54 | button { 55 | padding: 6px 12px; 56 | border: 1px solid #ccc; 57 | border-radius: 4px; 58 | background-color: transparent; 59 | cursor: pointer; 60 | &:hover { 61 | border-color: #2370ff; 62 | background-color: #2370ff; 63 | color: white !important; 64 | } 65 | } 66 | } 67 | `; 68 | 69 | function Pannel(props: { 70 | position: [x: number, y: number]; 71 | hide: () => void; 72 | }) { 73 | const { hide, position: [right, top] } = props; 74 | 75 | return ( 76 | 77 |
78 |

Share this file

79 |
{location.href}
80 |
81 |
Copy url to anyone
82 |
83 | 92 | 93 |
94 |
95 |
96 |
97 | ); 98 | } 99 | 100 | export function Share() { 101 | const [popup, setPopup] = useState<[number, number] | null>(null); 102 | const hide = useCallback(() => setPopup(null), []); 103 | return <> 104 |
{ 107 | const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); 108 | setPopup([window.innerWidth - rect.right, rect.bottom + 4]); 109 | }} 110 | > 111 | {icons.share} 112 |
113 | {popup && ( 114 | 115 | )} 116 | ; 117 | } 118 | -------------------------------------------------------------------------------- /src/biz/side-pane/file-tree/file.tsx: -------------------------------------------------------------------------------- 1 | import clsnames from "@root/base/classnames"; 2 | import { css } from "@root/base/styled"; 3 | import Popup, { PopupPosition } from "@root/biz/components/popup"; 4 | import { FileInfo } from "@root/model/api"; 5 | import { useState } from "react"; 6 | import { useNavigate } from "react-router-dom"; 7 | import icons from "../../components/icons"; 8 | import { Menu, MenuProps } from "./menu"; 9 | import { Rename } from "./rename"; 10 | 11 | const SFile = css` 12 | display: flex; 13 | align-items: center; 14 | height: 32px; 15 | padding: 6px 6px 6px 20px; 16 | gap: 8px; 17 | cursor: pointer; 18 | font-size: 14px; 19 | &:hover, &.active { 20 | background-color: rgba(0,0,0,0.04); 21 | } 22 | &+& { 23 | margin-top: 1px; 24 | } 25 | 26 | .file-left { 27 | flex: 1 1 100%; 28 | display: flex; 29 | gap: 8px; 30 | overflow: hidden; 31 | .file-icon { 32 | width: 16px; 33 | display: flex; 34 | align-items: center; 35 | } 36 | .file-title { 37 | flex: 1 1 100%; 38 | white-space: nowrap; 39 | overflow: hidden; 40 | text-overflow: ellipsis; 41 | } 42 | } 43 | .file-right { 44 | flex: 0 0 auto; 45 | display: none; 46 | align-items: center; 47 | } 48 | &:hover>.file-right, .file-right.active { 49 | display: flex; 50 | } 51 | `; 52 | const SMore = css` 53 | width: 24px; 54 | height: 24px; 55 | padding: 4px; 56 | border-radius: 2px; 57 | &:hover, &.active { 58 | background-color: #e4e4e4; 59 | } 60 | `; 61 | 62 | function Actions(props: MenuProps) { 63 | const [morePosition, setPos] = useState(null); 64 | const active = Boolean(morePosition) 65 | 66 | return ( 67 |
e.stopPropagation()}> 68 |
{ 71 | if (morePosition) return; 72 | const { x, bottom } = (ev.currentTarget).getBoundingClientRect(); 73 | setPos({ left: x, top: bottom + 4 }); 74 | }} 75 | > 76 | {icons.more} 77 |
78 | {morePosition && ( 79 | setPos(null)}> 80 | 81 | 82 | )} 83 |
84 | ); 85 | } 86 | 87 | export function File(props: { 88 | file: FileInfo; 89 | active?: boolean; 90 | }) { 91 | const { file, active } = props; 92 | const { id, title = 'Untitled' } = file; 93 | const [renameing, setRename] = useState(false); 94 | const navigate = useNavigate(); 95 | 96 | return ( 97 |
navigate(`/edit/${id}${location.search}`)} 100 | > 101 |
102 |
{icons.mindmap}
103 | {renameing ? ( 104 | setRename(false)} /> 105 | ) : ( 106 |
{title}
107 | )} 108 |
109 | {!renameing && ( 110 | setRename(true)} /> 111 | )} 112 |
113 | ); 114 | } 115 | 116 | -------------------------------------------------------------------------------- /src/base/atom.readme.md: -------------------------------------------------------------------------------- 1 | ## Usage Sample 2 | 3 | Declare atoms any where 4 | ```ts 5 | export const atomA = atom(1); 6 | export const atomB = atom('2'); 7 | export const atomC = atom([]); 8 | ``` 9 | 10 | Consume atoms 11 | ```tsx 12 | import React from 'react'; 13 | 14 | const BizA = () => { 15 | const [a] = useAtom(atomA); 16 | const [b] = useAtom(atomB); 17 | const setC = useChange(atomC); 18 | const removeB = () => setC(list => list.filter(item => (item !== b)));; 19 | return
{a}
; 20 | }; 21 | 22 | const BizB = () => { 23 | const [list] = useAtom(store.c); 24 | return ( 25 |
26 | {list.map(item => {item})} 27 |
28 | ); 29 | }; 30 | 31 | const App = () => ( 32 | 33 |
34 |
35 |
36 | ); 37 | 38 | export default App; 39 | ``` 40 | 41 | ## API Referrence 42 | #### ① WithStore 43 | Nothing special for this, just make it as the root of the compoment tree. then all descendant components can share atoms. 44 | 45 | Better to use it in this way 46 | ```tsx 47 | const root = ReactDOM.createRoot(rootEl); 48 | root.render( 49 | 50 | 51 | 52 | ); 53 | ``` 54 | 55 | #### ② atom, useAtom, useChange 56 | Simple atom with only data 57 | ```tsx 58 | const a = atom(123); 59 | 60 | const [data, setData] = useAtom(a); 61 | console.log(data); // 123 62 | setDate(456); // change to 456 63 | 64 | const setData = useChange(a); 65 | setData(456); // change to 456 66 | setDate((old) => old + 111); // change from 456 to 567 67 | ``` 68 | 69 | What's the difference between `useAtom` and `useChange` 70 | ```tsx 71 | // when data in atom changes, component A will re-render 72 | function A() { 73 | const [data, setData] = useAtom(a); 74 | return ...; 75 | } 76 | 77 | // when data in atom changes, component B won't re-render 78 | function B() { 79 | const setData = useChange(a); 80 | return ...; 81 | } 82 | ``` 83 | 84 | Complex atom with actions 85 | ```tsx 86 | interface Payload { 87 | name: string; 88 | age: string; 89 | optimize?: boolean; 90 | } 91 | 92 | const DefaultPayload: Payload = { 93 | name: 'JiaShuang', 94 | age: 18, 95 | }; 96 | 97 | const b = atom(35); 98 | const a = atom(DefaultPayload, (get, set, use) => { 99 | function changName(name: string) { 100 | set({ ...get(), name }); 101 | } 102 | function changeAge(age: number) { 103 | set({ ...get(), age }); 104 | } 105 | function checkOptimize() { 106 | // query data from other atom 107 | const line = use(b).data; 108 | const payload = get(); 109 | if (payload.age > line) { 110 | set({ ...payload, optimize: true }); 111 | } 112 | } 113 | }); 114 | 115 | const [payload, setPayload, actions] = useAtom(a); 116 | actions.changName('Yanzu Wu'); 117 | actions.changeAge(48); 118 | actions.checkOptimize(); 119 | 120 | const [setPayload, actions] = useChange(a); 121 | ... 122 | ``` 123 | 124 | #### ③ proxy 125 | Just like the middleware of redux, set a proxy to atom can help a lot 126 | 127 | For example, if you want to track the call stack of atom data change: 128 | ```tsx 129 | const a = atom(123); 130 | a.proxy = (set) => (change) => { 131 | console.trace(); 132 | return set(change); 133 | }; 134 | ``` 135 | -------------------------------------------------------------------------------- /src/biz/side-pane/file-tree/menu.tsx: -------------------------------------------------------------------------------- 1 | import { useChange } from "@root/base/atom"; 2 | import copyTextToClipboard from "@root/base/copy-text"; 3 | import { css } from "@root/base/styled"; 4 | import Modal from "@root/biz/components/modal"; 5 | import { filesAtom } from "@root/biz/store"; 6 | import { FileInfo } from "@root/model/api"; 7 | import { useNavigate } from "react-router-dom"; 8 | import icons from "../../components/icons"; 9 | 10 | const SMenu = css` 11 | &>div { 12 | display: flex; 13 | align-items: center; 14 | gap: 8px; 15 | padding: 8px 12px; 16 | width: 160px; 17 | cursor: pointer; 18 | &:hover { 19 | background-color: #f2f2f2; 20 | } 21 | &>svg { 22 | flex: 0 0 20px 23 | } 24 | &>span { 25 | flex: 1 1 100%; 26 | font-size: 14px; 27 | overflow: hidden; 28 | white-space: nowrap; 29 | text-overflow: ellipsis; 30 | } 31 | } 32 | `; 33 | const SDelete = css` 34 | padding: 8px 20px; 35 | h2 { 36 | all: unset; 37 | display: block; 38 | font-size: 20px; 39 | font-weight: 500; 40 | text-align: center; 41 | } 42 | div { 43 | margin-top: 12px; 44 | display: flex; 45 | gap: 20px; 46 | justify-content: center; 47 | button { 48 | padding: 8px 16px; 49 | border: 1px solid #ccc; 50 | border-radius: 4px; 51 | background-color: transparent; 52 | cursor: pointer; 53 | &:hover { 54 | border-color: #2370ff; 55 | background-color: #2370ff; 56 | color: white; 57 | } 58 | } 59 | } 60 | `; 61 | 62 | export interface MenuProps { 63 | file: FileInfo; 64 | rename: VoidFunction; 65 | } 66 | 67 | export function Menu(props: MenuProps) { 68 | const { file, rename } = props; 69 | const actions = useChange(filesAtom)[1]; 70 | const navigate = useNavigate(); 71 | 72 | function copy() { 73 | copyTextToClipboard(`${location.origin}/edit/${file.id}`) 74 | .then(() => { 75 | const hide = Modal.show( 76 |
77 | Added to clipboard 78 |
79 | ); 80 | setTimeout(hide, 1500); 81 | }); 82 | } 83 | 84 | function remove() { 85 | const hide = Modal.show( 86 |
87 |

Confirm Delete

88 |
89 | 90 | 96 |
97 |
98 | ); 99 | } 100 | 101 | return ( 102 |
103 |
window.open(`/edit/${file.id}`, '_blank')}> 104 | {icons.openInNewTab} 105 | Open in new tab 106 |
107 |
108 | {icons.link} 109 | Copy link 110 |
111 |
112 | {icons.rename} 113 | Rename 114 |
115 |
116 | {icons.remove} 117 | Delete 118 |
119 |
120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /src/model/observable.ts: -------------------------------------------------------------------------------- 1 | import { Queue, queue } from './queue'; 2 | 3 | export type Observer = (value: T) => void; 4 | export type Unobserve = () => void; 5 | 6 | type ObservableCore = { 7 | peek(): T; 8 | observe(observer: Observer): Unobserve; 9 | release(): void; 10 | }; 11 | 12 | export type Observable = ObservableCore & { 13 | map(f: (value: T) => U): Observable; 14 | bind(f: (value: T) => Observable): Observable; 15 | }; 16 | 17 | export type Mutable = Observable & { 18 | update(updater: (value: T) => T): void; 19 | }; 20 | 21 | function monadic(ob: ObservableCore): Observable { 22 | function bind(f: (valueT: T) => Observable): Observable { 23 | return observable((update) => { 24 | const onInnerUpdate = (valueInner: U) => update(() => valueInner); 25 | const obInner = f(ob.peek()); 26 | let unobInner = obInner.observe(onInnerUpdate); 27 | const unobOuter = ob.observe((valueOuter: T) => { 28 | unobInner(); 29 | const obInnerT = f(valueOuter); 30 | unobInner = obInnerT.observe(onInnerUpdate); 31 | onInnerUpdate(obInnerT.peek()); 32 | }); 33 | return [ 34 | obInner.peek(), 35 | () => { 36 | unobInner(); 37 | unobOuter(); 38 | }, 39 | ]; 40 | }); 41 | } 42 | 43 | return { ...ob, bind, map: (f) => bind((valueT) => pure(f(valueT))) }; 44 | } 45 | 46 | export function mutable( 47 | initValue: T, 48 | release: () => void = () => {}, 49 | ): Mutable { 50 | let value = initValue; 51 | const observers: Queue> = queue(); 52 | 53 | return { 54 | ...monadic({ 55 | peek: () => value, 56 | observe: observers.enqueue, 57 | release, 58 | }), 59 | update(updater) { 60 | const newValue = updater(value); 61 | if (newValue !== value) { 62 | value = newValue; 63 | // biome-ignore lint/complexity/noForEach: 64 | observers.toArray().forEach((observer) => observer(newValue)); 65 | } 66 | }, 67 | }; 68 | } 69 | 70 | export function observable( 71 | init: (update: (updater: (value: T) => T) => void) => [T, () => void], 72 | ): Observable { 73 | const updateT = (updater: (value: T) => T) => update(updater); 74 | const [initValue, release] = init(updateT); 75 | const { peek, observe, update, map, bind } = mutable(initValue, release); 76 | return { peek, observe, map, bind, release }; 77 | } 78 | 79 | export function pure(value: T): Observable { 80 | return monadic({ 81 | peek: () => value, 82 | observe: () => () => {}, 83 | release: () => {}, 84 | }); 85 | } 86 | 87 | type Observables = { 88 | [K in keyof T]: Observable; 89 | }; 90 | 91 | export function apply any>( 92 | f: F, 93 | ...args: Observables> 94 | ): Observable> { 95 | if (args.length === 0) return pure(f()); 96 | const [argHead, ...argsTail] = args; 97 | return argHead.bind((value) => 98 | apply((...argsN: any[]) => f(value, ...argsN), ...argsTail), 99 | ); 100 | } 101 | 102 | export function compareAndUpdate( 103 | value: T, 104 | equal: (valueA: T, valueB: T) => boolean, 105 | ) { 106 | return (curValue: T) => (equal(curValue, value) ? curValue : value); 107 | } 108 | -------------------------------------------------------------------------------- /src/biz/head/users.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@root/base/styled"; 2 | import { useState } from "react"; 3 | 4 | const SBox = css` 5 | display: flex; 6 | `; 7 | 8 | const SAvatar = css` 9 | position: relative; 10 | width: 28px; 11 | height: 28px; 12 | border-radius: 28px; 13 | outline: 2px solid white; 14 | cursor: pointer; 15 | border: 1px solid transparent; 16 | 17 | &:hover { 18 | border-color: #2370ff; 19 | } 20 | &>img { 21 | display: block; 22 | width: 100%; 23 | border-radius: 28px; 24 | } 25 | &+& { 26 | margin-left: -4px; 27 | } 28 | `; 29 | 30 | const SUserDetail = css` 31 | position: absolute; 32 | z-index: 100; 33 | top: calc(100% + 6px); 34 | left: 0; 35 | padding: 4px; 36 | border-radius: 4px; 37 | background-color: white; 38 | box-shadow: 0 0 4px rgba(0, 0, 0, 0.1); 39 | border: 1px solid #e3e3e3; 40 | 41 | display: flex; 42 | align-items: center; 43 | gap: 50px; 44 | 45 | .left { 46 | display: flex; 47 | align-items: center; 48 | gap: 4px; 49 | white-space: nowrap; 50 | font-size: 12px; 51 | .avatar { 52 | height: 28px; 53 | width: 28px; 54 | img { 55 | display: block; 56 | width: 100%; 57 | border-radius: 4px; 58 | } 59 | } 60 | } 61 | .right { 62 | height: 8px; 63 | width: 8px; 64 | border-radius: 4px; 65 | background-color: #1fec7f; 66 | margin-right: 10px; 67 | } 68 | `; 69 | 70 | 71 | 72 | interface UserInfo { 73 | id: string; 74 | name: string; 75 | avatar: string; 76 | } 77 | 78 | const MockUsers: UserInfo[] = [ 79 | { 80 | id: 's1', 81 | name: 'Jiashuang Shang', 82 | avatar: 'https://img1.baidu.com/it/u=1353340546,4028340368&fm=253&fmt=auto&app=138&f=JPEG?w=200&h=200', 83 | }, 84 | { 85 | id: 's2', 86 | name: 'Scott Wei', 87 | avatar: 'https://img2.baidu.com/it/u=2726011848,1335299621&fm=253&fmt=auto&app=138&f=JPEG?w=200&h=200', 88 | }, 89 | { 90 | id: 's3', 91 | name: 'Jianli Wei', 92 | avatar: 'https://img2.baidu.com/it/u=2068851657,392186408&fm=253&fmt=auto&app=138&f=JPEG?w=200&h=200', 93 | }, 94 | { 95 | id: 's4', 96 | name: 'Qiang Wu', 97 | avatar: 'https://img0.baidu.com/it/u=1243743546,257391698&fm=253&fmt=auto&app=138&f=JPEG?w=200&h=200', 98 | }, 99 | ]; 100 | 101 | function User(props: { 102 | user: UserInfo; 103 | }) { 104 | const { user: { id, name, avatar } } = props; 105 | const [visible, setVisible] = useState(false); 106 | 107 | return ( 108 |
setVisible(true)} 111 | onMouseLeave={() => setVisible(false)} 112 | > 113 | 114 | {visible && ( 115 |
116 |
117 |
118 | 119 |
120 |
{name}
121 |
122 |
123 |
124 | )} 125 |
126 | ); 127 | } 128 | 129 | 130 | export function Users() { 131 | const users = useState(MockUsers)[0]; 132 | 133 | return ( 134 |
135 | {users.slice(0, 4).map((u) => ( 136 | 137 | ))} 138 |
139 | ); 140 | } 141 | 142 | -------------------------------------------------------------------------------- /src/components/mind-map/Controllers/ScaleControl.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@base/styled'; 2 | import React, { FC } from 'react'; 3 | import { Add, Origin, Sub } from '../../icons/icons'; 4 | import { resetDrawingTransformEventName } from '../render/hooks/useRenderWithD3'; 5 | 6 | const SScaleControl = css` 7 | display: flex; 8 | align-items: center; 9 | gap: 10px; 10 | & > .label { 11 | width: 80px; 12 | text-align: right; 13 | } 14 | `; 15 | const sBtn = css` 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | width: 20px; 20 | height: 20px; 21 | font-size: 20px; 22 | padding: 0; 23 | border: 0; 24 | border-radius: 10px; 25 | color: #333; 26 | cursor: pointer; 27 | background-color: white; 28 | &:hover { 29 | background-color: #ddd; 30 | } 31 | `; 32 | const hoverShadow = css` 33 | &:hover { 34 | box-shadow: 0 0 10px 0px rgba(0, 0, 0, 0.5); 35 | } 36 | `; 37 | 38 | function scaler(val: number) { 39 | let re; 40 | if (val < 500) { 41 | re = val / 500; 42 | } else { 43 | re = 1 + (val - 500) / 100; 44 | } 45 | 46 | return Math.floor(re * 10 + 0.5) / 10; 47 | } 48 | 49 | function scalerIn(val: number) { 50 | let re; 51 | if (val < 1) { 52 | re = val * 500; 53 | } else { 54 | re = 500 + (val - 1) * 100; 55 | } 56 | 57 | return Math.round(re); 58 | } 59 | 60 | export const ScaleControl: FC<{ 61 | scale: number; 62 | setScale: (val: number) => void; 63 | min: number; 64 | max: number; 65 | style?: React.CSSProperties; 66 | }> = (props) => { 67 | const { scale, setScale, style } = props; 68 | return ( 69 |
70 | 80 | 89 | setScale(scaler(+e.target.value))} 96 | >{' '} 97 | 106 | { 117 | const val = +e.target.value; 118 | if (val >= props.min && val <= props.max) { 119 | setScale(val); 120 | } 121 | }} 122 | /> 123 |
124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /src/components/state/converter.ts: -------------------------------------------------------------------------------- 1 | import { MindMapCp, MindMapNodeProps } from '@root/model/mind-map-model'; 2 | import { Rec, mapRec } from '@root/model/ot-doc/record'; 3 | import { Timestamped } from '@root/model/ot-doc/timestamped'; 4 | import { Payload, RawNode } from '../mind-map/render'; 5 | 6 | const ROOT_ID = '00000000'; 7 | 8 | const node = ( 9 | id: string, 10 | payload: Payload = { content: '', collapsed: false }, 11 | ): RawNode => ({ id, payload }); 12 | 13 | export function cpToTree(cp: MindMapCp): RawNode { 14 | const nodes: Rec> = {}; 15 | const parents: Rec> = { 16 | [ROOT_ID]: { t: Number.POSITIVE_INFINITY, v: ROOT_ID }, 17 | }; 18 | const childrenIds: Rec = {}; 19 | const dfs = (id: string) => { 20 | const { children = [], stringProps, booleanProps } = cp[id] ?? {}; 21 | nodes[id] ??= node( 22 | id, 23 | propsToPayload({ 24 | stringProps: mapRec(stringProps ?? {}, ({ v }) => v), 25 | booleanProps: mapRec(booleanProps ?? {}, ({ v }) => v), 26 | }), 27 | ); 28 | childrenIds[id] = []; 29 | for (const { t, v: idC } of children) { 30 | if (parents[idC]) { 31 | if (parents[idC].t < t) { 32 | parents[idC] = { t, v: id }; 33 | } 34 | } else { 35 | parents[idC] = { t, v: id }; 36 | childrenIds[id].push(idC); 37 | dfs(idC); 38 | } 39 | } 40 | }; 41 | dfs(ROOT_ID); 42 | 43 | for (const id in nodes) { 44 | const node = nodes[id]; 45 | node.children = childrenIds[id] 46 | .filter((idC) => parents[idC]?.v === id) 47 | .map((idC) => nodes[idC]); 48 | } 49 | 50 | return nodes[ROOT_ID] ?? node(ROOT_ID); 51 | } 52 | 53 | export function treeToCp(tree: RawNode): MindMapCp { 54 | const cp: MindMapCp = {}; 55 | const t = Date.now(); 56 | const visited = new Set(); 57 | const dfs = (node: RawNode) => { 58 | const id = node === tree ? ROOT_ID : node.id; 59 | visited.add(id); 60 | const { stringProps = {}, booleanProps = {} } = payloadToProps( 61 | node.payload, 62 | ); 63 | cp[id] = { 64 | children: [], 65 | stringProps: mapRec(stringProps, (v) => ({ t, v })), 66 | booleanProps: mapRec(booleanProps, (v) => ({ t, v })), 67 | }; 68 | // biome-ignore lint/complexity/noForEach: 69 | node.children?.forEach((nodeC) => { 70 | if (!visited.has(nodeC.id)) { 71 | cp[id].children?.push({ t, v: nodeC.id }); 72 | dfs(nodeC); 73 | } 74 | }); 75 | }; 76 | 77 | dfs(tree); 78 | return cp; 79 | } 80 | 81 | export const payloadToProps = (payload: Payload): MindMapNodeProps => ({ 82 | stringProps: { 83 | content: payload.content, 84 | link: payload.link ?? '', 85 | hilight: payload.hilight ?? '', 86 | }, 87 | booleanProps: { 88 | collapsed: payload.collapsed ?? false, 89 | bold: payload.bold ?? false, 90 | italic: payload.italic ?? false, 91 | underline: payload.underline ?? false, 92 | }, 93 | }); 94 | 95 | export const propsToPayload = (props: MindMapNodeProps): Payload => ({ 96 | content: props.stringProps?.content ?? '', 97 | link: props.stringProps?.link, 98 | hilight: props.stringProps?.hilight, 99 | collapsed: props.booleanProps?.collapsed, 100 | bold: props.booleanProps?.bold, 101 | italic: props.booleanProps?.italic, 102 | underline: props.booleanProps?.underline, 103 | }); 104 | -------------------------------------------------------------------------------- /src/model/api.ts: -------------------------------------------------------------------------------- 1 | import { MindMapCp } from './mind-map-model'; 2 | import { Timestamped } from './ot-doc/timestamped'; 3 | import { 4 | Read, 5 | readArray, 6 | readBoolean, 7 | readNumber, 8 | readPartial, 9 | readRecord, 10 | readString, 11 | readStruct, 12 | } from './read'; 13 | 14 | import './data/behaviors/test'; 15 | 16 | const API_RESPONSE_TYPE_ERROR = 'API response type error'; 17 | 18 | const log = (value: T) => { 19 | console.log(value); 20 | return value; 21 | }; 22 | 23 | export interface FileInfo { 24 | created: string; 25 | updated: string; 26 | id: string; 27 | title: string; 28 | } 29 | 30 | export const listDocuments = () => 31 | fetch('/api/list') 32 | .then((res) => res.json()) 33 | .then( 34 | readPartial({ 35 | list: readArray( 36 | readStruct({ 37 | title: readString, 38 | id: readString, 39 | }), 40 | ), 41 | }), 42 | ) 43 | .then(({ list }) => list ?? []); 44 | 45 | export const createDocument = () => 46 | fetch('/api/new', { method: 'POST' }) 47 | .then((res) => res.json()) 48 | .then(readStruct({ id: readString })) 49 | .then((data) => { 50 | if (!data?.id) throw new Error(API_RESPONSE_TYPE_ERROR); 51 | return data.id; 52 | }); 53 | 54 | export const readTimestamped = ( 55 | read: Read, 56 | ): Read | undefined> => 57 | readStruct({ 58 | t: readNumber, 59 | v: read, 60 | }); 61 | 62 | export const getDocument = (id: string) => 63 | fetch(`/api/get/${id}`) 64 | .then((res) => res.json()) 65 | .then( 66 | readStruct({ 67 | id: readString, 68 | content: readString, 69 | }), 70 | ) 71 | .then((data) => { 72 | if (!data?.id) throw new Error(API_RESPONSE_TYPE_ERROR); 73 | if (!data?.content) return {}; 74 | return readRecord( 75 | readStruct({ 76 | stringProps: readRecord(readTimestamped(readString)), 77 | numberProps: readRecord(readTimestamped(readNumber)), 78 | booleanProps: readRecord(readTimestamped(readBoolean)), 79 | children: readArray(readTimestamped(readString)), 80 | }), 81 | )(JSON.parse(data.content)); 82 | }); 83 | 84 | export const updateDocument = (id: string, doc: MindMapCp) => 85 | fetch(`/api/update/${id}`, { 86 | method: 'PATCH', 87 | headers: { 'Content-Type': 'application/json' }, 88 | body: JSON.stringify(doc), 89 | }) 90 | .then((res) => res.json()) 91 | .then((data) => { 92 | console.log(data); 93 | if (!data?.id) throw new Error(API_RESPONSE_TYPE_ERROR); 94 | return data.id; 95 | }); 96 | 97 | export const updateDocumentName = (id: string, docName: string) => 98 | fetch(`/api/update/${id}/docName`, { 99 | method: 'PATCH', 100 | headers: { 'Content-Type': 'application/json' }, 101 | body: JSON.stringify({ docName }), 102 | }) 103 | .then((res) => res.json()) 104 | .then((data) => { 105 | console.log(data); 106 | if (!data?.id) throw new Error(API_RESPONSE_TYPE_ERROR); 107 | return data.id; 108 | }); 109 | 110 | export const deleteDocument = (id: string) => 111 | fetch(`/api/delete/${id}`, { method: 'DELETE' }) 112 | .then((res) => res.json()) 113 | .then((data) => { 114 | if (!data?.id) throw new Error(API_RESPONSE_TYPE_ERROR); 115 | return data.id; 116 | }); 117 | 118 | (window as any).api = { 119 | listDocuments, 120 | createDocument, 121 | updateDocument, 122 | deleteDocument, 123 | getDocument, 124 | }; 125 | -------------------------------------------------------------------------------- /src/biz/head/file-meta.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "@root/base/atom"; 2 | import { css } from "@root/base/styled"; 3 | import { useCallback, useEffect, useRef, useState } from "react"; 4 | import { useParams } from "react-router-dom"; 5 | import Popup from "../components/popup"; 6 | import SkeletonBlock from "../components/skeleton-block"; 7 | import { filesAtom } from "../store"; 8 | 9 | const SBox = css` 10 | display: flex; 11 | flex-direction: column; 12 | `; 13 | const STop = css` 14 | display: flex; 15 | align-items: center; 16 | `; 17 | const STitle = css` 18 | padding: 2px; 19 | border-radius: 2px; 20 | cursor: pointer; 21 | font-size: 13px; 22 | &:hover { 23 | background-color: #ccc; 24 | } 25 | `; 26 | 27 | const SLastEdit = css` 28 | font-size: 11px; 29 | color: #999999; 30 | `; 31 | 32 | const SRename = css` 33 | padding: 12px; 34 | &>input { 35 | border: 1px solid #ccc; 36 | border-radius: 2px; 37 | padding: 4px 6px; 38 | min-width: 200px; 39 | &:focus { 40 | outline: none; 41 | box-shadow: rgba(0, 106, 254, 0.12) 0px 0px 0px 2px; 42 | border-color: rgba(63,133,255,1); 43 | } 44 | } 45 | `; 46 | 47 | function Rename(props: { 48 | position: [x: number, y: number]; 49 | title: string; 50 | commit: (title: string) => void; 51 | hide: () => void; 52 | }) { 53 | const { position: [left, top], title, commit, hide } = props; 54 | const [text, edit] = useState(title); 55 | const ref = useRef(null); 56 | 57 | useEffect(() => { 58 | ref.current?.focus(); 59 | }, []); 60 | 61 | return ( 62 | 63 |
64 | edit(e.target.value)} 68 | onBlur={() => { 69 | if (text === title) return; 70 | commit(text); 71 | }} 72 | onKeyDown={(e) => { 73 | if (e.key !== 'Enter') return; 74 | (e.target as HTMLInputElement).blur(); 75 | }} 76 | /> 77 |
78 |
79 | ); 80 | } 81 | 82 | export function FileMeta() { 83 | const [popup, setPopup] = useState<[number, number] | null>(null); 84 | const hide = useCallback(() => setPopup(null), []); 85 | 86 | const id = useParams().fileId || ''; 87 | const [{ files, loading }, , actions] = useAtom(filesAtom); 88 | const file = files.find(f => f.id === id); 89 | 90 | if (loading || !file) { 91 | return ( 92 |
93 | 94 | 95 |
96 | ); 97 | } 98 | 99 | const title = file.title || 'Untitled'; 100 | return ( 101 |
102 |
103 |
{ 106 | const rect = (ev.target as HTMLElement).getBoundingClientRect(); 107 | setPopup([rect.left, rect.bottom]); 108 | }} 109 | >{title}
110 |
111 | {popup && ( 112 | { 116 | // todo: update by server api 117 | actions.update(id, { title: result }); 118 | }} 119 | hide={hide} 120 | /> 121 | )} 122 |
123 | Last edit: {(new Date(file.updated)).toLocaleString()} 124 |
125 |
126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /office-addin/src/taskpane/helpers/SignInHelper.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. 3 | * See LICENSE in the project root for license information. 4 | */ 5 | 6 | interface UserProfileResponse { 7 | id: string; 8 | name: string; 9 | email: string; 10 | } 11 | 12 | let instance: SignInHelper | null = null; 13 | 14 | Office.initialize = async function () { 15 | try { 16 | const profile = await SignInHelper.getInstance().checkUserProfile(); 17 | if (!profile || !profile.id) { 18 | await SignInHelper.getInstance().showSignInDialog(); 19 | } 20 | } catch (e) { 21 | await SignInHelper.getInstance().showSignInDialog(); 22 | console.error(e); 23 | } 24 | }; 25 | 26 | export class SignInHelper { 27 | private static USER_PROFILE_API = new URL(`${location.origin}/users/profile`); 28 | private static SIGN_OUT_API = new URL(`${location.origin}/auth/signout`); 29 | 30 | private dialog: Office.Dialog; 31 | private observers = []; 32 | 33 | static initializeInstance(): void { 34 | instance = new SignInHelper(); 35 | } 36 | 37 | static getInstance(): SignInHelper { 38 | if (!instance) { 39 | throw new Error("SignInHelper not initialized"); 40 | } 41 | return instance; 42 | } 43 | 44 | private processDialogMesssage(data) { 45 | const messageFromDialog = JSON.parse(data.message); 46 | const success = messageFromDialog.success; 47 | if (success) { 48 | this.dialog.close(); 49 | } 50 | for (const observer of this.observers) { 51 | observer(success); 52 | } 53 | } 54 | 55 | async showSignInDialog() { 56 | Office.context.ui.displayDialogAsync( 57 | `${location.origin}/auth/signin?targetUrl=${encodeURIComponent('/addin/landing-page.html')}`, 58 | { height: 300, width: 300 }, 59 | (asyncResult) => { 60 | this.dialog = asyncResult.value; 61 | this.dialog.addEventHandler(Office.EventType.DialogMessageReceived, this.processDialogMesssage.bind(this)); 62 | } 63 | ); 64 | } 65 | 66 | async signOut() { 67 | const headers = new Headers(); 68 | headers.set("Content-Type", "application/json"); 69 | 70 | const response = await fetch(SignInHelper.SIGN_OUT_API, { 71 | headers, 72 | method: "GET", 73 | }); 74 | 75 | const responseBody = await response.json(); 76 | 77 | if (response.ok && responseBody.id) { 78 | return responseBody; 79 | } else { 80 | throw new Error( 81 | `API call failed. HTTP status: ${response.status}. Response from server: ${JSON.stringify(responseBody)}` 82 | ); 83 | } 84 | } 85 | 86 | async checkUserProfile(): Promise { 87 | const headers = new Headers(); 88 | headers.set("Content-Type", "application/json"); 89 | 90 | const response = await fetch(SignInHelper.USER_PROFILE_API, { 91 | headers, 92 | method: "GET", 93 | }); 94 | 95 | const responseBody = await response.json(); 96 | 97 | if (response.ok && responseBody.id) { 98 | return responseBody; 99 | } else { 100 | throw new Error( 101 | `API call failed. HTTP status: ${response.status}. Response from server: ${JSON.stringify(responseBody)}` 102 | ); 103 | } 104 | } 105 | 106 | observeSignIn(observer: (success: boolean) => void) { 107 | this.observers.push(observer); 108 | } 109 | 110 | unobserveSignIn(observer: (success: boolean) => void) { 111 | const index = this.observers.indexOf(observer); 112 | if (index > -1) { 113 | this.observers.splice(index, 1); 114 | } 115 | } 116 | } 117 | 118 | SignInHelper.initializeInstance(); 119 | -------------------------------------------------------------------------------- /mai-mind-map-se/server/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { join, sep } from 'path'; 3 | import axios from 'axios'; 4 | 5 | export type Config = { 6 | ENV: string, 7 | STORAGE_CONN_STRING: string, 8 | CLOUD_INSTANCE: string, 9 | TENANT_ID: string, 10 | CLIENT_ID: string, 11 | CLIENT_SECRET: string, 12 | REDIRECT_URI: string, 13 | POST_LOGOUT_REDIRECT_URI: string, 14 | GRAPH_API_ENDPOINT: string, 15 | EXPRESS_SESSION_SECRET: string, 16 | NONE_STREAMING_AI_GENERATION_ENDPOINT: string, 17 | NONE_STREAMING_AI_CREATION_ENDPOINT: string, 18 | SUB_NODES_AI_GENERATION_ENDPOINT: string, 19 | STREAMING_AI_CREATION_ENDPOINT: string, 20 | DB_CONN_LIMIT: number, 21 | DB_HOST: string, 22 | DB_PORT: number, 23 | DB_USER: string, 24 | DB_SECRET: string, 25 | DB_NAME: string, 26 | }; 27 | 28 | /** 29 | * Reads the configuration from a file and returns the first line. 30 | * 31 | * @returns {stConfigring} The first line of the configuration file, or an empty 32 | * string if the file is empty or cannot be read. 33 | */ 34 | export function readConfig(): Config { 35 | const contents = fs.readFileSync(join(__dirname, `..${sep}config.json`), 'utf-8'); 36 | var obj: Config = JSON.parse(contents); 37 | return obj; 38 | } 39 | 40 | /** 41 | * Attaches a given access token to a MS Graph API call 42 | * @param endpoint: REST API endpoint to call 43 | * @param accessToken: raw access token string 44 | */ 45 | export async function fetch(endpoint: string, accessToken: string) { 46 | const options = { 47 | headers: { 48 | Authorization: `Bearer ${accessToken}` 49 | } 50 | }; 51 | console.log(`request made to ${endpoint} at: ` + new Date().toString()); 52 | const response = await axios.get(endpoint, options); 53 | return await response.data; 54 | } 55 | 56 | /** 57 | * Handles an error and returns a string representation of it. 58 | * 59 | * @param error - The error to handle. It can be of any type. 60 | * @returns A string representation of the error. If the error is a string, it 61 | * returns the error itself. 62 | * If the error is an instance of `Error`, it returns the error message. 63 | * Otherwise, it returns 'Unknown error'. 64 | */ 65 | export function handleError(error: unknown): string { 66 | if (typeof error === 'string') { 67 | return error; 68 | } else if (error instanceof Error) { 69 | return error.message.toString(); 70 | } 71 | return 'Unknown error'; 72 | } 73 | 74 | /** 75 | * Generates a unique identifier string. 76 | * 77 | * The identifier is created by generating a random number, converting it to a 78 | * base-36 string, padding it with zeros to ensure a minimum length, and then 79 | * slicing it to get a fixed length. 80 | * 81 | * @returns {string} A unique identifier string of length 8. 82 | */ 83 | export const genId = () => Math.random().toString(36).padEnd(10, '0').slice(2, 10); 84 | 85 | /** 86 | * The identifier of the root node. 87 | */ 88 | export const ROOT_ID = '00000000'; 89 | 90 | /** 91 | * Constant representing a permission denied error message. 92 | * 93 | * @constant {string} 94 | */ 95 | export const PERMISSION_DENIED = 'Permission denied'; 96 | 97 | /** 98 | * Retrieves the title of a document from its structure. 99 | * 100 | * @param doc - The document object from which to extract the title. 101 | * @returns The title of the document if it exists, otherwise `undefined`. 102 | */ 103 | export function getDocTitle(doc: any): string | undefined { 104 | return doc && doc[ROOT_ID] && 105 | doc[ROOT_ID].stringProps && 106 | doc[ROOT_ID].stringProps.content && 107 | doc[ROOT_ID].stringProps.content.v 108 | ? doc[ROOT_ID].stringProps.content.v : undefined; 109 | } 110 | -------------------------------------------------------------------------------- /src/components/mind-map/render/layout/flex-tree/do-layout.ts: -------------------------------------------------------------------------------- 1 | import { Direction, HierarchyOptions, Node } from './hierarchy'; 2 | import separateTree from './separate-root'; 3 | 4 | const VALID_DIRECTIONS = ['LR', 'RL', 'TB', 'BT', 'H', 'V'] as const; 5 | const HORIZONTAL_DIRECTIONS = ['LR', 'RL', 'H'] as const; 6 | 7 | const isHorizontal = (d: Direction) => HORIZONTAL_DIRECTIONS.includes(d as any); 8 | const DEFAULT_DIRECTION = VALID_DIRECTIONS[0]; 9 | 10 | function reassignXYIfRadial(root: Node, options: HierarchyOptions) { 11 | if (options.radial) { 12 | const [rScale, radScale] = options.isHorizontal 13 | ? (['x', 'y'] as const) 14 | : (['y', 'x'] as const); 15 | 16 | const min = { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY }; 17 | const max = { x: Number.NEGATIVE_INFINITY, y: Number.NEGATIVE_INFINITY }; 18 | 19 | let count = 0; 20 | root.DFTraverse((node) => { 21 | count++; 22 | const { x, y } = node; 23 | min.x = Math.min(min.x, x); 24 | min.y = Math.min(min.y, y); 25 | max.x = Math.max(max.x, x); 26 | max.y = Math.max(max.y, y); 27 | }); 28 | 29 | const radDiff = max[radScale] - min[radScale]; 30 | if (radDiff === 0) return; 31 | 32 | const avgRad = (Math.PI * 2) / count; 33 | root.DFTraverse((node) => { 34 | const rad = 35 | ((node[radScale] - min[radScale]) / radDiff) * (Math.PI * 2 - avgRad) + 36 | avgRad; 37 | const r = node[rScale] - root[rScale]; 38 | node.x = Math.cos(rad) * r; 39 | node.y = Math.sin(rad) * r; 40 | }); 41 | } 42 | } 43 | 44 | export default function doLayout( 45 | root: Node, 46 | options: HierarchyOptions, 47 | layoutAlgrithm: (node: Node, options: HierarchyOptions) => void, 48 | ) { 49 | const direction = options.direction || DEFAULT_DIRECTION; 50 | options.isHorizontal = isHorizontal(direction); 51 | if (direction && VALID_DIRECTIONS.indexOf(direction) === -1) { 52 | throw new TypeError(`Invalid direction: ${direction}`); 53 | } 54 | 55 | if (direction === VALID_DIRECTIONS[0]) { 56 | // LR 57 | layoutAlgrithm(root, options); 58 | } else if (direction === VALID_DIRECTIONS[1]) { 59 | // RL 60 | layoutAlgrithm(root, options); 61 | root.right2left(); 62 | } else if (direction === VALID_DIRECTIONS[2]) { 63 | // TB 64 | layoutAlgrithm(root, options); 65 | } else if (direction === VALID_DIRECTIONS[3]) { 66 | // BT 67 | layoutAlgrithm(root, options); 68 | root.bottom2top(); 69 | } else if ( 70 | direction === VALID_DIRECTIONS[4] || 71 | direction === VALID_DIRECTIONS[5] 72 | ) { 73 | // H or V 74 | // separate into left and right trees 75 | const { left, right } = separateTree(root, options); 76 | // do layout for left and right trees 77 | layoutAlgrithm(left, options); 78 | layoutAlgrithm(right, options); 79 | options.isHorizontal ? left.right2left() : left.bottom2top(); 80 | // combine left and right trees 81 | right.translate(left.x - right.x, left.y - right.y); 82 | // translate root 83 | root.x = left.x; 84 | root.y = right.y; 85 | const bb = root.getBoundingBox(); 86 | if (options.isHorizontal) { 87 | if (bb.top < 0) { 88 | root.translate(0, -bb.top); 89 | } 90 | } else { 91 | if (bb.left < 0) { 92 | root.translate(-bb.left, 0); 93 | } 94 | } 95 | } 96 | 97 | const fixedRoot = options.fixedRoot; 98 | if (fixedRoot || fixedRoot === undefined) { 99 | root.translate( 100 | -(root.x + root.width / 2 + root.hgap), 101 | -(root.y + root.height / 2 + root.vgap), 102 | ); 103 | } 104 | 105 | reassignXYIfRadial(root, options); 106 | return root; 107 | } 108 | -------------------------------------------------------------------------------- /office-addin/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19985060-6552-4d48-bff2-afb1891d8b44 4 | 1.0.0.0 5 | Microsoft 6 | en-US 7 | 8 | 9 | 10 | 11 | https://mai-mind-map.azurewebsites.net 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ReadWriteDocument 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |