├── .babelrc ├── .gitignore ├── README.md ├── client └── src │ ├── app │ ├── index.tsx │ └── middlewares │ │ ├── undo.ts │ │ └── ws.ts │ ├── apptools │ ├── KeyBind.ts │ ├── addon.ts │ └── commonfunc.ts │ ├── calcpath │ ├── connectline.ts │ └── topicshape.ts │ ├── components │ ├── core │ │ ├── Map │ │ │ ├── InfoItem │ │ │ │ └── Label.tsx │ │ │ ├── Topic │ │ │ │ ├── ConnectLine.tsx │ │ │ │ ├── Fill.tsx │ │ │ │ ├── SelectBox.tsx │ │ │ │ ├── Shape.tsx │ │ │ │ ├── Title.tsx │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── Sheet.tsx │ │ ├── index.scss │ │ └── index.tsx │ ├── index.scss │ ├── index.tsx │ └── ui │ │ ├── Header │ │ ├── index.scss │ │ └── index.tsx │ │ ├── OperationPanels │ │ ├── SheetEditPanel.tsx │ │ ├── TopicEditPanel.tsx │ │ ├── edit-panel-style.scss │ │ └── index.tsx │ │ └── antd │ │ └── index.tsx │ ├── config.ts │ ├── constants │ ├── EventTags.ts │ ├── KeyCode.ts │ ├── common.ts │ ├── defaultstyle.ts │ └── distance.ts │ ├── css │ ├── components │ │ └── topic.css │ └── main.css │ ├── index.tsx │ ├── interface │ ├── definefiles │ │ ├── dva │ │ │ └── index.d.ts │ │ ├── index.d.ts │ │ └── react-draggable │ │ │ └── index.d.ts │ └── index.ts │ ├── layout │ ├── index.ts │ └── logic │ │ └── logictoright.ts │ ├── managers │ └── index.ts │ ├── models │ ├── app.ts │ ├── map.ts │ └── sheet.ts │ └── socketHandler │ └── index.ts ├── package.json ├── public └── index.html ├── server └── src │ ├── index.ts │ └── socketHandler │ └── index.ts ├── share └── eventtags.ts ├── storedata.json ├── tsconfig.json ├── tsconfig.server.json ├── webpack.config.js └── webpack.config.server.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-2"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | public/dist/ 4 | server/dist/ 5 | share/*.js 6 | npm-debug.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 脑图 / MindMap 2 | 3 | 一个基于react与redux的简单web脑图应用。 4 | 5 | A simple mindmap web application based on react and redux. 6 | 7 | 在线demo地址 / Online demo:[mindmap](http://www.morsecoding.win:3000/) 8 | 9 | 运行方式 / the way to start it: 10 | 11 | ``` 12 | npm i 13 | npm run build 14 | npm start 15 | ``` 16 | 然后在任意一款现代浏览器上打开[localhost:3000](http://localhost:3000/) 17 | 18 | then open [localhost:3000](http://localhost:3000/) in your favorite modern browser. 19 | 20 | ## 技术栈 / Tech Stacks 21 | 22 | * [TypeScript](https://github.com/Microsoft/TypeScript) 23 | * [React](https://github.com/facebook/react) 24 | * [dva.js](https://github.com/dvajs/dva) 25 | 26 | ## 已实现功能列表 / Available Function List 27 | 28 | ### Topic结构编辑 / Edit Topic Structure 29 | * 添加子Topic / Add Child Topic 30 | * 同列向前插入Topic / Insert Topic Before 31 | * 同列向后插入Topic / Insert Topic After 32 | * 插入父Topic / Insert Parent Topic 33 | * 删除Topic / Delete Topic 34 | 35 | ### Topic内容与内容样式编辑 / Edit Topic Content 36 | * 编辑Topic文本 / Edit Topic Content Text 37 | * 改变文本尺寸 / Change Text Font Size 38 | * 改变文本颜色 / Change Text Color 39 | * 文本加粗 / Set Bold 40 | * 文本斜体 / Set Italic 41 | * 文本删除线 / Set Line Through 42 | 43 | 44 | ### Topic样式编辑 / Edit Topic Style 45 | * 改变Topic形状 / Edit Topic Shape 46 | * 改变填充颜色 / Edit Fill Color 47 | * 改变边框宽度 / Edit Border Width 48 | * 改变边框颜色 / Edit Border Color 49 | 50 | ### 连接线样式编辑 / Edit Connection Line Style 51 | * 改变连接线类型 / Edit Line Class 52 | * 改变连接线宽度 / Edit Line Width 53 | * 改变连接线颜色 / Edit Line Color 54 | 55 | ### 多人协同编辑 / Collaborative Editing 56 | * 同步操作 / Sync Operation 57 | 58 | ### 其它 / Others 59 | * Undo and Redo 60 | * SVG 缩放 / Scaling SVG Map 61 | 62 | ## 即将实现的功能 / Future Funtion List 63 | ### 用户名系统 / User Name System 64 | * 默认随机用户名 / Random username as default 65 | * 支持修改用户名 / Support edit username 66 | 67 | ### 多人协同编辑 / Collaborative Editing 68 | * 同步操作锁 / Operation lock 69 | 70 | ## 协议 / License 71 | MIT 72 | 73 | -------------------------------------------------------------------------------- /client/src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { events } from 'src/managers' 3 | import { UNDO_OR_REDO_TRIGGERED, PUSH_UNDO_STACK } from 'src/constants/EventTags' 4 | import dva from 'dva' 5 | import { Router, Route } from 'dva/router'; 6 | import mapModel from 'src/models/map' 7 | import sheetModel from 'src/models/sheet' 8 | import appModel from 'src/models/app' 9 | import AppComponent from 'src/components' 10 | import { undoMiddleware, undoGlobalReducer, hooks } from './middlewares/undo' 11 | import { createActionSocketMiddleware, createSyncStoreSocketMiddleware } from './middlewares/ws' 12 | 13 | class AppStarter { 14 | 15 | /** 16 | * @description dva's export object 17 | * */ 18 | private app: any; 19 | 20 | constructor() { 21 | this.initUndoMiddlewareHooks(); 22 | } 23 | 24 | private initUndoMiddlewareHooks() { 25 | hooks.onUndoOrRedoTrigger = () => events.emit(UNDO_OR_REDO_TRIGGERED); 26 | hooks.onPushUndo = () => events.emit(PUSH_UNDO_STACK); 27 | } 28 | 29 | /** 30 | * @description init dva with initialState 31 | * */ 32 | public start({initialState, wrapperElem, wsInstance}) { 33 | this.app = dva({ 34 | initialState, 35 | globalReducer: undoGlobalReducer 36 | }); 37 | 38 | this.setAllModels(); 39 | this.setRouter(); 40 | this.setMiddleware(wsInstance); 41 | 42 | this.app.start(wrapperElem); 43 | } 44 | 45 | /** 46 | * @description a proxy for store.dispatch 47 | * */ 48 | public dispatch(...args) { 49 | return this.app._store.dispatch(...args); 50 | } 51 | 52 | public getState() { 53 | return this.app._store.getState(); 54 | } 55 | 56 | private setAllModels() { 57 | this.app.model(appModel); 58 | this.app.model(mapModel); 59 | this.app.model(sheetModel); 60 | } 61 | 62 | private setMiddleware(wsInstance) { 63 | // todo 64 | this.app.use({ 65 | onAction: [undoMiddleware, createActionSocketMiddleware(wsInstance), createSyncStoreSocketMiddleware(wsInstance)] 66 | }); 67 | } 68 | 69 | private setRouter() { 70 | this.app.router(({ history }) => { 71 | return ( 72 | 73 | 74 | 75 | ); 76 | }); 77 | } 78 | } 79 | 80 | export default new AppStarter() -------------------------------------------------------------------------------- /client/src/app/middlewares/undo.ts: -------------------------------------------------------------------------------- 1 | import { deepClone } from 'src/apptools/commonfunc' 2 | import { sheetState, appState, mapState } from 'src/interface' 3 | 4 | const delayInvoking = (() => { 5 | 6 | let firstInvoke; 7 | 8 | return (invokeToDelay) => { 9 | firstInvoke = firstInvoke || invokeToDelay; 10 | 11 | setTimeout(() => { 12 | if (firstInvoke) { 13 | firstInvoke(); 14 | firstInvoke = null; 15 | } 16 | }, 0); 17 | } 18 | })(); 19 | 20 | interface globalState { 21 | sheet: sheetState 22 | app: appState 23 | map: mapState 24 | } 25 | 26 | const pastStateStack: Array = []; 27 | 28 | const futureStateStack: Array = []; 29 | 30 | export const ACTION_UNDO = 'ACTION_UNDO'; 31 | 32 | export const ACTION_REDO = 'ACTION_REDO'; 33 | 34 | export const undoMiddleware = (({ getState }) => dispatch => (action) => { 35 | 36 | const { type } = action; 37 | 38 | // if the action is router reducer / undo or redo reducer / need to ignore, just continue 39 | if (/^@@router/.test(type) || type === ACTION_UNDO || type === ACTION_REDO || action.ignoreUndo) { 40 | return dispatch(action) 41 | } 42 | 43 | // save pastState info 44 | const pastState = getState(); 45 | 46 | delayInvoking(() => { 47 | pastStateStack.push(pastState); 48 | futureStateStack.splice(0); 49 | 50 | hooks.onPushUndo && hooks.onPushUndo(); 51 | }); 52 | 53 | return dispatch(action); 54 | }); 55 | 56 | /** 57 | * @description global reducer for dispatch undo or redo action 58 | * */ 59 | export const undoGlobalReducer = { 60 | [ACTION_UNDO]: (state: globalState): globalState => { 61 | 62 | const pastState = pastStateStack.pop(); 63 | if (!pastState) return state; 64 | 65 | futureStateStack.push(state); 66 | 67 | hooks.onUndoOrRedoTrigger && hooks.onUndoOrRedoTrigger(); 68 | 69 | // selectionList remain the same 70 | pastState.map.selectionList = deepClone(state.map.selectionList); 71 | 72 | return pastState; 73 | }, 74 | 75 | [ACTION_REDO]: (state: globalState): globalState => { 76 | const futureState = futureStateStack.pop(); 77 | if (!futureState) return state; 78 | 79 | pastStateStack.push(state); 80 | 81 | hooks.onUndoOrRedoTrigger && hooks.onUndoOrRedoTrigger(); 82 | 83 | futureState.map.selectionList = deepClone(state.map.selectionList); 84 | 85 | return futureState; 86 | } 87 | }; 88 | 89 | export const hooks = { 90 | onPushUndo: null, 91 | onUndoOrRedoTrigger: null, 92 | hasUndo: () => !!pastStateStack.length, 93 | hasRedo: () => !!futureStateStack.length 94 | }; -------------------------------------------------------------------------------- /client/src/app/middlewares/ws.ts: -------------------------------------------------------------------------------- 1 | import { ClientEventTags } from 'root/share/eventtags' 2 | const broadcastTriggerFilter = '_isBroadcast'; 3 | 4 | function webSocketMiddlewareGenerator(msgType: string, getData: (getState, dispatch, action) => String) { 5 | return (ws) => ({getState, dispatch}) => next => action => { 6 | next(action); 7 | 8 | const { type } = action; 9 | if (/^@@router/.test(type) || action[broadcastTriggerFilter]) return; 10 | 11 | const sendMsg = { 12 | type: msgType, 13 | data: getData(getState, dispatch, action) 14 | }; 15 | 16 | ws.send(JSON.stringify(sendMsg)); 17 | } 18 | } 19 | 20 | export const createActionSocketMiddleware = webSocketMiddlewareGenerator(ClientEventTags.SYNC_ACTION, (getState, dispatch, action) => { 21 | return JSON.stringify(action); 22 | }); 23 | 24 | export const createSyncStoreSocketMiddleware = webSocketMiddlewareGenerator(ClientEventTags.SYNC_STORE, (getState) => { 25 | return JSON.stringify(getState()); 26 | }); 27 | -------------------------------------------------------------------------------- /client/src/apptools/KeyBind.ts: -------------------------------------------------------------------------------- 1 | import * as KeyCode from '../constants/KeyCode' 2 | import app from 'src/app' 3 | import { ACTION_UNDO, ACTION_REDO } from 'src/app/middlewares/undo' 4 | 5 | const elementsIdToStopPropagation = ['onUpdateLabel']; 6 | 7 | const operatorMap = { 8 | [KeyCode.Z_KEY](e) { 9 | if (e.metaKey || e.ctrlKey) { 10 | const dispatchType = e.shiftKey ? ACTION_REDO : ACTION_UNDO; 11 | app.dispatch({ type: dispatchType }); 12 | } 13 | }, 14 | 15 | [KeyCode.TAB_KEY](e) { 16 | e.preventDefault(); 17 | app.dispatch({ type: 'map/addChildTopic' }); 18 | }, 19 | 20 | [KeyCode.ENTER_KEY](e) { 21 | e.preventDefault(); 22 | app.dispatch({ type: 'map/addTopicAfter' }); 23 | }, 24 | 25 | [KeyCode.DELETE_KEY](e) { 26 | e.preventDefault(); 27 | app.dispatch({ type: 'map/removeTopic' }); 28 | } 29 | }; 30 | 31 | document.querySelector('body').addEventListener('keydown', function (e: any) { 32 | if ((elementsIdToStopPropagation).includes(e.target.id)) return true; 33 | 34 | const keyCode = e.keyCode; 35 | keyCode in operatorMap && operatorMap[keyCode](e); 36 | }); -------------------------------------------------------------------------------- /client/src/apptools/addon.ts: -------------------------------------------------------------------------------- 1 | import { TopicEditorStyle } from '../constants/defaultstyle'; 2 | import { TOPIC_SELECTED } from '../constants/EventTags'; 3 | 4 | import { events, selectionsManager, componentMapManager } from '../managers'; 5 | import { limitInvokeRepeat } from './commonfunc'; 6 | 7 | export const editReceiver = (() => { 8 | const keyMap = { 9 | 13 : 'Enter', 10 | 8 : 'Delete', 11 | 46 : 'Delete', 12 | 90 : 'Z', 13 | 38 : "Direct", 14 | 40 : "Direct", 15 | 37 : "Direct", 16 | 39 : "Direct", 17 | 27 : 'ESC' 18 | }; 19 | 20 | const input = document.createElement('input'); 21 | 22 | input.id = 'editReceiver'; 23 | 24 | const {minWidth, minHeight} = TopicEditorStyle; 25 | 26 | input.style.minWidth = minWidth + 'px'; 27 | input.style.minHeight = minHeight + 'px'; 28 | 29 | document.querySelector('#app-tools-container').appendChild(input); 30 | 31 | let currentComponent; 32 | 33 | // lifeCircle method 34 | const setShowStyle = () => { 35 | let { top, left, width, height } = currentComponent.getTitleClientRect(); 36 | 37 | const style = input.style; 38 | 39 | // fix left and top 40 | if (width < minWidth) { 41 | 42 | left -= (minWidth - width) / 2; 43 | if (width === 0) { 44 | top -= minHeight / 2; 45 | } 46 | width = minWidth; 47 | } 48 | 49 | style.width = width + 'px'; 50 | style.left = left + 'px'; 51 | style.top = top + 'px'; 52 | style.height = height + 'px'; 53 | 54 | style.zIndex = '1'; 55 | }; 56 | 57 | const setHideStyle = () => { 58 | input.style.zIndex = '-1'; 59 | 60 | input.value = ''; 61 | }; 62 | 63 | const updateText = () => { 64 | currentComponent.onUpdateTitle(input.value); 65 | }; 66 | 67 | const onBlur = () => { 68 | 69 | if (!isVisible()) { 70 | return false; 71 | } 72 | 73 | 74 | updateText(); 75 | setHideStyle(); 76 | }; 77 | 78 | const isVisible = () => { 79 | return Number(input.style.zIndex) > 0; 80 | }; 81 | 82 | const keyDownMap = { 83 | 'Enter' : function(e){ 84 | if (isVisible()) { 85 | e.stopPropagation(); 86 | updateText(); 87 | setHideStyle(); 88 | } 89 | }, 90 | 'ESC' : function(){ 91 | isVisible() && setHideStyle(); 92 | }, 93 | 'Delete' : function(e){ 94 | isVisible() && e.stopPropagation(); 95 | }, 96 | 'Z' : function (e) { 97 | if(e.metaKey){ 98 | isVisible() ? e.stopPropagation() : e.preventDefault(); 99 | } 100 | }, 101 | 'Direct' : function (e) { 102 | isVisible() && e.stopPropagation(); 103 | } 104 | }; 105 | 106 | 107 | // add Event 108 | input.addEventListener('keydown', e => { 109 | const which = e.which; 110 | which in keyMap && keyDownMap[keyMap[which]].call(this, e); 111 | }); 112 | 113 | input.addEventListener('blur', () => { 114 | onBlur(); 115 | }); 116 | 117 | input.addEventListener('focus', () => { 118 | 119 | }); 120 | 121 | input.addEventListener('input', () => { 122 | !isVisible() && setShowStyle(); 123 | }); 124 | 125 | input.addEventListener('copy', () => { 126 | if(!isVisible()) { 127 | currentComponent.copyTopicInfo(); 128 | } 129 | }); 130 | 131 | input.addEventListener('cut', () => { 132 | if(!isVisible()) { 133 | currentComponent.cutTopicInfo(); 134 | } 135 | }); 136 | 137 | input.addEventListener('paste', (e) => { 138 | if (!isVisible()) { 139 | e.preventDefault(); 140 | currentComponent.pasteTopicInfo(); 141 | } 142 | }); 143 | 144 | return { 145 | prepare (targetComponent) { 146 | 147 | currentComponent = targetComponent; 148 | 149 | input.focus(); 150 | }, 151 | 152 | show (targetComponent) { 153 | 154 | currentComponent = targetComponent; 155 | 156 | setShowStyle(); 157 | 158 | input.value = currentComponent.getTitle(); 159 | 160 | input.focus(); 161 | input.select(); 162 | }, 163 | 164 | finish () { 165 | 166 | } 167 | 168 | } 169 | })(); 170 | 171 | // todo 172 | export const dragSelectReceiver = (() => { 173 | const dragSelectBox = document.createElement('div'); 174 | dragSelectBox.id = 'dragSelectBox'; 175 | 176 | const dragSelectCover = document.createElement('div'); 177 | dragSelectCover.id = 'dragSelectCover'; 178 | 179 | dragSelectCover.appendChild(dragSelectBox); 180 | 181 | document.querySelector('#app-tools-container').appendChild(dragSelectCover); 182 | 183 | const body = document.querySelector('body'); 184 | 185 | let startPoint: [number, number]; 186 | 187 | let endPoint: [number, number]; 188 | 189 | interface selectBoxStyle { 190 | left: string 191 | top: string 192 | width: string 193 | height: string 194 | } 195 | 196 | function getSelectBoxStyle(startPoint: [number, number], endPoint: [number, number]): selectBoxStyle { 197 | const left = Math.min(startPoint[0], endPoint[0]) + 'px'; 198 | const top = Math.min(startPoint[1], endPoint[1]) + 'px'; 199 | const width = Math.abs(startPoint[0] - endPoint[0]) + 'px'; 200 | const height = Math.abs(startPoint[1] - endPoint[1]) + 'px'; 201 | 202 | return {left, top, width, height}; 203 | } 204 | 205 | interface rectBox { 206 | left: number 207 | top: number 208 | width: number 209 | height: number 210 | } 211 | 212 | function isBoxIntersects(boxA: rectBox, boxB: rectBox): boolean { 213 | return boxA.left <= boxB.left + boxB.width && 214 | boxA.left + boxA.width >= boxB.left && 215 | boxA.top <= boxB.top + boxB.height && 216 | boxA.top + boxA.height >= boxB.top; 217 | } 218 | 219 | 220 | // todo need to consider performance 221 | function dragMoving(e) { 222 | endPoint = [e.pageX, e.pageY]; 223 | 224 | const selectBoxStyle = getSelectBoxStyle(startPoint, endPoint); 225 | 226 | (Object).assign(dragSelectBox.style, selectBoxStyle); 227 | dragSelectCover.style.display = 'block'; 228 | 229 | const selectBoxNumberData = { 230 | left: parseInt(selectBoxStyle.left), 231 | top: parseInt(selectBoxStyle.top), 232 | width: parseInt(selectBoxStyle.width), 233 | height: parseInt(selectBoxStyle.height) 234 | }; 235 | 236 | const componentMap = componentMapManager.getMap(); 237 | 238 | Object.keys(componentMap).forEach((id) => { 239 | const component = componentMap[id]; 240 | const componentBoxRect = component.getGroupBoxRect(); 241 | 242 | if (isBoxIntersects(selectBoxNumberData, componentBoxRect)) { 243 | selectionsManager.addSelection(component); 244 | if (!component.state.selected) { 245 | component.setState({selected: true}); 246 | } 247 | } else { 248 | selectionsManager.removeSelection(component); 249 | if (component.state.selected) { 250 | component.setState({selected: false}); 251 | } 252 | } 253 | }); 254 | } 255 | 256 | // if browser is chrome, do not set limit because some bug existed here 257 | const userAgent = navigator.userAgent.toLowerCase(); 258 | const isChrome = /chrome/.test(userAgent) && !/edge/.test(userAgent); 259 | const limitedDragMoving = isChrome ? dragMoving : limitInvokeRepeat(dragMoving, 20); 260 | 261 | function dragEnd() { 262 | body.removeEventListener('mousemove', limitedDragMoving); 263 | body.removeEventListener('mouseup', dragEnd); 264 | 265 | dragSelectCover.style.display = 'none'; 266 | 267 | if (selectionsManager.getSelectionsArray().length) { 268 | events.emit(TOPIC_SELECTED); 269 | } 270 | } 271 | 272 | return { 273 | dragStart(e) { 274 | startPoint = [e.pageX, e.pageY]; 275 | 276 | body.addEventListener('mousemove', limitedDragMoving); 277 | body.addEventListener('mouseup', dragEnd); 278 | } 279 | } 280 | })(); -------------------------------------------------------------------------------- /client/src/apptools/commonfunc.ts: -------------------------------------------------------------------------------- 1 | export const getTextSize = (() => { 2 | const p = document.createElement('p'); 3 | 4 | p.id = 'getTextSize'; 5 | p.style.visibility = 'hidden'; 6 | 7 | document.querySelector('#app-tools-container').appendChild(p); 8 | 9 | return (text, fontSize: number | string) => { 10 | if (typeof fontSize === 'number') { 11 | fontSize = fontSize + 'px'; 12 | } 13 | p.style.fontSize = fontSize; 14 | p.innerText = text; 15 | 16 | return { 17 | width : p.clientWidth, 18 | height : p.clientHeight 19 | }; 20 | } 21 | })(); 22 | 23 | export const deepAssign = (target, ...options) => { 24 | options.forEach(opt => { 25 | for (let key in opt) { 26 | if (opt.hasOwnProperty(key)) { 27 | 28 | const aim = target[key]; 29 | const src = opt[key]; 30 | 31 | if (aim === src) { 32 | continue; 33 | } 34 | 35 | if (src && typeof src === 'object') { 36 | let clone; 37 | 38 | if (Array.isArray(src)) { 39 | clone = Array.isArray(aim) ? aim : []; 40 | } else { 41 | clone = typeof aim === 'object' ? aim : {}; 42 | } 43 | 44 | target[key] = deepAssign(clone, src); 45 | } else { 46 | target[key] = src; 47 | } 48 | 49 | } 50 | } 51 | }); 52 | 53 | return target; 54 | }; 55 | 56 | export function deepClone(target: T): T { 57 | return JSON.parse(JSON.stringify(target)); 58 | } 59 | 60 | export const replaceInfoId = (topicInfo) => { 61 | const infoCopy = deepClone(topicInfo); 62 | 63 | _replaceId(infoCopy); 64 | 65 | return infoCopy; 66 | 67 | function _replaceId(info = infoCopy) { 68 | info.id = generateUUID(); 69 | info.children && info.children.forEach(child => _replaceId(child)); 70 | } 71 | }; 72 | 73 | export const generateUUID = () => { 74 | return 'xxxyxxxxxxxyxxxxxxxxxyxxxx'.replace(/[xy]/g, function (c) { 75 | const r = Math.random() * 16 | 0, 76 | v = c === 'x' ? r : (r & 0x3 | 0x8); 77 | return v.toString(16); 78 | }); 79 | }; 80 | 81 | export const delayInvoking = (() => { 82 | 83 | let firstInvoke; 84 | 85 | return (invokeToDelay) => { 86 | firstInvoke = firstInvoke || invokeToDelay; 87 | 88 | setTimeout(() => { 89 | if (firstInvoke) { 90 | firstInvoke(); 91 | firstInvoke = null; 92 | } 93 | }, 0); 94 | } 95 | })(); 96 | 97 | export const limitInvokeRepeat = (func: Function, time: number) => { 98 | let limiting; 99 | return function (...arg: any[]) { 100 | if (limiting) return; 101 | 102 | limiting = setTimeout(function () { 103 | func(...arg); 104 | limiting = false; 105 | }, time); 106 | } 107 | }; 108 | 109 | export const wrapTextWithEllipsis = (text, fontSize, maxWidth) => { 110 | if (getTextSize(text, fontSize).width <= maxWidth) return text; 111 | 112 | const ellipsisLength = getTextSize('...', fontSize).width; 113 | 114 | let wrapResult = ''; 115 | 116 | sliceText(); 117 | 118 | return wrapResult + '...'; 119 | 120 | function sliceText(textToSlice: string = text) { 121 | if (textToSlice.length === 1) return textToSlice; 122 | 123 | const slicePart1 = textToSlice.slice(0, parseInt(textToSlice.length / 2 + '')); 124 | const slicePart2 = textToSlice.replace(slicePart1, ''); 125 | 126 | if (getTextSize(wrapResult + slicePart1, fontSize).width > maxWidth - ellipsisLength) sliceText(slicePart1); 127 | else { 128 | wrapResult += slicePart1; 129 | sliceText(slicePart2); 130 | } 131 | } 132 | }; 133 | -------------------------------------------------------------------------------- /client/src/calcpath/connectline.ts: -------------------------------------------------------------------------------- 1 | import { LineType, LayoutType, TopicShapeType } from '../constants/common'; 2 | import * as Distance from '../constants/distance'; 3 | import { TopicShapeSpecialData } from '../constants/defaultstyle'; 4 | 5 | 6 | export default { 7 | [LineType.RIGHT_ANGLE](topicInfo) { 8 | 9 | const {startPoint, centerPoint, endPoints} = getImportantPoints(topicInfo); 10 | 11 | // draw line path 12 | let path = ''; 13 | 14 | // start to center 15 | path += `M ${startPoint[0]} ${startPoint[1]} ${centerPoint[0]} ${centerPoint[1]} `; 16 | 17 | // center to each end 18 | endPoints.forEach((endPoint) => { 19 | path += `M ${centerPoint[0]} ${endPoint[1]} ${endPoint[0]} ${endPoint[1]} ` 20 | }); 21 | 22 | // center line 23 | const endPointYs = endPoints.map(endPoint => endPoint[1]); 24 | const minY = Math.min(...endPointYs); 25 | const maxY = Math.max(...endPointYs); 26 | if (minY !== maxY) path += `M ${centerPoint[0]} ${minY} ${centerPoint[0]} ${maxY}`; 27 | 28 | return path; 29 | }, 30 | 31 | [LineType.ROUNDED](topicInfo) { 32 | 33 | const roundR = 5; 34 | 35 | const {startPoint, centerPoint, endPoints} = getImportantPoints(topicInfo); 36 | 37 | let path = ''; 38 | 39 | // start to center 40 | path += `M ${startPoint[0]} ${startPoint[1]} ${centerPoint[0]} ${centerPoint[1]} `; 41 | 42 | // center to each end 43 | endPoints.forEach((endPoint) => { 44 | const endPointY = endPoint[1]; 45 | 46 | if (endPointY === 0) { 47 | path += `M ${centerPoint[0]} ${endPointY} ${endPoint[0]} ${endPointY} `; 48 | } 49 | 50 | else { 51 | path += `M ${centerPoint[0]} ${endPointY + (endPointY < 0 ? + roundR : - roundR)} ` + 52 | `Q ${centerPoint[0]} ${endPointY} ${centerPoint[0] + roundR} ${endPointY} ` + 53 | `L ${endPoint[0]} ${endPointY} `; 54 | } 55 | }); 56 | 57 | // center line 58 | const endPointYs = endPoints.map(endPoint => endPoint[1]); 59 | const minY = Math.min(...endPointYs); 60 | const maxY = Math.max(...endPointYs); 61 | if (minY !== maxY) path += `M ${centerPoint[0]} ${minY + roundR} ${centerPoint[0]} ${maxY - roundR}`; 62 | 63 | return path; 64 | } 65 | } 66 | 67 | function getImportantPoints(topicInfo) { 68 | const {position: parentPosition, boxSize, style} = topicInfo; 69 | 70 | const marginLeft = Distance.TopicMargin[LayoutType.LOGIC_TO_RIGHT].marginLeft; 71 | 72 | const halfWidth = boxSize.width / 2; 73 | 74 | // startPoint 75 | let startPoint; 76 | switch (style.shapeClass) { 77 | case TopicShapeType.PARALLELOGRAM : 78 | { 79 | const cutLength = boxSize.height / 2 / TopicShapeSpecialData.parallelogramSlope; 80 | startPoint = [halfWidth - cutLength, 0]; 81 | break; 82 | } 83 | 84 | default : 85 | { 86 | startPoint = [halfWidth, 0]; 87 | } 88 | } 89 | 90 | // centerPoint 91 | const centerPoint = [halfWidth + marginLeft / 2, startPoint[1]]; 92 | 93 | // endPoints 94 | const endPoints = topicInfo.children.map((childInfo) => { 95 | const {position, boxSize, style} = childInfo; 96 | 97 | const fixedPosition = [position[0] - parentPosition[0], position[1] - parentPosition[1]]; 98 | 99 | switch (style.shapeClass) { 100 | case TopicShapeType.PARALLELOGRAM : 101 | { 102 | const cutLength = boxSize.height / 2 / TopicShapeSpecialData.parallelogramSlope; 103 | return [fixedPosition[0] - boxSize.width / 2 + cutLength, fixedPosition[1]]; 104 | } 105 | 106 | default : 107 | { 108 | return [fixedPosition[0] - boxSize.width / 2, fixedPosition[1]]; 109 | } 110 | } 111 | 112 | }); 113 | 114 | return {startPoint, centerPoint, endPoints} 115 | } 116 | -------------------------------------------------------------------------------- /client/src/calcpath/topicshape.ts: -------------------------------------------------------------------------------- 1 | import { TopicShapeType } from '../constants/common'; 2 | import { selectBoxSpace, TopicShapeSpecialData } from '../constants/defaultstyle'; 3 | 4 | const getSelectBoxPath = (boxSize) => { 5 | let {width, height} = boxSize; 6 | 7 | width += selectBoxSpace * 2; 8 | height += selectBoxSpace * 2; 9 | 10 | const halfWidth = width / 2; 11 | const halfHeight = height / 2; 12 | 13 | return `M ${-halfWidth} ${-halfHeight} h ${width} v ${height} h ${-width} Z`; 14 | }; 15 | 16 | export default { 17 | 18 | [TopicShapeType.RECT](boxSize) { 19 | const {width, height} = boxSize; 20 | 21 | const halfWidth = width / 2; 22 | const halfHeight = height / 2; 23 | 24 | const topicShapePath = `M ${-halfWidth} ${-halfHeight} h ${width} v ${height} h ${-width} Z`; 25 | 26 | const topicSelectBoxPath = getSelectBoxPath(boxSize); 27 | 28 | return { topicShapePath, topicSelectBoxPath }; 29 | }, 30 | 31 | [TopicShapeType.ROUNDED_RECT](boxSize) { 32 | const rx = 5, ry = 5; 33 | const roundR = TopicShapeSpecialData.roundedRectR; 34 | const doubleR = roundR * 2; 35 | 36 | const {width, height} = boxSize; 37 | 38 | const halfWidth = width / 2; 39 | const halfHeight = height / 2; 40 | 41 | const topicShapePath = `M ${-halfWidth + roundR} ${-halfHeight} h ${width - doubleR} ` + 42 | `Q ${halfWidth} ${-halfHeight} ${halfWidth} ${-halfHeight + ry} v ${height - doubleR} ` + 43 | `Q ${halfWidth} ${halfHeight} ${halfWidth - rx} ${halfHeight} h ${doubleR - width} ` + 44 | `Q ${-halfWidth} ${halfHeight} ${-halfWidth} ${halfHeight - ry} v ${doubleR - height} ` + 45 | `Q ${-halfWidth} ${-halfHeight} ${-halfWidth + rx} ${-halfHeight} Z`; 46 | 47 | const topicSelectBoxPath = getSelectBoxPath(boxSize); 48 | 49 | return { topicShapePath, topicSelectBoxPath }; 50 | }, 51 | 52 | [TopicShapeType.PARALLELOGRAM](boxSize) { 53 | const {width, height} = boxSize; 54 | 55 | const halfWidth = width / 2; 56 | const halfHeight = height / 2; 57 | 58 | const cutLength = height / TopicShapeSpecialData.parallelogramSlope; 59 | 60 | const topicShapePath = `M ${-halfWidth + cutLength} ${-halfHeight} h ${width - cutLength} ` + 61 | `L ${halfWidth - cutLength} ${halfHeight} h ${cutLength - width} Z`; 62 | 63 | const topicSelectBoxPath = getSelectBoxPath(boxSize); 64 | 65 | return { topicShapePath, topicSelectBoxPath }; 66 | } 67 | } -------------------------------------------------------------------------------- /client/src/components/core/Map/InfoItem/Label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { InfoItemMode } from 'src/constants/common'; 3 | import { LabelStyle } from 'src/constants/defaultstyle'; 4 | import * as CommonFunc from 'src/apptools/commonfunc'; 5 | 6 | import { extendTopicInfo } from 'src/interface'; 7 | 8 | interface LabelProps { 9 | topicInfo: extendTopicInfo 10 | displayMode: 'card' | 'icon' 11 | x?: number 12 | } 13 | 14 | const Label = ({topicInfo, displayMode, x}: LabelProps) => { 15 | let {boxSize: {width: parentWidth, height: parentHeight}, labelBoxSize: {width: labelWidth, height: labelHeight} , label: labelText} = topicInfo; 16 | 17 | if (displayMode === InfoItemMode.CARD) { 18 | 19 | const halfParentWidth = parentWidth / 2; 20 | const halfParentHeight = parentHeight / 2; 21 | 22 | if (labelWidth > parentWidth) labelWidth = parentWidth; 23 | 24 | let path = `M ${-halfParentWidth} ${halfParentHeight + 1} h ${labelWidth} v ${labelHeight} h ${-labelWidth} v ${-labelHeight} z`; 25 | 26 | const {fontSize, fillColor, padding} = LabelStyle; 27 | 28 | const labelTextStartX = -halfParentWidth + padding; 29 | const labelTextStartY = halfParentHeight + 1 + labelHeight / 2; 30 | 31 | labelText = CommonFunc.wrapTextWithEllipsis(labelText, fontSize, labelWidth - padding * 2); 32 | 33 | return ( 34 | 35 | 36 | {labelText} 37 | 38 | ) 39 | } else { 40 | 41 | const imageProps = { 42 | xlinkHref: '../../images/label.png', 43 | width: 20, 44 | height: 20, 45 | x: x, 46 | y: -10 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 53 | ); 54 | } 55 | }; 56 | 57 | export default Label; -------------------------------------------------------------------------------- /client/src/components/core/Map/Topic/ConnectLine.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import CalcConnectLine from 'src/calcpath/connectline'; 3 | 4 | // Connect line 5 | const ConnectLine = ({topicInfo}) => { 6 | const {lineClass, lineWidth, lineColor} = topicInfo.style; 7 | const path = CalcConnectLine[lineClass](topicInfo); 8 | 9 | return 10 | }; 11 | 12 | export default ConnectLine; -------------------------------------------------------------------------------- /client/src/components/core/Map/Topic/Fill.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // Topic Fill 4 | interface TopicFillProps { 5 | d: string; 6 | fillColor: string 7 | } 8 | 9 | const TopicFill = ({d, fillColor}: TopicFillProps) => { 10 | return ; 11 | }; 12 | 13 | export default TopicFill; -------------------------------------------------------------------------------- /client/src/components/core/Map/Topic/SelectBox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { SelectBoxColor } from 'src/constants/defaultstyle' 3 | 4 | // Topic Select Box 5 | interface TopicSelectBoxProps { 6 | d: string; 7 | selected: boolean; 8 | hovered: boolean; 9 | } 10 | 11 | const TopicSelectBox = ({d, selected, hovered}: TopicSelectBoxProps) => { 12 | 13 | const selectedBoxProps = { 14 | className: 'topic-select-box', 15 | fill: 'none', 16 | d: d, 17 | stroke: selected ? SelectBoxColor.SELECTED : SelectBoxColor.HOVER, 18 | strokeWidth: '3', 19 | style: { visibility: (selected || hovered) ? 'visible' : 'hidden' } 20 | }; 21 | 22 | return ; 23 | }; 24 | 25 | export default TopicSelectBox; -------------------------------------------------------------------------------- /client/src/components/core/Map/Topic/Shape.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // Topic Shape 4 | interface TopicShapeProps { 5 | d: string; 6 | strokeWidth: string; 7 | strokeColor: string; 8 | } 9 | 10 | const TopicShape = ({d, strokeWidth, strokeColor}: TopicShapeProps) => { 11 | return ; 12 | }; 13 | 14 | export default TopicShape; -------------------------------------------------------------------------------- /client/src/components/core/Map/Topic/Title.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // Topic Title 4 | interface TopicTitleProps { 5 | title: string 6 | fontSize: string 7 | fontColor: string 8 | isFontBold: boolean 9 | isFontItalic: boolean 10 | isFontLineThrough: boolean 11 | x: number 12 | } 13 | 14 | class TopicTitle extends React.Component { 15 | render() { 16 | 17 | let { title, fontSize, fontColor, isFontBold, isFontItalic, isFontLineThrough, x } = this.props; 18 | 19 | // 设置默认title 20 | if (title.trim() === '') title = 'Topic'; 21 | 22 | const style: any = { 23 | fontSize: fontSize, 24 | fill: fontColor 25 | }; 26 | 27 | if (isFontBold) style.fontWeight = 700; 28 | if (isFontItalic) style.fontStyle = 'italic'; 29 | if (isFontLineThrough) style.textDecoration = 'line-through'; 30 | 31 | return { title }; 32 | } 33 | } 34 | 35 | export default TopicTitle -------------------------------------------------------------------------------- /client/src/components/core/Map/Topic/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'dva'; 3 | import TopicShape from './Shape'; 4 | import TopicFill from './Fill'; 5 | import TopicSelectBox from './SelectBox'; 6 | import ConnectLine from './ConnectLine'; 7 | import TopicTitle from './Title'; 8 | import Label from '../InfoItem/Label'; 9 | 10 | import CalcTopicShape from 'src/calcpath/topicshape'; 11 | import { pasteInfoManager, componentMapManager } from 'src/managers'; 12 | import * as AddOn from 'src/apptools/addon'; 13 | import {TopicType, LineStrokeWidthType, TopicStrokeWidthType, LineType} from 'src/constants/common'; 14 | 15 | import { extendTopicInfo, appState } from 'src/interface'; 16 | 17 | // todo props and state interface 18 | interface TopicProps { 19 | app: appState 20 | selectionList: Array 21 | topicInfo: extendTopicInfo 22 | dispatch: Function 23 | } 24 | 25 | interface TopicState { 26 | hovered: boolean 27 | } 28 | 29 | class Topic extends React.Component { 30 | 31 | refs: any; 32 | 33 | constructor() { 34 | super(); 35 | 36 | this.state = { 37 | hovered: false 38 | }; 39 | } 40 | 41 | static defaultProps = { 42 | selectionList: [] 43 | }; 44 | 45 | componentWillMount() { 46 | componentMapManager.addComponent(this.props.topicInfo.id, this); 47 | } 48 | 49 | componentWillUnmount() { 50 | componentMapManager.removeComponent(this.props.topicInfo.id); 51 | } 52 | 53 | // todo 54 | shouldComponentUpdate(nextProps: TopicProps, nextState) { 55 | // const stringify = JSON.stringify.bind(JSON); 56 | 57 | // // check state 58 | // const stateHasChanged = stringify(this.state) !== stringify(nextState); 59 | // if (stateHasChanged) return true; 60 | 61 | // // check self's props 62 | // const topicInfo = this.props.topicInfo; 63 | // const nextTopicInfo = nextProps.topicInfo; 64 | 65 | // const targetTreeHasChanged = (this.props.targetTree || {} as any).id !== (nextProps.targetTree || {} as any).id; 66 | // const styleHasChanged = stringify(topicInfo.style) !== stringify(nextTopicInfo.style); 67 | // const boundsHasChanged = stringify(topicInfo.bounds) !== stringify(nextTopicInfo.bounds); 68 | // const positionHasChanged = stringify(topicInfo.position) !== stringify(nextTopicInfo.position); 69 | // const titleHasChanged = topicInfo.title !== nextTopicInfo.title; 70 | 71 | // const selfPropsHasChanged = styleHasChanged || boundsHasChanged || positionHasChanged || titleHasChanged || targetTreeHasChanged; 72 | // if (selfPropsHasChanged) return true; 73 | 74 | 75 | // // check child structure props 76 | // const children = topicInfo.children; 77 | // const nextChildren = nextTopicInfo.children; 78 | 79 | // let childShapeClassHasChanged = false; 80 | // if (children && nextChildren) { 81 | // childShapeClassHasChanged = children.some((childInfo, index) => { 82 | // return childInfo.style.shapeClass !== nextChildren[index].style.shapeClass; 83 | // }); 84 | // } 85 | 86 | // return childShapeClassHasChanged; 87 | return true 88 | } 89 | 90 | /** 91 | * @description check is this topic selected 92 | */ 93 | isThisTopicSelected(): boolean { 94 | const { topicInfo, selectionList } = this.props; 95 | return Boolean(selectionList.length && (selectionList.indexOf(topicInfo.id) !== -1)); 96 | } 97 | 98 | getTopicShapePath() { 99 | const topicInfo = this.props.topicInfo; 100 | return CalcTopicShape[topicInfo.style.shapeClass](topicInfo.boxSize); 101 | } 102 | 103 | // userAgent events 104 | onTopicClick(e: MouseEvent) { 105 | e.stopPropagation(); 106 | 107 | // if has not selected 108 | if (!this.isThisTopicSelected()) { 109 | const dispatchType = (e.ctrlKey || e.metaKey) ? 'map/addSelectionToList' : 'map/setSingleSelection'; 110 | // reset hovered state 111 | this.setState({ hovered: false }); 112 | // update selection store 113 | this.props.dispatch({ type: dispatchType, id: this.props.topicInfo.id, ignoreUndo: true }); 114 | // prepare edit receiver 115 | AddOn.editReceiver.prepare(this); 116 | } 117 | } 118 | 119 | onTopicDoubleClick() { 120 | AddOn.editReceiver.show(this); 121 | } 122 | 123 | /** 124 | * @description triggered when topic mouse enter, the topic should display hovered style 125 | */ 126 | onTopicMouseEnter() { 127 | // if not selected, show hovered box 128 | if (!this.isThisTopicSelected()) { 129 | this.setState({ hovered: true }); 130 | } 131 | } 132 | 133 | onTopicMouseOut(e) { 134 | const targetClass = e.target.getAttribute('class'); 135 | if (targetClass && targetClass.includes('topic-fill') && this.state.hovered) { 136 | this.setState({ hovered: false }); 137 | } 138 | } 139 | 140 | copyTopicInfo() { 141 | pasteInfoManager.refreshInfo(this.props.topicInfo.originTopicInfo); 142 | } 143 | 144 | cutTopicInfo() { 145 | if (this.getType() === TopicType.ROOT) return false; 146 | 147 | pasteInfoManager.refreshInfo(this.props.topicInfo.originTopicInfo); 148 | } 149 | 150 | pasteTopicInfo() { 151 | if (!pasteInfoManager.hasInfoStashed()) return; 152 | } 153 | 154 | onUpdateTitle(title) { 155 | if (title === this.props.topicInfo.title) { 156 | return false; 157 | } 158 | 159 | this.props.dispatch({ type: 'map/setTitle', title }); 160 | } 161 | 162 | // method for editReceiver 163 | getTitleClientRect() { 164 | return this.refs.TopicTitle.refs.title.getBoundingClientRect(); 165 | } 166 | 167 | getTitle() { 168 | return this.props.topicInfo.title || 'Topic'; 169 | } 170 | 171 | getType() { 172 | return this.props.topicInfo.type; 173 | } 174 | 175 | getGroupBoxRect() { 176 | return this.refs.TopicGroup.querySelector('.topic-select-box').getBoundingClientRect(); 177 | } 178 | 179 | renderInnerItem() { 180 | 181 | let innerGroupWidth = 0; 182 | 183 | const topicInfo = this.props.topicInfo; 184 | 185 | const style = topicInfo.style; 186 | const title = topicInfo.title == null ? 'Topic' : topicInfo.title; 187 | 188 | const TopicTitleProps = { 189 | ref: 'TopicTitle', 190 | title: title, 191 | fontSize: style.fontSize, 192 | fontColor: style.fontColor, 193 | isFontBold: style.isFontBold, 194 | isFontItalic: style.isFontItalic, 195 | isFontLineThrough: style.isFontLineThrough 196 | }; 197 | 198 | innerGroupWidth += this.props.topicInfo.titleAreaSize.width; 199 | 200 | const needLabel = topicInfo.label; 201 | // const isLabelIcon = this.props.infoItem.label === CommonConstant.INFO_ITEM_ICON_MODE; 202 | const isLabelIcon = false; 203 | const doRenderIconLabel = needLabel && isLabelIcon; 204 | 205 | let labelX: number; 206 | 207 | if (doRenderIconLabel) { 208 | innerGroupWidth += 5 + topicInfo.labelBoxSize.width; 209 | labelX = topicInfo.titleAreaSize.width - innerGroupWidth / 2 + 5; 210 | } 211 | 212 | return ( 213 | 214 | 215 | {doRenderIconLabel ? 217 | ); 218 | 219 | } 220 | 221 | // todo 222 | renderCardItem() { 223 | const topicInfo = this.props.topicInfo; 224 | const needLabel = topicInfo.label; 225 | // const isLabelCard = this.props.infoItem.label === CommonConstant.INFO_ITEM_CARD_MODE; 226 | const isLabelCard = true; 227 | const doRenderCardLabel = needLabel && isLabelCard; 228 | 229 | return ( 230 | 231 | {doRenderCardLabel ? 233 | ); 234 | } 235 | 236 | render() { 237 | 238 | const { topicInfo } = this.props; 239 | 240 | const TopicGroupProps = { 241 | ref: 'TopicGroup', 242 | className: `topic-group ${topicInfo.type}`, 243 | transform: `translate(${topicInfo.position[0]},${topicInfo.position[1]})` 244 | }; 245 | 246 | const { topicShapePath, topicSelectBoxPath } = this.getTopicShapePath(); 247 | const style = topicInfo.style; 248 | 249 | const TopicFillProps = { 250 | d: topicShapePath, 251 | fillColor: style.fillColor 252 | }; 253 | 254 | const TopicShapeProps = { 255 | d: topicShapePath, 256 | strokeWidth: style.strokeWidth, 257 | strokeColor: style.strokeColor 258 | }; 259 | 260 | const TopicBoxGroupProps = { 261 | className: 'topic-box-group', 262 | onClick: (e) => this.onTopicClick(e), 263 | onDoubleClick: () => this.onTopicDoubleClick(), 264 | onMouseEnter: () => this.onTopicMouseEnter(), 265 | onMouseOut: (e) => this.onTopicMouseOut(e) 266 | }; 267 | 268 | const TopicSelectBoxProps = { 269 | d: topicSelectBoxPath, 270 | selected: this.isThisTopicSelected(), 271 | hovered: this.state.hovered 272 | }; 273 | 274 | const needConnectLine = 275 | style.lineClass !== LineType.NONE && 276 | style.lineWidth !== LineStrokeWidthType.NONE && 277 | topicInfo.children && topicInfo.children.length; 278 | 279 | const needShape = style.strokeWidth !== TopicStrokeWidthType.NONE; 280 | 281 | return ( 282 | 283 | 284 | {needShape ? : []} 285 | 286 | {this.renderInnerItem()} 287 | 288 | 289 | {/*{this.renderCardItem()}*/} 290 | {needConnectLine ? : []} 291 | 292 | ); 293 | } 294 | } 295 | 296 | const mapStateToProps = ({ app, map }) => { 297 | return { app, selectionList: map.selectionList }; 298 | }; 299 | 300 | export default connect(mapStateToProps)(Topic); 301 | -------------------------------------------------------------------------------- /client/src/components/core/Map/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'dva' 3 | import Topic from './Topic'; 4 | const Draggable = require('react-draggable'); 5 | import { TopicType, TopicStrokeWidthType, InfoItemMode } from 'src/constants/common'; 6 | import { deepClone, getTextSize } from 'src/apptools/commonfunc'; 7 | import layoutTopics from 'src/layout'; 8 | import { LabelStyle, TopicStyle } from 'src/constants/defaultstyle'; 9 | import * as Distance from 'src/constants/distance'; 10 | import { topicExtendedInfoMap } from 'src/managers' 11 | import { mapState, appState, topicInfo, extendTopicInfo } from 'src/interface' 12 | 13 | interface MapProps { 14 | map: mapState 15 | app: appState 16 | /** 17 | * @description mindmap缩放值 18 | * */ 19 | scaleValue: number 20 | } 21 | 22 | class Map extends React.Component { 23 | 24 | /** 25 | * @description get extended topics info, including the parentId, position, boxSize, it's type, and so on 26 | * @param topicInfo the topic info tree to extended 27 | * */ 28 | calcExtendedTopicTreeInfo(topicInfo: topicInfo): extendTopicInfo { 29 | // copy as return value 30 | const topicInfoCopy: extendTopicInfo = deepClone(topicInfo) as extendTopicInfo; 31 | 32 | // set origin info 33 | topicInfoCopy.originTopicInfo = deepClone(topicInfo); 34 | 35 | // set topic's info about parent 36 | this.setTopicInfoAboutParent(topicInfoCopy); 37 | 38 | // mix style with default setting 39 | topicInfoCopy.style = Object.assign({}, TopicStyle[topicInfoCopy.type], topicInfoCopy.style); 40 | 41 | this.setTopicSizeInfo(topicInfoCopy); 42 | 43 | topicInfo.children && (topicInfoCopy.children = topicInfo.children.map(childTopic => this.calcExtendedTopicTreeInfo(childTopic))); 44 | 45 | // save extended topic info 46 | topicExtendedInfoMap[topicInfoCopy.id] = deepClone(topicInfoCopy); 47 | 48 | return topicInfoCopy; 49 | } 50 | 51 | /** 52 | * @param topicInfo the topic to find parent 53 | * @param treeLevelToCheck the check level of current check traversal, the start level is the ROOT level 54 | * @return the parent topic info of a topic node 55 | * */ 56 | getParentOfTopicNode(topicInfo: topicInfo, treeLevelToCheck: topicInfo = this.props.map.topicTree): topicInfo { 57 | // if the topicInfo to check is the current check level, it means this topic is the ROOT topic 58 | if (topicInfo.id === treeLevelToCheck.id) return; 59 | // start traversing 60 | const children = treeLevelToCheck.children; 61 | if (children) { 62 | for (const childTreeToCheck of children) { 63 | if (topicInfo.id === childTreeToCheck.id) return treeLevelToCheck; 64 | 65 | // use depth-first traversal to find the parent 66 | const parentResult = this.getParentOfTopicNode(topicInfo, childTreeToCheck); 67 | if (parentResult) return parentResult; 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * @description set topic's info about it's parent, including it's type, parentId, index 74 | * @param topicInfo 75 | */ 76 | setTopicInfoAboutParent(topicInfo: extendTopicInfo) { 77 | const parent = this.getParentOfTopicNode(topicInfo); 78 | 79 | // set type 80 | let topicType; 81 | if (!parent) topicType = TopicType.ROOT; 82 | else if (!this.getParentOfTopicNode(parent)) topicType = TopicType.MAIN; 83 | else topicType = TopicType.SUB; 84 | topicInfo.type = topicType; 85 | 86 | // set parentId 87 | topicInfo.parentId = parent ? parent.id : null; 88 | 89 | // set index 90 | topicInfo.index = parent ? parent.children.findIndex(childTopic => childTopic.id === topicInfo.id) : 0; 91 | } 92 | 93 | /** 94 | * @description calc and set topic's size info 95 | * */ 96 | setTopicSizeInfo(topicInfo: extendTopicInfo) { 97 | const { style: { fontSize, strokeWidth, shapeClass }, type } = topicInfo; 98 | 99 | // set title area size 100 | const titleAreaSize = getTextSize(topicInfo.title || 'Topic', fontSize); 101 | topicInfo.titleAreaSize = titleAreaSize; 102 | 103 | let boxSize = { width: 0, height: 0 }; 104 | const fontSizeNumber = parseInt(fontSize); 105 | const strokeWidthNumber = strokeWidth === TopicStrokeWidthType.NONE ? 0 : parseInt(strokeWidth); 106 | const { paddingLeft, paddingTop } = Distance.TopicPaddingOverride[type][shapeClass]; 107 | boxSize.width = titleAreaSize.width + fontSizeNumber * paddingLeft * 2 + strokeWidthNumber; 108 | boxSize.height = titleAreaSize.height + fontSizeNumber * paddingTop * 2 + strokeWidthNumber; 109 | 110 | // fixme: if has info item 111 | if (topicInfo.label) this.extendBoxSizeWithInfoItem(topicInfo, boxSize); 112 | topicInfo.boxSize = boxSize; 113 | } 114 | 115 | /** 116 | * @description if current topic has info item, extend it's boxSize 117 | * */ 118 | extendBoxSizeWithInfoItem(topicInfo: extendTopicInfo, boxSize: { width: number, height: number }) { 119 | const infoItemSettings = this.props.app.infoItemDisplay; 120 | 121 | interface labelBoxSize { width: number, height: number, mode: string } 122 | 123 | let labelBoxSize: labelBoxSize; 124 | 125 | if (infoItemSettings.label === InfoItemMode.CARD) { 126 | const { width: labelTextWidth, height: labelTextHeight } = getTextSize(topicInfo.label, LabelStyle.fontSize); 127 | 128 | const labelPadding = LabelStyle.padding; 129 | const labelWidth = labelTextWidth + labelPadding * 2; 130 | const labelHeight = labelTextHeight + labelPadding * 2; 131 | 132 | labelBoxSize = { width: labelWidth, height: labelHeight, mode: infoItemSettings.label }; 133 | } else { 134 | labelBoxSize = { width: 20, height: 20, mode: infoItemSettings.label }; 135 | boxSize.width += labelBoxSize.width; 136 | } 137 | 138 | topicInfo.labelBoxSize = labelBoxSize; 139 | } 140 | 141 | /** 142 | * @description render nested topic tree component 143 | * */ 144 | renderTopicTree() { 145 | const { topicTree, mapStructure } = this.props.map; 146 | 147 | const extendedTopicInfo = this.calcExtendedTopicTreeInfo(topicTree); 148 | 149 | // get bounds and position 150 | layoutTopics(extendedTopicInfo, mapStructure); 151 | 152 | const topicsList = []; 153 | 154 | const createTopic = topicInfo => { 155 | const topicProps = { 156 | key: topicInfo.id, 157 | topicInfo: topicInfo 158 | }; 159 | 160 | return ; 161 | }; 162 | 163 | const setTopicArrayData = (topicInfo) => { 164 | topicsList.push(createTopic(topicInfo)); 165 | topicInfo.children && topicInfo.children.forEach(childTree => setTopicArrayData(childTree)); 166 | }; 167 | 168 | setTopicArrayData(extendedTopicInfo); 169 | 170 | return topicsList; 171 | } 172 | 173 | render() { 174 | return ( 175 | e.stopPropagation()}> 176 | { this.renderTopicTree() } 177 | 178 | ); 179 | } 180 | } 181 | 182 | const mapStateToProps = ({ map, app }) => { 183 | return { map, app } 184 | }; 185 | 186 | export default connect(mapStateToProps)(Map); -------------------------------------------------------------------------------- /client/src/components/core/Sheet.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'dva' 3 | import { Spin } from 'src/components/ui/antd' 4 | import { dragSelectReceiver } from 'src/apptools/addon'; 5 | import { sheetState, appState } from 'src/interface' 6 | 7 | interface SheetProps { 8 | sheet: sheetState 9 | selectionList: Array 10 | receivingInitState: boolean 11 | dispatch: Function 12 | } 13 | 14 | class Sheet extends React.Component { 15 | 16 | topicsContainer: HTMLElement; 17 | 18 | editReceiver: HTMLElement; 19 | 20 | onWheel(e) { 21 | e.preventDefault(); 22 | this.moveTopicsContainer(e.deltaX, e.deltaY); 23 | this.moveEditReceiver(e.deltaX, e.deltaY); 24 | } 25 | 26 | onMouseDown(e) { 27 | dragSelectReceiver.dragStart(e); 28 | } 29 | 30 | onMouseUp() { 31 | if (this.props.selectionList.length !== 0) { 32 | // clear target topic list 33 | this.props.dispatch({type: 'map/clearSelectionList', ignoreUndo: true}); 34 | } 35 | } 36 | 37 | // todo try svg animation 38 | moveTopicsContainer(deltaX, deltaY) { 39 | if (deltaX === 0 && deltaY === 0) return false; 40 | 41 | const {topicsContainer} = this; 42 | const transformAttr = topicsContainer.getAttribute('transform'); 43 | const execResult = /translate\(\s*([^\s,)]+)[ ,]([^\s,)]+)/.exec(transformAttr); 44 | const [preX, preY] = execResult ? [Number(execResult[1]), Number(execResult[2])] : [0, 0]; 45 | const [newX, newY] = [preX - deltaX, preY - deltaY]; 46 | 47 | topicsContainer.setAttribute('transform', `translate(${newX},${newY})`); 48 | } 49 | 50 | moveEditReceiver(deltaX, deltaY) { 51 | if (deltaX === 0 && deltaY === 0) return false; 52 | 53 | const {editReceiver} = this; 54 | 55 | if (Number(editReceiver.style.zIndex) < 0) return false; 56 | 57 | const {left: preLeft, top: preTop} = editReceiver.style; 58 | editReceiver.style.left = parseInt(preLeft) - deltaX + 'px'; 59 | editReceiver.style.top = parseInt(preTop) - deltaY + 'px'; 60 | } 61 | 62 | componentDidMount() { 63 | this.topicsContainer = document.querySelector('.topics-group') as HTMLElement; 64 | this.editReceiver = document.querySelector('#editReceiver') as HTMLElement; 65 | } 66 | 67 | render() { 68 | const spinProps = { 69 | spinning: this.props.receivingInitState, 70 | size: 'large', 71 | wrapperClassName: 'sheet-loading-container' 72 | }; 73 | 74 | const sheetProps = { 75 | id: 'sheet', 76 | style: {backgroundColor: this.props.sheet.backgroundColor}, 77 | onWheel: (e) => this.onWheel(e), 78 | onMouseDown: (e) => this.onMouseDown(e), 79 | onMouseUp: () => this.onMouseUp() 80 | }; 81 | 82 | return ( 83 | 84 | { this.props.children } 85 | 86 | ); 87 | } 88 | } 89 | 90 | const mapStateToProps = ({sheet, map, app}) => { 91 | return { 92 | sheet, 93 | selectionList: map.selectionList, 94 | receivingInitState: app.receivingInitState 95 | }; 96 | }; 97 | 98 | export default connect(mapStateToProps)(Sheet); -------------------------------------------------------------------------------- /client/src/components/core/index.scss: -------------------------------------------------------------------------------- 1 | .core-container { 2 | width: calc(100% - 300px); 3 | 4 | // svg缩放滑块容器 5 | .scale-slider-container { 6 | position: fixed; 7 | bottom: 20px; 8 | left: 20px; 9 | height: 200px; 10 | padding: 10px 0 20px 0; 11 | border-radius: 20px; 12 | background-color: #eee; 13 | border: 1px solid #eee; 14 | } 15 | 16 | .sheet-loading-container { 17 | width: 100%; 18 | height: 100%; 19 | .ant-spin-container { 20 | width: 100%; 21 | height: 100%; 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /client/src/components/core/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Sheet from './Sheet' 3 | import Map from './Map' 4 | import { LocalStorageKey } from 'src/constants/common' 5 | import { Slider } from 'src/components/ui/antd' 6 | import './index.scss' 7 | 8 | interface CoreComponentState { 9 | /** 10 | * @description svg的缩放值 11 | * */ 12 | scaleValue: number 13 | } 14 | 15 | class CoreComponent extends React.PureComponent { 16 | 17 | constructor() { 18 | super(); 19 | 20 | this.state = { 21 | // 缩放值默认为1 22 | scaleValue: Number(localStorage.getItem(LocalStorageKey.SCALE_VALUE)) || 1 23 | } 24 | } 25 | 26 | /** 27 | * @description 监听缩放值改变 28 | * */ 29 | onScaleValueChanged(value: number) { 30 | this.setState({ scaleValue: value }); 31 | localStorage.setItem(LocalStorageKey.SCALE_VALUE, value.toString()); 32 | } 33 | 34 | /** 35 | * @description 输出改变svg缩放的slider 36 | * */ 37 | renderScaleChangeSlider() { 38 | const sliderProps = { 39 | min: 0.5, 40 | max: 1.5, 41 | step: 0.1, 42 | dots: true, 43 | vertical: true, 44 | value: this.state.scaleValue, 45 | onChange: (value) => this.onScaleValueChanged(value) 46 | }; 47 | 48 | return ( 49 |
50 | 51 |
52 | ) 53 | } 54 | 55 | render() { 56 | 57 | return ( 58 |
59 | 60 | 61 | 62 |
63 | ) 64 | } 65 | } 66 | 67 | export default CoreComponent -------------------------------------------------------------------------------- /client/src/components/index.scss: -------------------------------------------------------------------------------- 1 | main { 2 | display: flex; 3 | height: calc(100vh - 60px); 4 | } -------------------------------------------------------------------------------- /client/src/components/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import CoreComponent from './core' 3 | import Header from './ui/Header' 4 | import OperationPanel from './ui/OperationPanels' 5 | require('./index.scss'); 6 | 7 | export default () => { 8 | return ( 9 |
10 |
11 |
12 | 13 | 14 |
15 |
16 | ) 17 | } -------------------------------------------------------------------------------- /client/src/components/ui/Header/index.scss: -------------------------------------------------------------------------------- 1 | header { 2 | width: 100%; 3 | height: 60px; 4 | background-color: #444; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | 9 | .profile-area { 10 | 11 | } 12 | 13 | .undo-btn-area { 14 | margin-right: 20px; 15 | .undo-btn { 16 | margin-right: 10px; 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /client/src/components/ui/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { events } from 'src/managers' 3 | import { UNDO_OR_REDO_TRIGGERED, PUSH_UNDO_STACK } from 'src/constants/EventTags' 4 | import { Button } from '../antd' 5 | import { ACTION_UNDO, ACTION_REDO, hooks } from 'src/app/middlewares/undo'; 6 | import app from 'src/app' 7 | require('./index.scss'); 8 | 9 | interface HeaderState { 10 | hasUndo: boolean; 11 | hasRedo: boolean; 12 | } 13 | 14 | export default class Header extends React.Component { 15 | 16 | constructor() { 17 | super(); 18 | 19 | this.state = { 20 | hasUndo: false, 21 | hasRedo: false 22 | } 23 | } 24 | 25 | componentDidMount() { 26 | events.on(PUSH_UNDO_STACK, () => { 27 | this.setState({ hasUndo: true, hasRedo: false }); 28 | }); 29 | 30 | events.on(UNDO_OR_REDO_TRIGGERED, () => { 31 | this.setState({ hasUndo: hooks.hasUndo(), hasRedo: hooks.hasRedo() }); 32 | }) 33 | } 34 | 35 | render() { 36 | const undoBtnProps = { 37 | type: "primary", 38 | className: 'undo-btn', 39 | disabled: !this.state.hasUndo, 40 | onClick: () => app.dispatch({ type: ACTION_UNDO }) 41 | }; 42 | 43 | const redoBtnProps = { 44 | type: "primary", 45 | disabled: !this.state.hasRedo, 46 | onClick: () => app.dispatch({ type: ACTION_REDO }) 47 | }; 48 | 49 | return ( 50 |
51 |
52 | 53 |
54 |
55 | 56 | 57 |
58 |
59 | ); 60 | } 61 | } -------------------------------------------------------------------------------- /client/src/components/ui/OperationPanels/SheetEditPanel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'dva' 3 | import { ColorPicker } from '../antd' 4 | import { appState, sheetState } from 'src/interface' 5 | 6 | interface SheetEditPanelProps { 7 | app: appState 8 | sheet: sheetState 9 | selectionList: Array 10 | dispatch: Function 11 | } 12 | 13 | class SheetEditPanel extends React.Component { 14 | 15 | static defaultProps = { 16 | selectionList: [] 17 | }; 18 | 19 | render() { 20 | const panelProps = { 21 | className: 'edit-panel sheet-edit-panel', 22 | style: { 23 | display: !this.props.selectionList.length ? 'block' : 'none' 24 | } 25 | }; 26 | 27 | const backgroundColorPickerProps = { 28 | value: this.props.sheet.backgroundColor, 29 | onChange: (value) => this.props.dispatch({ type: 'sheet/setBackgroundColor', backgroundColor: value }) 30 | }; 31 | 32 | return ( 33 |
34 |
35 | Background Color : 36 | 37 |
38 |
39 |
40 | ); 41 | } 42 | } 43 | 44 | const mapStateToProps = ({ sheet, app, map }) => { 45 | return { sheet, app, selectionList: map.selectionList } 46 | }; 47 | 48 | export default connect(mapStateToProps)(SheetEditPanel); -------------------------------------------------------------------------------- /client/src/components/ui/OperationPanels/TopicEditPanel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { connect } from 'dva' 3 | import { TopicShapeType, TopicStrokeWidthType, LineType, LineStrokeWidthType } from 'src/constants/common' 4 | import { topicExtendedInfoMap } from 'src/managers' 5 | import { Button, Selector, ColorPicker, Switch } from '../antd' 6 | 7 | const optionsMap = { 8 | fontSize: { 9 | '8px': '8', '9px': '9', '10px': '10', '11px': '11', '12px': '12', '13px': '13', '14px': '14', '16px': '16', 10 | '18px': '18', '20px': '20', '22px': '22', '24px': '24', '36px': '36', '48px': '48', '56px': '56' 11 | }, 12 | 13 | shapeClass: { 14 | [TopicShapeType.RECT]: 'Rect', 15 | [TopicShapeType.ROUNDED_RECT]: 'Rounded Rectangle', 16 | [TopicShapeType.PARALLELOGRAM]: 'Parallelogram' 17 | }, 18 | 19 | borderWidth: { 20 | [TopicStrokeWidthType.NONE]: 'None', 21 | [TopicStrokeWidthType.THIN]: 'Thin', 22 | [TopicStrokeWidthType.MIDDLE]: 'Middle', 23 | [TopicStrokeWidthType.BOLD]: 'Bold' 24 | }, 25 | 26 | lineClass: { 27 | [LineType.NONE]: 'None', 28 | [LineType.RIGHT_ANGLE]: 'Right Angle', 29 | [LineType.ROUNDED]: 'Rounded' 30 | }, 31 | 32 | lineWidth: { 33 | [LineStrokeWidthType.NONE]: 'None', 34 | [LineStrokeWidthType.THIN]: 'Thin', 35 | [LineStrokeWidthType.MIDDLE]: 'Middle', 36 | [LineStrokeWidthType.BOLD]: 'Bold' 37 | } 38 | }; 39 | 40 | interface TopicEditPanelProps { 41 | selectionList: Array 42 | dispatch: Function 43 | } 44 | 45 | interface TopicEditPanelState { 46 | show?: boolean; 47 | isTargetRoot?: boolean; 48 | 49 | fontSize?: string; 50 | fontColor?: string; 51 | isFontBold?: boolean; 52 | isFontItalic?: boolean; 53 | isFontLineThrough?: boolean; 54 | 55 | shapeClass?: string; 56 | fillColor?: string; 57 | strokeWidth?: string; 58 | strokeColor?: string; 59 | 60 | lineClass?: string; 61 | lineWidth?: string; 62 | lineColor?: string; 63 | 64 | labelText?: string; 65 | } 66 | 67 | class TopicEditPanel extends React.Component { 68 | constructor(props: TopicEditPanelProps) { 69 | super(); 70 | 71 | this.setStateBySelectionList(props.selectionList); 72 | } 73 | 74 | static defaultProps = { 75 | selectionList: [] 76 | }; 77 | 78 | componentWillReceiveProps(nextProps: TopicEditPanelProps) { 79 | // reset state while selection list changed 80 | this.setStateBySelectionList(nextProps.selectionList); 81 | } 82 | 83 | /** 84 | * @description set state according to current selected topics 85 | * @param selectionList current selected topics 86 | * */ 87 | setStateBySelectionList(selectionList: Array) { 88 | if (!selectionList.length) return; 89 | 90 | // todo 先根据最后列表中最后的一个topic来确定样式 91 | const topicInfoToSetStyle = topicExtendedInfoMap[selectionList[selectionList.length - 1]]; 92 | const styleToSet = topicInfoToSetStyle.style; 93 | 94 | this.state = { 95 | fontSize: styleToSet.fontSize, 96 | fontColor: styleToSet.fontColor, 97 | isFontBold: styleToSet.isFontBold, 98 | isFontItalic: styleToSet.isFontItalic, 99 | isFontLineThrough: styleToSet.isFontLineThrough, 100 | 101 | shapeClass: styleToSet.shapeClass, 102 | fillColor: styleToSet.fillColor, 103 | strokeWidth: styleToSet.strokeWidth, 104 | strokeColor: styleToSet.strokeColor, 105 | 106 | lineClass: styleToSet.lineClass, 107 | lineWidth: styleToSet.lineWidth, 108 | lineColor: styleToSet.lineColor, 109 | 110 | labelText: topicInfoToSetStyle.label || '', 111 | 112 | // todo 113 | isTargetRoot: false 114 | } 115 | } 116 | 117 | /** 118 | * @description the operator button for editing topic tree 119 | */ 120 | renderTreeEditWidgetArea() { 121 | 122 | const addChildTopicBtnProps = { 123 | type: 'primary', 124 | onClick: () => this.props.dispatch({ type: 'map/addChildTopic' }) 125 | }; 126 | 127 | const addTopicBeforeBtnProps = { 128 | type: 'primary', 129 | onClick: () => this.props.dispatch({ type: 'map/addTopicBefore' }) 130 | }; 131 | 132 | const addTopicAfterBtnProps = { 133 | type: 'primary', 134 | onClick: () => this.props.dispatch({ type: 'map/addTopicAfter' }) 135 | }; 136 | 137 | const addParentTopicBtnProps = { 138 | type: 'primary', 139 | onClick: () => this.props.dispatch({ type: 'map/addParentTopic' }) 140 | }; 141 | 142 | const removeTopicBtnProps = { 143 | type: 'danger', 144 | onClick: () => this.props.dispatch({ type: 'map/removeTopic' }) 145 | }; 146 | 147 | return ( 148 |
149 | 150 | 151 | 152 | 153 | 154 |
155 | ) 156 | } 157 | 158 | /** 159 | * @description the operator widget for editing topic text 160 | */ 161 | renderFontStyleEditWidgetArea() { 162 | 163 | const fontSizeSelectorProps = { 164 | options: optionsMap.fontSize, 165 | value: this.state.fontSize, 166 | onChange: (value) => this.props.dispatch({ type: 'map/setFontSize', fontSize: value }) 167 | }; 168 | 169 | const fontColorPickerProps = { 170 | value: this.state.fontColor, 171 | onChange: (value) => this.props.dispatch({ type: 'map/setFontColor', fontColor: value }) 172 | }; 173 | 174 | const isFontBoldSwitchProps = { 175 | checked: this.state.isFontBold, 176 | onChange: (value) => this.props.dispatch({ type: 'map/setIsFontBold', isFontBold: value }) 177 | }; 178 | 179 | const isFontItalicSwitchProps = { 180 | checked: this.state.isFontItalic, 181 | onChange: (value) => this.props.dispatch({ type: 'map/setIsFontItalic', isFontItalic: value }) 182 | }; 183 | 184 | const isFontLineThroughProps = { 185 | checked: this.state.isFontLineThrough, 186 | onChange: (value) => this.props.dispatch({ type: 'map/setIsFontLineThrough', isFontLineThrough: value }) 187 | }; 188 | 189 | return ( 190 |
191 |
192 | Font Size : 193 | 194 |
195 |
196 | Font Color : 197 | 198 |
199 |
200 | Bold : 201 | 202 |
203 |
204 | Italic : 205 | 206 |
207 |
208 | Line Through : 209 | 210 |
211 |
212 | ) 213 | } 214 | 215 | /** 216 | * @description the operator widget for editing topic shape style 217 | * */ 218 | renderShapeStyleEditWidgetArea() { 219 | const shapeClassSelectorProps = { 220 | options: optionsMap.shapeClass, 221 | value: this.state.shapeClass, 222 | onChange: (value) => this.props.dispatch({ type: 'map/setShapeClass', shapeClass: value }) 223 | }; 224 | 225 | const fillColorPickerProps = { 226 | value: this.state.fillColor, 227 | onChange: (value) => this.props.dispatch({ type: 'map/setFillColor', fillColor: value }) 228 | }; 229 | 230 | const borderWidthSelectorProps = { 231 | options: optionsMap.borderWidth, 232 | value: this.state.strokeWidth, 233 | onChange: (value) => this.props.dispatch({ type: 'map/setBorderWidth', borderWidth: value }) 234 | }; 235 | 236 | const borderColorPickerProps = { 237 | value: this.state.strokeColor, 238 | onChange: (value) => this.props.dispatch({ type: 'map/setBorderColor', borderColor: value }) 239 | }; 240 | 241 | return ( 242 |
243 |
244 | Shape Class : 245 | 246 |
247 |
248 | Fill Color : 249 | 250 |
251 |
252 | Border Width : 253 | 254 |
255 |
256 | Border Color : 257 | 258 |
259 |
260 | ) 261 | } 262 | 263 | renderLineStyleEditWidgetArea() { 264 | const lineClassSelectorProps = { 265 | options: optionsMap.lineClass, 266 | value: this.state.lineClass, 267 | onChange: (value) => this.props.dispatch({ type: 'map/setLineClass', lineClass: value }) 268 | }; 269 | 270 | const lineWidthSelectorProps = { 271 | options: optionsMap.lineWidth, 272 | value: this.state.lineWidth, 273 | onChange: (value) => this.props.dispatch({ type: 'map/setLineWidth', lineWidth: value }) 274 | }; 275 | 276 | const lineColorPickerProps = { 277 | value: this.state.lineColor, 278 | onChange: (value) => this.props.dispatch({ type: 'map/setLineColor', lineColor: value }) 279 | }; 280 | 281 | return ( 282 |
283 |
284 | Line Class : 285 | 286 |
287 |
288 | Line Width : 289 | 290 |
291 |
292 | Line Color : 293 | 294 |
295 |
296 | ) 297 | } 298 | 299 | render() { 300 | const panelProps = { 301 | className: 'edit-panel topic-edit-panel', 302 | }; 303 | 304 | return ( 305 |
306 | { this.renderTreeEditWidgetArea() } 307 |
308 | { this.renderFontStyleEditWidgetArea() } 309 |
310 | { this.renderShapeStyleEditWidgetArea() } 311 |
312 | { this.renderLineStyleEditWidgetArea() } 313 |
314 | ); 315 | } 316 | } 317 | 318 | const mapStateToProps = ({ map }) => { 319 | return { selectionList: map.selectionList }; 320 | }; 321 | 322 | export default connect(mapStateToProps)(TopicEditPanel); -------------------------------------------------------------------------------- /client/src/components/ui/OperationPanels/edit-panel-style.scss: -------------------------------------------------------------------------------- 1 | 2 | .edit-panel { 3 | width: 300px; 4 | height: 100%; 5 | border-left: 2px solid #29333e; 6 | box-shadow: -4px 1px 10px -2px #29333e; 7 | padding: 10px; 8 | } 9 | 10 | .sketch-color-picker { 11 | display: inline-flex; 12 | position: relative; 13 | } 14 | 15 | .color-picker-swatch { 16 | padding: 5px; 17 | background: #fff; 18 | border-radius: 1px; 19 | box-shadow: 0 0 0 1px rgba(0,0,0,.1); 20 | display: inline-block; 21 | cursor: pointer; 22 | } 23 | 24 | .color-picker-swatch div { 25 | width: 36px; 26 | height: 14px; 27 | border-radius: 2px; 28 | border: 1px solid #d1d1d1; 29 | } 30 | 31 | .color-picker-popover { 32 | position: absolute; 33 | z-index: 2; 34 | top: 27px; 35 | right: 0; 36 | } 37 | 38 | .color-picker-cover { 39 | position: fixed; 40 | top: 0; 41 | right: 0; 42 | bottom: 0; 43 | left: 0; 44 | } 45 | 46 | /* topic edit panel */ 47 | 48 | #onUpdateIsFontBold + label { 49 | font-weight: 700; 50 | } 51 | 52 | #onUpdateIsFontItalic + label { 53 | font-style: italic; 54 | } 55 | 56 | #onUpdateIsFontLineThrough + label { 57 | text-decoration: line-through; 58 | } 59 | 60 | 61 | /* widget type-func area */ 62 | .edit-panel .type-func { 63 | 64 | } 65 | 66 | /* panel's btn */ 67 | .edit-panel .ant-btn { 68 | width: 100%; 69 | display: block; 70 | margin: 10px auto; 71 | } 72 | 73 | /* panel's widget row */ 74 | .edit-panel .row-container { 75 | display: flex; 76 | align-items: center; 77 | justify-content: space-between; 78 | margin: 10px 0; 79 | } 80 | 81 | /* selector's width */ 82 | .row-container .ant-select { 83 | width: 64%; 84 | } 85 | 86 | /* panel's hr */ 87 | .edit-panel .hr { 88 | height: 0; 89 | border-top: 1px solid rgba(0, 0, 0, 0.65); 90 | margin: 10px 0; 91 | } -------------------------------------------------------------------------------- /client/src/components/ui/OperationPanels/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { connect } from 'dva' 3 | import SheetEditPanel from './SheetEditPanel'; 4 | import TopicEditPanel from './TopicEditPanel'; 5 | import './edit-panel-style.scss' 6 | 7 | interface OperatorPanelsProps { 8 | /** 9 | * @description 当前被选中的topic的id列表 / current selected topic's id list 10 | * */ 11 | selectionList: Array 12 | } 13 | 14 | interface OperatorPanelsState { 15 | /** 16 | * @description 是否展示面板 17 | * */ 18 | showPanel: boolean 19 | } 20 | 21 | class OperatorPanels extends React.PureComponent { 22 | 23 | constructor() { 24 | super(); 25 | 26 | // todo 添加侧边栏显示切换 27 | this.state = { 28 | showPanel: true 29 | } 30 | } 31 | 32 | render() { 33 | const selectionLen = this.props.selectionList.length; 34 | 35 | if (!selectionLen) return ; 36 | 37 | return ; 38 | } 39 | } 40 | 41 | const mapStateToProps = ({ map }) => { 42 | return { selectionList: map.selectionList }; 43 | }; 44 | 45 | export default connect(mapStateToProps)(OperatorPanels); -------------------------------------------------------------------------------- /client/src/components/ui/antd/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { SwatchesPicker } from 'react-color' 3 | 4 | import 'antd/lib/button/style/css' 5 | export const Button = require('antd/lib/button'); 6 | 7 | import 'antd/lib/dropdown/style/css' 8 | export const Dropdown = require('antd/lib/dropdown'); 9 | 10 | import 'antd/lib/menu/style/css' 11 | export const Menu = require('antd/lib/menu'); 12 | 13 | import 'antd/lib/icon/style/css' 14 | export const Icon = require('antd/lib/icon'); 15 | 16 | import 'antd/lib/select/style/css' 17 | export const Select = require('antd/lib/select'); 18 | 19 | import 'antd/lib/switch/style/css' 20 | export const Switch = require('antd/lib/switch'); 21 | 22 | import 'antd/lib/slider/style/css' 23 | export const Slider = require('antd/lib/slider'); 24 | 25 | import 'antd/lib/spin/style/css' 26 | export const Spin = require('antd/lib/spin'); 27 | 28 | interface SelectorProps { 29 | // the option list for selector 30 | options: { [index: string]: string } 31 | value: string 32 | style?: any 33 | onChange: (value: string) => any 34 | } 35 | 36 | // dropdown with button 37 | export class Selector extends React.Component { 38 | 39 | renderOptionList() { 40 | const Option = Select.Option; 41 | return Object.keys(this.props.options).map(optionValue => { 42 | return 43 | }) 44 | } 45 | 46 | render() { 47 | return ( 48 | 51 | ) 52 | } 53 | } 54 | 55 | interface colorPickerProps { 56 | value: string 57 | onChange: (colorHexValue: string) => any 58 | } 59 | 60 | export class ColorPicker extends React.Component { 61 | 62 | constructor() { 63 | super(); 64 | this.state = { 65 | displayColorPicker: false, 66 | color: '' 67 | } 68 | } 69 | 70 | onClickSwatch() { 71 | this.setState({ displayColorPicker: !this.state.displayColorPicker }); 72 | } 73 | 74 | onClosePicker() { 75 | this.setState({ displayColorPicker: false }); 76 | } 77 | 78 | onColorChange(color) { 79 | this.setState({color: color.hex}) 80 | } 81 | 82 | onColorChangeComplete(color) { 83 | this.props.onChange(color.hex); 84 | } 85 | 86 | render() { 87 | 88 | const swatchProps = { 89 | className: 'color-picker-swatch', 90 | onClick: () => this.onClickSwatch() 91 | }; 92 | 93 | let showColor; 94 | if (this.state.displayColorPicker) { 95 | showColor = this.state.color; 96 | } else { 97 | showColor = this.props.value; 98 | this.state.color = this.props.value; 99 | } 100 | 101 | const styleColor = { 102 | background: showColor 103 | }; 104 | 105 | const coverProps = { 106 | className: 'color-picker-cover', 107 | onClick: () => this.onClosePicker() 108 | }; 109 | 110 | const pickerProps = { 111 | color: showColor, 112 | onChange: (color) => this.onColorChange(color), 113 | onChangeComplete: (color) => this.onColorChangeComplete(color) 114 | }; 115 | 116 | return ( 117 |
118 |
119 |
120 |
121 | { this.state.displayColorPicker ? 122 |
123 |
124 | 125 |
: null 126 | } 127 |
128 | ) 129 | } 130 | } -------------------------------------------------------------------------------- /client/src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview client端配置信息 / settings for client file 3 | * */ 4 | 5 | // 获取环境变量信息 / get environment variable 6 | const { is_dev, is_prod } = process.env; 7 | 8 | export default { 9 | /** 10 | * @description socket服务器地址 / socket server location 11 | * */ 12 | get socketServer() { 13 | // 需要变动的时候配置这里 14 | const devServer = 'ws://localhost:3000'; 15 | const prodServer = 'ws://52.220.174.148:3000'; 16 | 17 | if (is_dev) return devServer; 18 | if (is_prod) return prodServer; 19 | }, 20 | } -------------------------------------------------------------------------------- /client/src/constants/EventTags.ts: -------------------------------------------------------------------------------- 1 | // events about topic 2 | export const TOPIC_SELECTED = 'TOPIC_SELECTED'; 3 | export const TOPIC_DESELECTED = 'TOPIC_DESELECTED'; 4 | 5 | export const PUSH_UNDO_STACK = 'PUSH_UNDO_STACK'; 6 | export const UNDO_OR_REDO_TRIGGERED = 'UNDO_OR_REDO_TRIGGERED'; -------------------------------------------------------------------------------- /client/src/constants/KeyCode.ts: -------------------------------------------------------------------------------- 1 | export const ENTER_KEY = 13; 2 | export const ESCAPE_KEY = 27; 3 | export const TAB_KEY = 9; 4 | export const DELETE_KEY = 8; 5 | export const Z_KEY = 90; -------------------------------------------------------------------------------- /client/src/constants/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Topic类型 / Topic Type 3 | * */ 4 | export const TopicType = { 5 | // 根节点 6 | ROOT: 'ROOT', 7 | // 主要节点 8 | MAIN: 'MAIN', 9 | // 子节点 10 | SUB: 'SUB' 11 | }; 12 | 13 | /** 14 | * @description Topic形状类型 Topic Shape Type 15 | * */ 16 | export const TopicShapeType = { 17 | // 矩形 18 | RECT: 'RECT', 19 | // 圆角矩形 20 | ROUNDED_RECT: 'ROUNDED_RECT', 21 | // 菱形 22 | PARALLELOGRAM: 'PARALLELOGRAM' 23 | }; 24 | 25 | /** 26 | * @description Topic描边宽度 Topic stroke width 27 | * */ 28 | export const TopicStrokeWidthType = { 29 | // 没有描边 30 | NONE: 'NONE', 31 | // 细的 32 | THIN: 1, 33 | // 中等的 34 | MIDDLE: 4, 35 | // 粗的 36 | BOLD: 8 37 | }; 38 | 39 | /** 40 | * @description 连接线形状类型 Line Type 41 | * */ 42 | export const LineType = { 43 | // 无连线 44 | NONE: 'NONE', 45 | // 直角线 46 | RIGHT_ANGLE: 'RIGHT_ANGLE', 47 | // 圆角线 48 | ROUNDED: 'ROUNDED' 49 | }; 50 | 51 | /** 52 | * @description 连接线的宽度 53 | * */ 54 | export const LineStrokeWidthType = TopicStrokeWidthType; 55 | 56 | /** 57 | * @description 布局类型 58 | * */ 59 | export const LayoutType = { 60 | // 向右逻辑图 61 | LOGIC_TO_RIGHT: 'LOGIC_TO_RIGHT' 62 | }; 63 | 64 | /** 65 | * @description info item 显示类型 66 | * */ 67 | export const InfoItemMode = { 68 | // 卡片类型 69 | CARD: 'CARD', 70 | // 图标类型 71 | ICON: 'ICON' 72 | }; 73 | 74 | /** 75 | * @description 本地存储信息的key名 / localStorage info's key name 76 | * */ 77 | export const LocalStorageKey = { 78 | // 缩放值 / scale value info 79 | SCALE_VALUE: 'SCALE_VALUE' 80 | }; 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /client/src/constants/defaultstyle.ts: -------------------------------------------------------------------------------- 1 | import { TopicType, TopicShapeType, LineType } from './common'; 2 | 3 | /** 4 | * @description topic默认样式 5 | * */ 6 | export const TopicStyle = { 7 | 8 | [TopicType.ROOT]: { 9 | fontSize: 24, 10 | fontColor: "#4c4c4c", 11 | isFontBold: true, 12 | shapeClass: TopicShapeType.ROUNDED_RECT, 13 | strokeWidth: 1, 14 | strokeColor: "#000000", 15 | lineClass: LineType.ROUNDED, 16 | lineWidth: 1, 17 | lineColor: "#7f7f7f", 18 | fillColor: "#cbdefd" 19 | }, 20 | 21 | [TopicType.MAIN]: { 22 | fontSize: 18, 23 | fontColor: "#4c4c4c", 24 | isFontBold: true, 25 | shapeClass: TopicShapeType.ROUNDED_RECT, 26 | strokeWidth: 1, 27 | strokeColor: "#000000", 28 | lineClass: LineType.RIGHT_ANGLE, 29 | lineWidth: 1, 30 | lineColor: "#7f7f7f", 31 | fillColor: "#fef4ec" 32 | }, 33 | 34 | [TopicType.SUB]: { 35 | fontSize: 16, 36 | fontColor: "#4c4c4c", 37 | isFontBold: true, 38 | shapeClass: TopicShapeType.ROUNDED_RECT, 39 | strokeWidth: 1, 40 | strokeColor: "#000000", 41 | lineClass: LineType.RIGHT_ANGLE, 42 | lineWidth: 1, 43 | lineColor: "#7f7f7f", 44 | fillColor: "#fef4ec" 45 | } 46 | }; 47 | 48 | /** 49 | * @description label默认样式 50 | * */ 51 | export const LabelStyle = { 52 | fontSize: 12, 53 | fillColor: '#edf9cc', 54 | padding: 5 55 | }; 56 | 57 | /** 58 | * @description 连接线默认样式 59 | * */ 60 | export const LineStyle = { 61 | stroke: '#7f7f7f' 62 | }; 63 | 64 | /** 65 | * @description topic文本输入框默认样式 66 | * */ 67 | export const TopicEditorStyle = { 68 | minWidth: 100, 69 | minHeight: 20 70 | }; 71 | 72 | /** 73 | * @description 选择框的颜色 74 | * */ 75 | export const SelectBoxColor = { 76 | HOVER: 'rgb(199, 217, 231)', 77 | SELECTED: 'rgb(75, 111, 189)' 78 | }; 79 | 80 | /** 81 | * @description topic选择框与topic之间的间距 82 | * */ 83 | export const selectBoxSpace = 5; 84 | 85 | /** 86 | * @description topic形状相关的一些特殊数据 87 | * */ 88 | export const TopicShapeSpecialData = { 89 | // 菱形的斜率 90 | parallelogramSlope: 2.5, 91 | // 圆角矩形的圆角半径 92 | roundedRectR: 5 93 | }; -------------------------------------------------------------------------------- /client/src/constants/distance.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview 设置组件的各种距离 3 | * */ 4 | 5 | import { TopicType, TopicShapeType, LayoutType } from './common'; 6 | 7 | const { RECT, ROUNDED_RECT, PARALLELOGRAM } = TopicShapeType; 8 | 9 | /** 10 | * @description Topic内补的倍率 11 | * */ 12 | export const TopicPaddingOverride = { 13 | [TopicType.ROOT]: { 14 | [RECT]: { 15 | paddingTop: 2 / 3, 16 | paddingLeft: 4 / 5 17 | }, 18 | [ROUNDED_RECT]: { 19 | paddingTop: 2 / 3, 20 | paddingLeft: 4 / 5 21 | }, 22 | [PARALLELOGRAM]: { 23 | paddingTop: 2 / 3, 24 | paddingLeft: 8 / 5 25 | } 26 | }, 27 | 28 | [TopicType.MAIN]: { 29 | [RECT]: { 30 | paddingTop: 2 / 5, 31 | paddingLeft: 2 / 3 32 | }, 33 | [ROUNDED_RECT]: { 34 | paddingTop: 2 / 5, 35 | paddingLeft: 2 / 3 36 | }, 37 | [PARALLELOGRAM]: { 38 | paddingTop: 2 / 5, 39 | paddingLeft: 4 / 3 40 | } 41 | }, 42 | 43 | [TopicType.SUB]: { 44 | [RECT]: { 45 | paddingTop: 1 / 2, 46 | paddingLeft: 2 / 3 47 | }, 48 | [ROUNDED_RECT]: { 49 | paddingTop: 1 / 2, 50 | paddingLeft: 2 / 3 51 | }, 52 | [PARALLELOGRAM]: { 53 | paddingTop: 1 / 2, 54 | paddingLeft: 4 / 3 55 | } 56 | } 57 | }; 58 | 59 | /** 60 | * @description Topic之间的外间距,与布局模式相关 61 | * */ 62 | export const TopicMargin = { 63 | [LayoutType.LOGIC_TO_RIGHT] : { 64 | marginTop: 10, 65 | marginLeft: 20 66 | } 67 | }; -------------------------------------------------------------------------------- /client/src/css/components/topic.css: -------------------------------------------------------------------------------- 1 | .topic { 2 | 3 | } 4 | 5 | .topic-default { 6 | 7 | } 8 | 9 | /* topic shape style */ 10 | .topic-group .topic-shape { 11 | fill: none; 12 | } 13 | 14 | /* topic text style */ 15 | .label { 16 | pointer-events: none; 17 | } 18 | 19 | .topic-group text, .label text { 20 | dominant-baseline : central; 21 | cursor : default; 22 | 23 | -moz-user-select: none; 24 | -webkit-user-select: none; 25 | -ms-user-select: none; 26 | user-select: none; 27 | 28 | pointer-events: none; 29 | } 30 | 31 | .label text { 32 | text-anchor : start; 33 | } -------------------------------------------------------------------------------- /client/src/css/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | font-family: "Lucida Grande", "Lucida Sans Unicode", Helvetica, Arial, Verdana, sans-serif; 4 | } 5 | 6 | body, #core-container { 7 | width: 100vw; 8 | height: 100vh; 9 | overflow: hidden; 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | #sheet { 15 | width: 100%; 16 | height: 100%; 17 | } 18 | 19 | text { 20 | cursor: default; 21 | } 22 | 23 | /* app tools */ 24 | #getTextSize, #editReceiver, #dragSelectBox { 25 | position: fixed; 26 | } 27 | 28 | #editReceiver { 29 | z-index: -1; 30 | } 31 | 32 | #dragSelectCover { 33 | position: absolute; 34 | left: 0; 35 | top: 0; 36 | width: 100vw; 37 | height: 100vh; 38 | display: none; 39 | } 40 | 41 | #dragSelectBox { 42 | position: absolute; 43 | border: 1px solid #01358f; 44 | background-color: #7f8da8; 45 | opacity: 0.4; 46 | } -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | import './apptools/KeyBind' 3 | import * as React from 'react' 4 | import SocketHandler from './socketHandler' 5 | import app from './app' 6 | 7 | import './css/main.css' 8 | import './css/components/topic.css' 9 | 10 | // init socket handle with callback 11 | new SocketHandler((storeData, wsInstance) => { 12 | app.start({ 13 | initialState: storeData, 14 | wrapperElem: '#app', 15 | wsInstance, 16 | }); 17 | }); -------------------------------------------------------------------------------- /client/src/interface/definefiles/dva/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace ReactRouter { 2 | export interface PlainRoute {} 3 | export interface EnterHook {} 4 | export interface LeaveHook {} 5 | export interface ParseQueryString {} 6 | export interface RedirectFunction {} 7 | export interface RouteHook {} 8 | export interface StringifyQuery {} 9 | export interface RouterListener {} 10 | export interface RouterState {} 11 | export interface HistoryBase {} 12 | export interface RouterOnContext {} 13 | export interface RouteProps {} 14 | export interface RouteComponentProps {} 15 | } 16 | 17 | declare namespace HistoryModule { 18 | export interface History {} 19 | } -------------------------------------------------------------------------------- /client/src/interface/definefiles/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// -------------------------------------------------------------------------------- /client/src/interface/definefiles/react-draggable/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace reactDraggable { 2 | 3 | interface DraggableBounds { 4 | left: number 5 | right: number 6 | top: number 7 | bottom:number 8 | } 9 | 10 | interface DraggableProps { 11 | axis?: string 12 | bounds?: DraggableBounds|string|boolean 13 | start?:{x:number,y:number} 14 | zIndex?:number 15 | } 16 | 17 | interface DraggableCoreProps { 18 | allowAnyClick: boolean 19 | disabled: boolean 20 | enableUserSelectHack: boolean 21 | grid: number[] 22 | handle: string 23 | cancel: string 24 | onStart: (event, ui) => boolean 25 | onDrag: (event, ui) => boolean 26 | onStop: (event, ui) => boolean 27 | onMouseDown: (event, ui) => boolean 28 | } 29 | 30 | export default class Draggable {} 31 | 32 | export class DraggableCore {} 33 | } 34 | 35 | declare module 'react-draggable' { 36 | export = reactDraggable 37 | } -------------------------------------------------------------------------------- /client/src/interface/index.ts: -------------------------------------------------------------------------------- 1 | export declare type appState = { 2 | /** 3 | * @description display mod of info item 4 | * */ 5 | infoItemDisplay: { 6 | label: 'card' | 'icon' 7 | } 8 | /** 9 | * @description 是否正在获取初始化state 10 | * */ 11 | receivingInitState: boolean 12 | } 13 | 14 | export declare type sheetState = { 15 | backgroundColor: string 16 | } 17 | 18 | export declare type mapState = { 19 | topicTree: topicInfo 20 | selectionList: Array 21 | mapStructure: string 22 | } 23 | 24 | /** 25 | * @description interface of topic tree info, it's also the map info in store data 26 | * */ 27 | export interface topicInfo { 28 | /** 29 | * @description topic's uuid 30 | * */ 31 | id: string 32 | 33 | /** 34 | * @description topic's title 35 | * @default "TOPIC" 36 | * */ 37 | title?: string 38 | 39 | /** 40 | * @description label's text 41 | * */ 42 | label?: string 43 | 44 | /** 45 | * @description topic's style 46 | * */ 47 | style?: { 48 | lineClass?: string 49 | 50 | shapeClass?: string 51 | 52 | textColor?: string 53 | 54 | fillColor?: string 55 | 56 | fontSize?: string 57 | 58 | fontColor?: string 59 | 60 | strokeWidth?: string 61 | 62 | lineWidth?: string 63 | 64 | lineColor?: string 65 | 66 | strokeColor?: string 67 | 68 | isFontBold?: boolean 69 | 70 | isFontItalic?: boolean 71 | 72 | isFontLineThrough?: boolean 73 | } 74 | 75 | /** 76 | * @description the collection of child topic 77 | * */ 78 | children?: Array 79 | } 80 | 81 | /** 82 | * @description interface of extended topic tree info, only exists in running time. extended in src/components/core/Map/index.tsx 83 | * */ 84 | export interface extendTopicInfo extends topicInfo { 85 | 86 | /** 87 | * @description the index of current topic in parent's children collection 88 | * */ 89 | index: number 90 | 91 | /** 92 | * @description the size of topic's title 93 | * */ 94 | titleAreaSize: { 95 | width: number 96 | height: number 97 | } 98 | 99 | /** 100 | * @description the size of topic self's svg box 101 | * */ 102 | boxSize: { 103 | width: number 104 | height: number 105 | } 106 | 107 | labelBoxSize: { 108 | width: number 109 | height: number 110 | } 111 | 112 | /** 113 | * @description the size of current topic with it's children 114 | * */ 115 | bounds: { 116 | width: number 117 | height: number 118 | } 119 | 120 | /** 121 | * @description 122 | * */ 123 | childrenBounds: { 124 | width: number 125 | height: number 126 | } 127 | 128 | /** 129 | * @description the position of current topic 130 | * */ 131 | position: [number, number] 132 | 133 | /** 134 | * @description parent's uuid 135 | * */ 136 | parentId: string 137 | 138 | /** 139 | * @description topic's type 140 | * */ 141 | type: string 142 | 143 | /** 144 | * @description a copy of origin topic info 145 | * */ 146 | originTopicInfo: topicInfo 147 | 148 | /** 149 | * @description the collection of child topic 150 | * */ 151 | children?: Array 152 | } -------------------------------------------------------------------------------- /client/src/layout/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview 布局入口函数 / layout entry function 3 | * */ 4 | import logicToRight from './logic/logictoright'; 5 | import * as Distance from '../constants/distance'; 6 | import { LayoutType } from '../constants/common'; 7 | import { extendTopicInfo } from 'src/interface' 8 | 9 | /** 10 | * @description structure name to layout function 11 | * */ 12 | const layoutFunctionMap = { 13 | [LayoutType.LOGIC_TO_RIGHT]: logicToRight 14 | }; 15 | 16 | /** 17 | * @description the root topic's default position 18 | * */ 19 | const rootPosition: [number, number] = [300, 300]; 20 | 21 | /** 22 | * @description calculate and assign the position of every single topic 23 | * @param topicTree 24 | * @param mapStructure 25 | * @return {extendTopicInfo} 26 | * */ 27 | export default (topicTree: extendTopicInfo, mapStructure: string = LayoutType.LOGIC_TO_RIGHT) => { 28 | // set root topic's position 29 | topicTree.position = rootPosition; 30 | 31 | layoutFunctionMap[mapStructure].startLayout(topicTree); 32 | } -------------------------------------------------------------------------------- /client/src/layout/logic/logictoright.ts: -------------------------------------------------------------------------------- 1 | import * as Distance from 'src/constants/distance'; 2 | import { LayoutType } from 'src/constants/common'; 3 | import { extendTopicInfo } from 'src/interface' 4 | 5 | const { LOGIC_TO_RIGHT } = LayoutType; 6 | 7 | class LogicToRight { 8 | 9 | public startLayout(topicTree: extendTopicInfo) { 10 | this.calcBounds(topicTree); 11 | this.calcPosition(topicTree); 12 | } 13 | 14 | private calcBounds(topicTree: extendTopicInfo) { 15 | const boxSize = topicTree.boxSize; 16 | 17 | const bounds = { 18 | width : boxSize.width, 19 | height : boxSize.height 20 | }; 21 | 22 | const childrenBounds = { width : 0, height : 0 }; 23 | 24 | const {marginLeft, marginTop} = Distance.TopicMargin[LOGIC_TO_RIGHT]; 25 | 26 | if (topicTree.children && topicTree.children.length) { 27 | topicTree.children.forEach((childTree) => { 28 | const childBounds = this.calcBounds(childTree); 29 | if (childBounds.width > childrenBounds.width) childrenBounds.width = childBounds.width; 30 | childrenBounds.height += childBounds.height + marginTop; 31 | }); 32 | 33 | childrenBounds.width += marginLeft; 34 | childrenBounds.height -= marginTop; 35 | } 36 | 37 | bounds.width += childrenBounds.width; 38 | if (childrenBounds.height > bounds.height) bounds.height = childrenBounds.height; 39 | 40 | topicTree.bounds = bounds; 41 | topicTree.childrenBounds = childrenBounds; 42 | topicTree.boxSize = boxSize; 43 | 44 | return bounds; 45 | } 46 | 47 | private calcPosition(topicTree: extendTopicInfo) { 48 | const { position, boxSize, children } = topicTree; 49 | 50 | const {marginLeft, marginTop} = Distance.TopicMargin[LayoutType.LOGIC_TO_RIGHT]; 51 | 52 | let topHeight = 0; 53 | 54 | const childrenMiddleHeight = topicTree.childrenBounds.height / 2; 55 | 56 | children && children.forEach((childTree) => { 57 | const childTreeBounds = childTree.bounds; 58 | const halfChildBoxWidth = childTree.boxSize.width / 2; 59 | 60 | const x = position[0] + boxSize.width / 2 + marginLeft + halfChildBoxWidth; 61 | 62 | let y = position[1] + topHeight + childTreeBounds.height / 2 - childrenMiddleHeight; 63 | 64 | topHeight += childTreeBounds.height + marginTop; 65 | 66 | // // fix label height 67 | // if (childTree.labelBoxSize && childTree.labelBoxSize.mode === 'card' && children.length !== 1) { 68 | // y -= childTree.labelBoxSize.height / 2; 69 | // } 70 | 71 | childTree.position = [x, y]; 72 | 73 | this.calcPosition(childTree); 74 | }); 75 | } 76 | } 77 | 78 | export default new LogicToRight() -------------------------------------------------------------------------------- /client/src/managers/index.ts: -------------------------------------------------------------------------------- 1 | import * as EventEmitter from 'events' 2 | import * as CommonFunc from '../apptools/commonfunc' 3 | import { extendTopicInfo } from 'src/interface' 4 | 5 | export const events = new EventEmitter(); 6 | 7 | export const selectionsManager = (() => { 8 | 9 | const selections = []; 10 | 11 | const getSelectionsArray = () => { 12 | return selections; 13 | }; 14 | 15 | // done 16 | const addSelection = (selection) => { 17 | 18 | if (selections.indexOf(selection) < 0) { 19 | selections.push(selection); 20 | } 21 | 22 | }; 23 | 24 | // done 25 | const selectSingle = (selection) => { 26 | clearSelection(); 27 | addSelection(selection); 28 | }; 29 | 30 | // done 31 | const clearSelection = () => { 32 | 33 | if (!selections.length) return false; 34 | 35 | selections.forEach((selection) => { 36 | selection.onDeselected(); 37 | }); 38 | 39 | selections.splice(0); 40 | }; 41 | 42 | const removeSelection = (selection) => { 43 | if ((selections).includes(selection)) { 44 | selections.splice(selections.indexOf(selection), 1); 45 | } 46 | }; 47 | 48 | 49 | return { getSelectionsArray, addSelection, selectSingle, clearSelection, removeSelection } 50 | })(); 51 | 52 | export const pasteInfoManager = (() => { 53 | let componentInfoToBePasted; 54 | 55 | const refreshInfo = (info) => { 56 | componentInfoToBePasted = CommonFunc.deepClone(info); 57 | }; 58 | 59 | const getInfo = () => { 60 | return CommonFunc.replaceInfoId(componentInfoToBePasted); 61 | }; 62 | 63 | const hasInfoStashed = () => { 64 | return !!componentInfoToBePasted; 65 | }; 66 | 67 | return {refreshInfo, getInfo, hasInfoStashed}; 68 | })(); 69 | 70 | export const componentMapManager = (() => { 71 | let sheetComponent; 72 | 73 | const map = {}; 74 | 75 | return { 76 | addComponent(id: string, component) { 77 | map[id] = component; 78 | }, 79 | 80 | removeComponent(id: string) { 81 | delete map[id]; 82 | }, 83 | 84 | getMap() { 85 | return map; 86 | }, 87 | 88 | get sheetComponent() { 89 | return sheetComponent; 90 | }, 91 | 92 | set sheetComponent(component) { 93 | if (sheetComponent) return; 94 | sheetComponent = component; 95 | } 96 | } 97 | })(); 98 | 99 | /** 100 | * @description topic id to extendedInfo map 101 | * */ 102 | export const topicExtendedInfoMap: { [index: string]: extendTopicInfo } = {}; -------------------------------------------------------------------------------- /client/src/models/app.ts: -------------------------------------------------------------------------------- 1 | import { appState } from 'src/interface' 2 | 3 | const appModel = { 4 | namespace: 'app', 5 | 6 | state: { 7 | // info item的显示方式 8 | infoItemDisplay: {}, 9 | // 正在获取init state数据 10 | receivingInitState: true 11 | }, 12 | 13 | reducers: { 14 | /** 15 | * @description 获取初始化数据成功 16 | * */ 17 | receiveInitStateSuccess(state: appState): appState { 18 | return { ...state, receivingInitState: false } 19 | } 20 | } 21 | }; 22 | 23 | export default appModel; -------------------------------------------------------------------------------- /client/src/models/map.ts: -------------------------------------------------------------------------------- 1 | import { deepClone, generateUUID } from 'src/apptools/commonfunc' 2 | import { topicExtendedInfoMap } from 'src/managers' 3 | import { TopicType, LayoutType } from 'src/constants/common'; 4 | import { mapState, topicInfo, extendTopicInfo } from 'src/interface' 5 | 6 | /** 7 | * @todo 8 | * 1:复制黏贴功能 9 | * 2:鼠标框选功能 10 | * 3:undo与redo功能 11 | * 4:socket共享功能 12 | * 5:共享编辑锁功能 13 | * */ 14 | 15 | class TopicTreeWalkHelper { 16 | 17 | /** 18 | * @param topicTreeToSearch the search area to find target topic 19 | * @param targetId search target's uuid 20 | * */ 21 | public findTopicInfoById(topicTreeToSearch: topicInfo, targetId: string): topicInfo { 22 | if (targetId === topicTreeToSearch.id) return topicTreeToSearch; 23 | 24 | if (topicTreeToSearch.children) { 25 | for (const childTopic of topicTreeToSearch.children) { 26 | const topicInfo = this.findTopicInfoById(childTopic, targetId); 27 | if (topicInfo) return topicInfo; 28 | } 29 | } 30 | } 31 | 32 | /** 33 | * @description filter selection array 34 | * */ 35 | public getSelectionsArrayWithoutChild(topicTreeInfo: topicInfo, selectionList: Array): Array { 36 | const isAAncestorOfB = this.getAncestorCheckMethod(topicTreeInfo, selectionList); 37 | 38 | return selectionList.filter((selectionB) => { 39 | return !selectionList.some((selectionA) => { 40 | return isAAncestorOfB(selectionA, selectionB); 41 | }) && selectionB.type !== TopicType.ROOT; 42 | }); 43 | } 44 | 45 | private getAncestorCheckMethod(topicTreeInfo: topicInfo, selectionList: Array) { 46 | const ancestorMap = {}; 47 | 48 | selectionList.forEach((selection) => { 49 | getSelectionsAncestorList(selection); 50 | }); 51 | 52 | return function (selectionA: topicInfo, selectionB: topicInfo) { 53 | return selectionA.id !== topicTreeInfo.id && ancestorMap[selectionB.id].includes(selectionA.id); 54 | }; 55 | 56 | function getSelectionsAncestorList(selection: topicInfo) { 57 | const targetId = selection.id; 58 | const targetList = ancestorMap[targetId] = []; 59 | 60 | if (targetId === topicTreeInfo.id) return; 61 | 62 | search(); 63 | 64 | function search(searchSource = topicTreeInfo) { 65 | if (!searchSource.children) return; 66 | 67 | for (const childTopic of searchSource.children) { 68 | if (childTopic.id === targetId) { 69 | targetList.push(searchSource.id); 70 | return true; 71 | } 72 | 73 | targetList.push(searchSource.id); 74 | 75 | if (search(childTopic)) { 76 | return true; 77 | } 78 | 79 | targetList.pop(); 80 | } 81 | } 82 | } 83 | } 84 | 85 | /** 86 | * @description update the topic info in selection list and topic tree 87 | * */ 88 | public updateEverySelectionSelfInfo(topicTreeInfo: topicInfo, selectionList: Array, process: (selectionInfo: topicInfo) => any) { 89 | const topicTreeCopy = deepClone(topicTreeInfo); 90 | const selectionListCopy = deepClone(selectionList); 91 | 92 | selectionListCopy.forEach(id => { 93 | const topicInfoInTree = this.findTopicInfoById(topicTreeCopy, id); 94 | topicInfoInTree.style = topicInfoInTree.style || {}; 95 | process(topicInfoInTree); 96 | }); 97 | 98 | return { topicTree: topicTreeCopy, selectionList: selectionListCopy } 99 | } 100 | 101 | public updateSingleSelectionSelfInfo(topicTreeInfo: topicInfo, selectionList: Array, process: (selectionInfo: topicInfo) => any) { 102 | const topicTreeCopy = deepClone(topicTreeInfo); 103 | const selectionListCopy = deepClone(selectionList); 104 | const targetTopicInTree = this.findTopicInfoById(topicTreeCopy, selectionListCopy[selectionListCopy.length - 1]); 105 | 106 | process(targetTopicInTree); 107 | 108 | return { topicTree: topicTreeCopy, selectionList: selectionListCopy } 109 | } 110 | } 111 | 112 | const topicTreeWalkHelper = new TopicTreeWalkHelper(); 113 | 114 | 115 | /** 116 | * @description reducers about selections manager 117 | */ 118 | const selectionsReducer = { 119 | 120 | /** 121 | * @description set only one topic selected 122 | * @param state 123 | * @param id selected topic's id 124 | */ 125 | setSingleSelection(state: mapState, { id }: { id: string }): mapState { 126 | return { ...state, selectionList: [id] } 127 | }, 128 | 129 | /** 130 | * @description add a new selection to list 131 | * @param state 132 | * @param id selected topic's id 133 | */ 134 | addSelectionToList(state: mapState, { id }: { id: string }): mapState { 135 | const selectionListCopy = deepClone(state.selectionList); 136 | // push that topic into selection list 137 | selectionListCopy.push(id); 138 | 139 | return { ...state, selectionList: selectionListCopy } 140 | }, 141 | 142 | /** 143 | * @description remove one topic from selectionList, triggered when ctrl + click on a selected topic 144 | */ 145 | removeSelectionFromList(state: mapState, { id }: { id: string }): mapState { 146 | const selectionListCopy = deepClone(state.selectionList); 147 | selectionListCopy.splice(selectionListCopy.indexOf(id), 1); 148 | 149 | return { ...state, selectionList: selectionListCopy } 150 | }, 151 | 152 | /** 153 | * @description clear selectionList, no topic was selected 154 | */ 155 | clearSelectionList(state: mapState): mapState { 156 | return { ...state, selectionList: [] } 157 | } 158 | }; 159 | 160 | /** 161 | * @description reducers about editing topic tree, such as adding child topic, or removing topic self 162 | * */ 163 | const topicTreeEditReducer = { 164 | 165 | /** 166 | * @description attach a new topic as current selection's child topic 167 | * */ 168 | addChildTopic(state: mapState): mapState { 169 | const { selectionList } = state; 170 | const topicTreeCopy = deepClone(state.topicTree); 171 | const targetTopicTree = topicTreeWalkHelper.findTopicInfoById(topicTreeCopy, selectionList[selectionList.length - 1]); 172 | 173 | // only add child topic for the latest selection topic 174 | targetTopicTree.children = targetTopicTree.children || []; 175 | const childLength = targetTopicTree.children.length; 176 | targetTopicTree.children.splice(childLength, 0, { id: generateUUID() }); 177 | 178 | return { ...state, topicTree: topicTreeCopy } 179 | }, 180 | 181 | /** 182 | * @description attach a new topic before selection's index 183 | * */ 184 | addTopicBefore(state: mapState): mapState { 185 | const { selectionList } = state; 186 | const topicTreeCopy = deepClone(state.topicTree); 187 | const targetTopicInfo = topicExtendedInfoMap[selectionList[selectionList.length - 1]]; 188 | const parentTopicTreeInfo = topicTreeWalkHelper.findTopicInfoById(topicTreeCopy, targetTopicInfo.parentId); 189 | 190 | // add a new topic into selection's left hand place 191 | parentTopicTreeInfo.children.splice(targetTopicInfo.index, 0, { id: generateUUID() }); 192 | 193 | return { ...state, topicTree: topicTreeCopy } 194 | }, 195 | 196 | /** 197 | * @description just like the method "addTopicBefore" 198 | * */ 199 | addTopicAfter(state: mapState): mapState { 200 | const { selectionList } = state; 201 | const topicTreeCopy = deepClone(state.topicTree); 202 | const targetTopicTreeInfo = topicExtendedInfoMap[selectionList[selectionList.length - 1]]; 203 | const parentTopicTreeInfo = topicTreeWalkHelper.findTopicInfoById(topicTreeCopy, targetTopicTreeInfo.parentId); 204 | 205 | // add a new topic into selection's right hand place 206 | parentTopicTreeInfo.children.splice(targetTopicTreeInfo.index + 1, 0, { id: generateUUID() }); 207 | 208 | return { ...state, topicTree: topicTreeCopy } 209 | }, 210 | 211 | /** 212 | * @description append a new topic as selection's parent 213 | * */ 214 | addParentTopic(state: mapState): mapState { 215 | const selectionListCopy = deepClone(state.selectionList); 216 | const targetTopicTreeInfo = topicExtendedInfoMap[selectionListCopy[selectionListCopy.length - 1]]; 217 | 218 | const topicTreeCopy = deepClone(state.topicTree); 219 | // find the selected topic's current parent 220 | const parentTopicTreeInfo = topicTreeWalkHelper.findTopicInfoById(topicTreeCopy, targetTopicTreeInfo.parentId); 221 | // remove the selection, has attach a new topic with a special child 222 | parentTopicTreeInfo.children.splice(targetTopicTreeInfo.index, 1, { 223 | id: generateUUID(), 224 | children: [targetTopicTreeInfo.originTopicInfo] 225 | }); 226 | 227 | return { ...state, topicTree: topicTreeCopy } 228 | }, 229 | 230 | 231 | /** 232 | * @description remove topic, all selected topic will be removed 233 | * */ 234 | removeTopic(state: mapState): mapState { 235 | const selectionListCopy = deepClone(state.selectionList); 236 | const topicTreeCopy = deepClone(state.topicTree); 237 | 238 | const selectionListWithoutChild = topicTreeWalkHelper 239 | .getSelectionsArrayWithoutChild(topicTreeCopy, selectionListCopy.map(id => topicExtendedInfoMap[id])); 240 | 241 | selectionListWithoutChild.forEach((selectionInfo) => { 242 | const selectionParent = topicTreeWalkHelper.findTopicInfoById(topicTreeCopy, selectionInfo.parentId); 243 | // remove topic from parent's child list 244 | selectionParent.children.splice(selectionInfo.index, 1); 245 | }); 246 | 247 | // clear selectionList 248 | return { ...state, topicTree: topicTreeCopy, selectionList: [] } 249 | } 250 | }; 251 | 252 | const topicTextEditReducer = { 253 | 254 | setTitle(state: mapState, { title }: { title: string }): mapState { 255 | return { 256 | ...state, 257 | ...topicTreeWalkHelper.updateSingleSelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 258 | selectionInfo.title = title; 259 | }) 260 | }; 261 | }, 262 | 263 | setFontSize(state: mapState, { fontSize }: { fontSize: string }): mapState { 264 | return { 265 | ...state, 266 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 267 | selectionInfo.style.fontSize = fontSize; 268 | }) 269 | }; 270 | }, 271 | 272 | setFontColor(state: mapState, { fontColor }: { fontColor: string }): mapState { 273 | return { 274 | ...state, 275 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 276 | selectionInfo.style.fontColor = fontColor; 277 | }) 278 | }; 279 | }, 280 | 281 | setIsFontBold(state: mapState, { isFontBold }: { isFontBold: boolean }): mapState { 282 | return { 283 | ...state, 284 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 285 | selectionInfo.style.isFontBold = isFontBold; 286 | }) 287 | }; 288 | }, 289 | 290 | setIsFontItalic(state: mapState, { isFontItalic }: { isFontItalic: boolean }): mapState { 291 | return { 292 | ...state, 293 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 294 | selectionInfo.style.isFontItalic = isFontItalic; 295 | }) 296 | }; 297 | }, 298 | 299 | setIsFontLineThrough(state: mapState, { isFontLineThrough }: { isFontLineThrough: boolean }): mapState { 300 | return { 301 | ...state, 302 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 303 | selectionInfo.style.isFontLineThrough = isFontLineThrough; 304 | }) 305 | }; 306 | }, 307 | }; 308 | 309 | const topicShapeStyleEditReducer = { 310 | setShapeClass(state: mapState, { shapeClass }: { shapeClass: string }): mapState { 311 | return { 312 | ...state, 313 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 314 | selectionInfo.style.shapeClass = shapeClass; 315 | }) 316 | }; 317 | }, 318 | 319 | setFillColor(state: mapState, { fillColor }: { fillColor: string }): mapState { 320 | return { 321 | ...state, 322 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 323 | selectionInfo.style.fillColor = fillColor; 324 | }) 325 | }; 326 | }, 327 | 328 | setBorderWidth(state: mapState, { borderWidth }: { borderWidth: string }): mapState { 329 | return { 330 | ...state, 331 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 332 | selectionInfo.style.strokeWidth = borderWidth; 333 | }) 334 | }; 335 | }, 336 | 337 | setBorderColor(state: mapState, { borderColor }: { borderColor: string }): mapState { 338 | return { 339 | ...state, 340 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 341 | selectionInfo.style.strokeColor = borderColor; 342 | }) 343 | }; 344 | } 345 | }; 346 | 347 | const connectLineStyleEditReducer = { 348 | setLineClass(state: mapState, { lineClass }: { lineClass: string }): mapState { 349 | return { 350 | ...state, 351 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 352 | selectionInfo.style.lineClass = lineClass; 353 | }) 354 | }; 355 | }, 356 | 357 | setLineWidth(state: mapState, { lineWidth }: { lineWidth: string }): mapState { 358 | return { 359 | ...state, 360 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 361 | selectionInfo.style.lineWidth = lineWidth; 362 | }) 363 | }; 364 | }, 365 | 366 | setLineColor(state: mapState, { lineColor }: { lineColor: string }): mapState { 367 | return { 368 | ...state, 369 | ...topicTreeWalkHelper.updateEverySelectionSelfInfo(state.topicTree, state.selectionList, (selectionInfo) => { 370 | selectionInfo.style.lineColor = lineColor; 371 | }) 372 | }; 373 | }, 374 | }; 375 | 376 | const mapModel = { 377 | namespace: 'map', 378 | 379 | state: { 380 | topicTree: {}, 381 | selectionList: [], 382 | mapStructure: LayoutType.LOGIC_TO_RIGHT 383 | }, 384 | 385 | reducers: Object.assign({}, 386 | selectionsReducer, topicTreeEditReducer, 387 | topicTextEditReducer, topicShapeStyleEditReducer, 388 | connectLineStyleEditReducer 389 | ) 390 | }; 391 | 392 | export default mapModel -------------------------------------------------------------------------------- /client/src/models/sheet.ts: -------------------------------------------------------------------------------- 1 | import { sheetState } from 'src/interface' 2 | 3 | const sheetModel = { 4 | namespace: 'sheet', 5 | 6 | state: { 7 | backgroundColor: '' 8 | }, 9 | 10 | reducers: { 11 | /** 12 | * @description update sheet's background color 13 | * */ 14 | setBackgroundColor(state: sheetState, { backgroundColor }: { backgroundColor: string }): sheetState { 15 | return { ...state, backgroundColor } 16 | } 17 | } 18 | }; 19 | 20 | export default sheetModel; -------------------------------------------------------------------------------- /client/src/socketHandler/index.ts: -------------------------------------------------------------------------------- 1 | import app from 'src/app' 2 | import { ServerEventTags } from 'root/share/eventtags' 3 | import config from 'src/config' 4 | 5 | class SocketHandler { 6 | 7 | private wsInstance: WebSocket; 8 | 9 | constructor(initCallback) { 10 | this.connectSocketServer(); 11 | this.registerSocketMessageHandler(initCallback); 12 | } 13 | 14 | private connectSocketServer() { 15 | this.wsInstance = new WebSocket(config.socketServer) 16 | } 17 | 18 | private registerSocketMessageHandler(initCallback: Function) { 19 | this.wsInstance.onmessage = (msg: MessageEvent) => { 20 | const parsedData = JSON.parse(msg.data); 21 | 22 | switch (parsedData.type) { 23 | case ServerEventTags.RECEIVE_STORE_DATA: { 24 | return initCallback(JSON.parse(parsedData.data), this.wsInstance); 25 | } 26 | 27 | case ServerEventTags.RECEIVE_BROADCAST_ACTION: { 28 | return app.dispatch(JSON.parse(parsedData.data)); 29 | } 30 | } 31 | }; 32 | 33 | this.wsInstance.onclose = () => { 34 | console.log('lost connection!'); 35 | }; 36 | } 37 | } 38 | 39 | export default SocketHandler -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-mindmap", 3 | "version": "0.0.1", 4 | "description": "a mind map application based on react", 5 | "scripts": { 6 | "dev": "run-p dev-client dev-server", 7 | "dev-client": "webpack --watch --env=develop", 8 | "dev-server": "webpack --config webpack.config.server.js --watch", 9 | "build": "run-p build-client build-server", 10 | "build-client": "webpack --env=production --optimize-minimize", 11 | "build-server": "webpack --config webpack.config.server.js", 12 | "start": "node server/dist/index.js" 13 | }, 14 | "author": "nana.morse", 15 | "license": "MIT", 16 | "dependencies": { 17 | "antd": "^2.8.3", 18 | "babel-plugin-lodash": "^3.2.11", 19 | "dva": "git://github.com/NanaMorse/dva.git#global-reducer-option", 20 | "koa": "^1.2.0", 21 | "koa-static": "^2.0.0", 22 | "react": "^15.3.1", 23 | "react-color": "^2.2.7", 24 | "react-dom": "^15.3.1", 25 | "react-draggable": "^2.2.2", 26 | "react-redux": "^4.4.5", 27 | "reactcss": "1.2.0", 28 | "redux": "^3.5.2", 29 | "ws": "^1.1.1" 30 | }, 31 | "devDependencies": { 32 | "@types/es6-shim": "^0.31.33", 33 | "@types/koa": "^2.0.39", 34 | "@types/koa-static": "^2.0.22", 35 | "@types/node": "^7.0.8", 36 | "@types/react": "^15.0.17", 37 | "@types/react-color": "^2.11.0", 38 | "@types/react-dom": "^0.14.23", 39 | "@types/react-redux": "^4.4.37", 40 | "@types/ws": "0.0.40", 41 | "babel-core": "^6.9.1", 42 | "babel-loader": "^6.4.1", 43 | "babel-polyfill": "^6.13.0", 44 | "babel-preset-es2015": "^6.9.0", 45 | "babel-preset-react": "^6.5.0", 46 | "babel-preset-stage-0": "^6.24.1", 47 | "babel-preset-stage-2": "^6.22.0", 48 | "css-loader": "^0.27.3", 49 | "node-sass": "^4.5.2", 50 | "npm-run-all": "^4.0.2", 51 | "sass-loader": "^6.0.3", 52 | "style-loader": "^0.16.1", 53 | "ts-loader": "^0.8.2", 54 | "typescript": "^2.0.10", 55 | "webpack": "^2.3.3", 56 | "webpack-node-externals": "^1.5.4", 57 | "yargs": "^7.1.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mind 6 | 7 | 8 |
9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa' 2 | import * as serve from 'koa-static' 3 | import { Server } from 'ws' 4 | import SocketHandler from './socketHandler' 5 | 6 | const app = new Koa(); 7 | 8 | app.use(serve('./public')); 9 | 10 | const wss = new Server({server: app.listen(3000)}); 11 | 12 | new SocketHandler(wss); 13 | 14 | console.log('application is running on port 3000'); -------------------------------------------------------------------------------- /server/src/socketHandler/index.ts: -------------------------------------------------------------------------------- 1 | import * as WebSocket from 'ws' 2 | import * as fs from 'fs' 3 | import { ServerEventTags, ClientEventTags } from 'root/share/eventtags' 4 | 5 | const storeFilePath = './storedata.json'; 6 | 7 | class SocketHandler { 8 | 9 | /** 10 | * @description 启动的ws server 11 | * */ 12 | private webSocketServer: WebSocket.Server; 13 | 14 | constructor(webSocketServer) { 15 | this.webSocketServer = webSocketServer; 16 | webSocketServer.on('connection', (ws) => this.onClientConnect(ws)) 17 | } 18 | 19 | /** 20 | * @description 当有客户端连接 / while a new client connect 21 | * @param ws 22 | * */ 23 | private onClientConnect(ws: WebSocket) { 24 | console.log('client connect!'); 25 | this.sendFullStoreDataToClient(ws); 26 | ws.on('message', (message) => this.onClientSendMessageToServer(message, ws)) 27 | } 28 | 29 | /** 30 | * @description 向客户端发送完整store数据 31 | * */ 32 | private sendFullStoreDataToClient(ws: WebSocket) { 33 | const storeData = fs.readFileSync(storeFilePath, 'utf-8'); 34 | ws.send(JSON.stringify({ type: ServerEventTags.RECEIVE_STORE_DATA, data: storeData })) 35 | } 36 | 37 | /** 38 | * @description 监听client端发送的数据 39 | * */ 40 | private onClientSendMessageToServer(message, ws: WebSocket) { 41 | const { type, data } = JSON.parse(message); 42 | switch (type) { 43 | case ClientEventTags.SYNC_STORE: { 44 | return this.saveStoreDataToServer(data); 45 | } 46 | case ClientEventTags.SYNC_ACTION: { 47 | return this.broadcastActionToOtherClient(data, ws); 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * @description 将客户端同步的store数据写至本地 54 | * @todo 考虑使用server端reducer来更新server端的store 55 | * */ 56 | private saveStoreDataToServer(storeData) { 57 | storeData = JSON.parse(storeData); 58 | delete storeData.routing; 59 | delete storeData['@@dva']; 60 | storeData = JSON.stringify(storeData); 61 | 62 | fs.writeFile(storeFilePath, storeData, function (err) { 63 | if (err) throw err; 64 | console.log('write ok!'); 65 | }); 66 | } 67 | 68 | /** 69 | * @description 将action发送给其他的客户端 70 | * */ 71 | private broadcastActionToOtherClient(data, ws: WebSocket) { 72 | const parsedAction = JSON.parse(data); 73 | // set broadcast filter 74 | parsedAction['_isBroadcast'] = true; 75 | 76 | this.webSocketServer.clients.forEach((client) => { 77 | if (client !== ws) client.send(JSON.stringify({ 78 | type: ServerEventTags.RECEIVE_BROADCAST_ACTION, 79 | data: JSON.stringify(parsedAction) 80 | })); 81 | }); 82 | } 83 | } 84 | 85 | export default SocketHandler -------------------------------------------------------------------------------- /share/eventtags.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview 客户端与服务端共用的事件tag名 / the common event tags for both client and server 3 | * 希望能找到client端与server端共用文件编译的最佳实践 4 | * */ 5 | 6 | /** 7 | * @description 服务端事件Tag / Server side event tags 8 | * */ 9 | export const ServerEventTags = { 10 | // 获取store数据 11 | RECEIVE_STORE_DATA: 'RECEIVE_STORE_DATA', 12 | // 获取ACTION拘束 13 | RECEIVE_BROADCAST_ACTION: 'RECEIVE_BROADCAST_ACTION' 14 | }; 15 | 16 | /** 17 | * @description 客户端事件Tag / Client side event tags 18 | * */ 19 | export const ClientEventTags = { 20 | // 同步store信息 21 | SYNC_STORE: 'SYNC_STORE', 22 | // 同步action信息 23 | SYNC_ACTION: 'SYNC_ACTION' 24 | }; -------------------------------------------------------------------------------- /storedata.json: -------------------------------------------------------------------------------- 1 | {"app":{"infoItemDisplay":{"label":"icon"}},"map":{"topicTree":{"id":"300af847dec8435975135bc737","title":"Root Topic","children":[{"id":"960a6c8bd9697dc4502188d0e3","title":"Main Topic 1","children":[{"id":"b02ae01315594db3d43b7a7870","title":"Sub Topic 1.1"},{"id":"125a4257fcf9102de7417ad635","title":"Sub Topic 1.2"}]},{"id":"b7d8dd75d7b89e70bb710bf22f","title":"Main Topic 2"},{"id":"e49af565c719edd4675329637e","title":"Main Topic 3"}],"style":{"lineClass":"ROUNDED"}},"selectionList":[]},"sheet":{"backgroundColor":"#ffffff"}} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "./public/dist/", 5 | "sourceMap": true, 6 | "noImplicitAny": false, 7 | "module": "commonjs", 8 | "target": "es5", 9 | "jsx": "react", 10 | "types": ["node"], 11 | "lib": ["es2015", "es2017", "dom"], 12 | "paths": { 13 | "src/*": ["./client/src/*"], 14 | "root/*": ["./*"] 15 | } 16 | }, 17 | "exclude": [ 18 | "./node_modules" 19 | ] 20 | } -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outFile": "./server/dist/bundle.js", 5 | "module": "system", 6 | "target": "es5", 7 | "sourceMap": false, 8 | "noImplicitAny": false, 9 | "paths": { 10 | "src/*": ["./server/src/*"], 11 | "root/*": ["./*"] 12 | } 13 | }, 14 | "include": [ 15 | "server/src" 16 | ], 17 | "exclude": [ 18 | "node_modules", 19 | "share" 20 | ] 21 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const yargs = require('yargs'); 4 | const fs = require('fs'); 5 | 6 | // 获取脚本参数 / get script parameter 7 | const { env } = yargs.argv; 8 | const isEnvDevelop = env === 'develop'; 9 | const isEnvProduction = env === 'production'; 10 | 11 | console.log(env, isEnvDevelop, isEnvProduction); 12 | 13 | if (isEnvProduction) { 14 | // 删除source-map数据 / remove source map data 15 | fs.unlink(path.join(__dirname, './public/dist/bundle.js.map'), (err) => { 16 | if (err) console.error(err) 17 | }) 18 | } 19 | 20 | module.exports = { 21 | 22 | entry: './client/src/index.tsx', 23 | 24 | output: { 25 | path : path.join(__dirname, '/public/dist'), 26 | filename : 'bundle.js' 27 | }, 28 | 29 | // Enable sourcemaps for debugging webpack's output. 30 | devtool: isEnvDevelop ? "source-map" : '', 31 | 32 | resolve: { 33 | // Add '.ts' and '.tsx' as resolvable extensions. 34 | extensions: [".ts", ".tsx", ".js"], 35 | 36 | alias: { 37 | src: path.join(__dirname, '/client/src'), 38 | root: path.join(__dirname, '/') 39 | }, 40 | }, 41 | 42 | module: { 43 | loaders: [ 44 | { 45 | test: /\.js$/, 46 | loader: 'babel-loader', 47 | exclude: /node_modules\/(?!(dva)\/).*/ 48 | }, 49 | 50 | // All files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'. 51 | { 52 | test: /\.ts(x?)$/, 53 | loader: 'ts-loader', 54 | exclude: /node_modules/ 55 | }, 56 | 57 | { 58 | test: /\.(s?)css$/, 59 | use: [{ 60 | loader: "style-loader" 61 | }, { 62 | loader: "css-loader", 63 | }, { 64 | loader: "sass-loader", 65 | }] 66 | } 67 | ] 68 | }, 69 | 70 | plugins: [ 71 | new webpack.DefinePlugin({ 72 | 'process.env': { 73 | 'NODE_ENV': JSON.stringify(env), 74 | 'is_dev': isEnvDevelop, 75 | 'is_prod': isEnvProduction 76 | } 77 | }) 78 | ], 79 | 80 | externals: { 81 | "react": "React", 82 | "react-dom": "ReactDOM", 83 | "redux": "Redux" 84 | } 85 | }; -------------------------------------------------------------------------------- /webpack.config.server.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const nodeExternals = require('webpack-node-externals'); 5 | 6 | module.exports = { 7 | entry: './server/src/index.ts', 8 | output: { 9 | path : path.join(__dirname, '/server/dist'), 10 | filename : 'index.js' 11 | }, 12 | 13 | resolve: { 14 | extensions: [".ts", ".js"], 15 | 16 | alias: { 17 | src: path.join(__dirname, '/server/src'), 18 | root: path.join(__dirname, '/') 19 | }, 20 | }, 21 | 22 | target: 'node', 23 | 24 | module: { 25 | loaders: [ 26 | // All files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'. 27 | { 28 | test: /\.ts(x?)$/, 29 | loader: 'ts-loader', 30 | exclude: /node_modules/ 31 | }, 32 | 33 | { 34 | test: /\.js$/, 35 | loader: 'babel-loader', 36 | exclude: /node_modules\/(?!(dva)\/).*/ 37 | } 38 | ] 39 | }, 40 | 41 | externals: [nodeExternals()], 42 | }; --------------------------------------------------------------------------------