diff --git a/simple-mind-map/bin/wsServer.mjs b/simple-mind-map/bin/wsServer.mjs new file mode 100644 index 00000000..23efb3a7 --- /dev/null +++ b/simple-mind-map/bin/wsServer.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +import ws from 'ws' +import http from 'http' +import * as map from 'lib0/map' + +const wsReadyStateConnecting = 0 +const wsReadyStateOpen = 1 +const wsReadyStateClosing = 2 // eslint-disable-line +const wsReadyStateClosed = 3 // eslint-disable-line + +const pingTimeout = 30000 + +const port = process.env.PORT || 4444 +// @ts-ignore +const wss = new ws.Server({ noServer: true }) + +const server = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('okay') +}) + +/** + * Map froms topic-name to set of subscribed clients. + * @type {Map>} + */ +const topics = new Map() + +/** + * @param {any} conn + * @param {object} message + */ +const send = (conn, message) => { + if ( + conn.readyState !== wsReadyStateConnecting && + conn.readyState !== wsReadyStateOpen + ) { + conn.close() + } + try { + conn.send(JSON.stringify(message)) + } catch (e) { + conn.close() + } +} + +/** + * Setup a new client + * @param {any} conn + */ +const onconnection = conn => { + /** + * @type {Set} + */ + const subscribedTopics = new Set() + let closed = false + // Check if connection is still alive + let pongReceived = true + const pingInterval = setInterval(() => { + if (!pongReceived) { + conn.close() + clearInterval(pingInterval) + } else { + pongReceived = false + try { + conn.ping() + } catch (e) { + conn.close() + } + } + }, pingTimeout) + conn.on('pong', () => { + pongReceived = true + }) + conn.on('close', () => { + subscribedTopics.forEach(topicName => { + const subs = topics.get(topicName) || new Set() + subs.delete(conn) + if (subs.size === 0) { + topics.delete(topicName) + } + }) + subscribedTopics.clear() + closed = true + }) + conn.on( + 'message', + /** @param {object} message */ message => { + if (typeof message === 'string') { + message = JSON.parse(message) + } + if (message && message.type && !closed) { + switch (message.type) { + case 'subscribe': + /** @type {Array} */ ;(message.topics || []).forEach( + topicName => { + if (typeof topicName === 'string') { + // add conn to topic + const topic = map.setIfUndefined( + topics, + topicName, + () => new Set() + ) + topic.add(conn) + // add topic to conn + subscribedTopics.add(topicName) + } + } + ) + break + case 'unsubscribe': + /** @type {Array} */ ;(message.topics || []).forEach( + topicName => { + const subs = topics.get(topicName) + if (subs) { + subs.delete(conn) + } + } + ) + break + case 'publish': + if (message.topic) { + const receivers = topics.get(message.topic) + if (receivers) { + message.clients = receivers.size + receivers.forEach(receiver => send(receiver, message)) + } + } + break + case 'ping': + send(conn, { type: 'pong' }) + } + } + } + ) +} +wss.on('connection', onconnection) + +server.on('upgrade', (request, socket, head) => { + // You may check auth of request here.. + /** + * @param {any} ws + */ + const handleAuth = ws => { + wss.emit('connection', ws, request) + } + wss.handleUpgrade(request, socket, head, handleAuth) +}) + +server.listen(port) + +console.log('Signaling server running on localhost:', port) diff --git a/simple-mind-map/index.js b/simple-mind-map/index.js index 031571cc..6dc77fd2 100644 --- a/simple-mind-map/index.js +++ b/simple-mind-map/index.js @@ -258,15 +258,13 @@ class MindMap { // 动态设置思维导图数据,纯节点数据 setData(data) { + data = simpleDeepClone(data || {}) this.execCommand('CLEAR_ACTIVE_NODE') this.command.clearHistory() this.command.addHistory() - if (this.richText) { - this.renderer.renderTree = this.richText.handleSetData(data) - } else { - this.renderer.renderTree = data - } + this.renderer.setData(data) this.reRender(() => {}, CONSTANTS.SET_DATA) + this.emit('set_data', data) } // 动态设置思维导图数据,包括节点数据、布局、主题、视图 diff --git a/simple-mind-map/package-lock.json b/simple-mind-map/package-lock.json index 5efac16e..f3e0ec0b 100644 --- a/simple-mind-map/package-lock.json +++ b/simple-mind-map/package-lock.json @@ -18,7 +18,9 @@ "quill": "^1.3.6", "tern": "^0.24.3", "uuid": "^9.0.0", - "xml-js": "^1.6.11" + "xml-js": "^1.6.11", + "y-webrtc": "^10.2.5", + "yjs": "^13.6.8" }, "devDependencies": { "eslint": "^8.25.0", @@ -290,6 +292,25 @@ "node": ">= 0.6.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -310,6 +331,29 @@ "node": ">= 0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -574,6 +618,11 @@ "node": ">=0.6" } }, + "node_modules/err-code": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", + "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==" + }, "node_modules/errno": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", @@ -873,6 +922,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-browser-rtc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz", + "integrity": "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==" + }, "node_modules/get-intrinsic": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", @@ -1012,6 +1066,25 @@ "node": ">=8.0.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -1150,6 +1223,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/js-sdsl": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", @@ -1248,6 +1330,25 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.86", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.86.tgz", + "integrity": "sha512-kxigQTM4Q7NwJkEgdqQvU21qiR37twcqqLmh+/SbiGbRLfPlLVbHyY9sWp7PwXh0Xus9ELDSjsUOwcrdt5yZ4w==", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -1960,7 +2061,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -2016,6 +2116,14 @@ "performance-now": "^2.1.0" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", @@ -2176,6 +2284,47 @@ "node": ">=8" } }, + "node_modules/simple-peer": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.11.1.tgz", + "integrity": "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "buffer": "^6.0.3", + "debug": "^4.3.2", + "err-code": "^3.0.1", + "get-browser-rtc": "^1.1.0", + "queue-microtask": "^1.2.3", + "randombytes": "^2.1.0", + "readable-stream": "^3.6.0" + } + }, + "node_modules/simple-peer/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/stackblur-canvas": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.5.0.tgz", @@ -2410,6 +2559,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "optional": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-js": { "version": "1.6.11", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", @@ -2421,6 +2591,65 @@ "xml-js": "bin/cli.js" } }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-webrtc": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.2.5.tgz", + "integrity": "sha512-ZyBNvTI5L28sQ2PQI0T/JvyWgvuTq05L21vGkIlcvNLNSJqAaLCBJRe3FHEqXoaogqWmRcEAKGfII4ErNXMnNw==", + "dependencies": { + "lib0": "^0.2.42", + "simple-peer": "^9.11.0", + "y-protocols": "^1.0.5" + }, + "bin": { + "y-webrtc-signaling": "bin/server.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "optionalDependencies": { + "ws": "^7.2.0" + } + }, + "node_modules/yjs": { + "version": "13.6.8", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", + "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -2628,6 +2857,11 @@ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "optional": true }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2642,6 +2876,15 @@ "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==" }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -2834,6 +3077,11 @@ "tapable": "^0.2.3" } }, + "err-code": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", + "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==" + }, "errno": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", @@ -3066,6 +3314,11 @@ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==" }, + "get-browser-rtc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz", + "integrity": "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==" + }, "get-intrinsic": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", @@ -3163,6 +3416,11 @@ "text-segmentation": "^1.0.3" } }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, "ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -3262,6 +3520,11 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==" + }, "js-sdsl": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", @@ -3338,6 +3601,14 @@ "type-check": "~0.4.0" } }, + "lib0": { + "version": "0.2.86", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.86.tgz", + "integrity": "sha512-kxigQTM4Q7NwJkEgdqQvU21qiR37twcqqLmh+/SbiGbRLfPlLVbHyY9sWp7PwXh0Xus9ELDSjsUOwcrdt5yZ4w==", + "requires": { + "isomorphic.js": "^0.2.4" + } + }, "lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -3765,8 +4036,7 @@ "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, "quill": { "version": "1.3.6", @@ -3807,6 +4077,14 @@ "performance-now": "^2.1.0" } }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "requires": { + "safe-buffer": "^5.1.0" + } + }, "readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", @@ -3916,6 +4194,32 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "simple-peer": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.11.1.tgz", + "integrity": "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==", + "requires": { + "buffer": "^6.0.3", + "debug": "^4.3.2", + "err-code": "^3.0.1", + "get-browser-rtc": "^1.1.0", + "queue-microtask": "^1.2.3", + "randombytes": "^2.1.0", + "readable-stream": "^3.6.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "stackblur-canvas": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.5.0.tgz", @@ -4088,6 +4392,13 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "optional": true, + "requires": {} + }, "xml-js": { "version": "1.6.11", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", @@ -4096,6 +4407,33 @@ "sax": "^1.2.4" } }, + "y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "requires": { + "lib0": "^0.2.85" + } + }, + "y-webrtc": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.2.5.tgz", + "integrity": "sha512-ZyBNvTI5L28sQ2PQI0T/JvyWgvuTq05L21vGkIlcvNLNSJqAaLCBJRe3FHEqXoaogqWmRcEAKGfII4ErNXMnNw==", + "requires": { + "lib0": "^0.2.42", + "simple-peer": "^9.11.0", + "ws": "^7.2.0", + "y-protocols": "^1.0.5" + } + }, + "yjs": { + "version": "13.6.8", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", + "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "requires": { + "lib0": "^0.2.74" + } + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/simple-mind-map/package.json b/simple-mind-map/package.json index 506c5f20..5a22c55f 100644 --- a/simple-mind-map/package.json +++ b/simple-mind-map/package.json @@ -22,7 +22,8 @@ "scripts": { "lint": "eslint src/", "format": "prettier --write .", - "types": "npx -p typescript tsc index.js --declaration --allowJs --emitDeclarationOnly --outDir types --target es2017" + "types": "npx -p typescript tsc index.js --declaration --allowJs --emitDeclarationOnly --outDir types --target es2017", + "wsServe": "node ./bin/server.mjs" }, "module": "index.js", "main": "./dist/simpleMindMap.umd.min.js", @@ -37,7 +38,9 @@ "quill": "^1.3.6", "tern": "^0.24.3", "uuid": "^9.0.0", - "xml-js": "^1.6.11" + "xml-js": "^1.6.11", + "y-webrtc": "^10.2.5", + "yjs": "^13.6.8" }, "keywords": [ "javascript", diff --git a/simple-mind-map/src/constants/defaultOptions.js b/simple-mind-map/src/constants/defaultOptions.js index 6a4ad674..d7cc00fb 100644 --- a/simple-mind-map/src/constants/defaultOptions.js +++ b/simple-mind-map/src/constants/defaultOptions.js @@ -204,5 +204,10 @@ export const defaultOpt = { }, // 自定义标签的颜色 // {pass: 'green, unpass: 'red'} - tagsColorMap: {} + tagsColorMap: {}, + // 节点协作样式配置 + cooperateStyle: { + avatarSize: 22,// 头像大小 + fontSize: 12,// 如果是文字头像,那么文字的大小 + } } diff --git a/simple-mind-map/src/core/render/Render.js b/simple-mind-map/src/core/render/Render.js index 5f53134e..d0fa7ea6 100644 --- a/simple-mind-map/src/core/render/Render.js +++ b/simple-mind-map/src/core/render/Render.js @@ -97,6 +97,15 @@ class Render { )(this, this.mindMap.opt.layout) } + // 重新设置思维导图数据 + setData(data) { + if (this.mindMap.richText) { + this.renderTree = this.mindMap.richText.handleSetData(data) + } else { + this.renderTree = data + } + } + // 绑定事件 bindEvent() { // 点击事件 diff --git a/simple-mind-map/src/core/render/node/Node.js b/simple-mind-map/src/core/render/node/Node.js index d287755b..9df52019 100644 --- a/simple-mind-map/src/core/render/node/Node.js +++ b/simple-mind-map/src/core/render/node/Node.js @@ -6,6 +6,7 @@ import nodeExpandBtnMethods from './nodeExpandBtn' import nodeCommandWrapsMethods from './nodeCommandWraps' import nodeCreateContentsMethods from './nodeCreateContents' import nodeExpandBtnPlaceholderRectMethods from './nodeExpandBtnPlaceholderRect' +import nodeCooperateMethods from './nodeCooperate' import { CONSTANTS } from '../../../constants/constant' // 节点类 @@ -55,6 +56,8 @@ class Node { this.parent = opt.parent || null // 子节点 this.children = opt.children || [] + // 当前同时操作该节点的用户列表 + this.userList = [] // 节点内容的容器 this.group = null this.shapeNode = null // 节点形状节点 @@ -74,6 +77,7 @@ class Node { this._openExpandNode = null this._closeExpandNode = null this._fillExpandNode = null + this._userListGroup = null this._lines = [] this._generalizationLine = null this._generalizationNode = null @@ -121,6 +125,12 @@ class Node { Object.keys(nodeCreateContentsMethods).forEach(item => { this[item] = nodeCreateContentsMethods[item].bind(this) }) + // 协同相关 + if (this.mindMap.cooperate) { + Object.keys(nodeCooperateMethods).forEach((item) => { + this[item] = nodeCooperateMethods[item].bind(this) + }) + } // 初始化 this.getSize() } @@ -283,6 +293,8 @@ class Node { this.group.add(this.shapeNode) // 渲染一个隐藏的矩形区域,用来触发展开收起按钮的显示 this.renderExpandBtnPlaceholderRect() + // 创建协同头像节点 + if (this.createUserListNode) this.createUserListNode() // 概要节点添加一个带所属节点id的类名 if (this.isGeneralization && this.generalizationBelongNode) { this.group.addClass('generalization_' + this.generalizationBelongNode.uid) @@ -527,6 +539,8 @@ class Node { } // 更新概要 this.renderGeneralization() + // 更新协同头像 + if (this.updateUserListNode) this.updateUserListNode() // 更新节点位置 let t = this.group.transform() // // 如果上次不在可视区内,且本次也不在,那么直接返回 diff --git a/simple-mind-map/src/core/render/node/nodeCooperate.js b/simple-mind-map/src/core/render/node/nodeCooperate.js new file mode 100644 index 00000000..970dfb2c --- /dev/null +++ b/simple-mind-map/src/core/render/node/nodeCooperate.js @@ -0,0 +1,104 @@ +import { Circle, G, Text, Image } from '@svgdotjs/svg.js' +import { generateColorByContent } from '../../../utils/index' + +// 协同相关功能 + +// 创建容器 +function createUserListNode() { + // 如果没有注册协作插件,那么需要创建 + if (!this.mindMap.cooperate) return + this._userListGroup = new G() + this.group.add(this._userListGroup) +} + +// 创建文本头像 +function createTextAvatar(item) { + const { avatarSize, fontSize } = this.mindMap.opt.cooperateStyle + const g = new G() + const str = item.isMore ? item.name : String(item.name)[0] + // 圆 + const circle = new Circle().size(avatarSize, avatarSize) + circle.fill({ + color: item.color || generateColorByContent(str) + }) + // 文本 + const text = new Text() + .text(str) + .fill({ + color: '#fff' + }) + .css({ + 'font-size': fontSize + }) + .dx(-fontSize / 2) + .dy((avatarSize - fontSize) / 2) + g.add(circle).add(text) + return g +} + +// 创建图片头像 +function createImageAvatar(item) { + const { avatarSize } = this.mindMap.opt.cooperateStyle + return new Image().load(item.avatar).size(avatarSize, avatarSize) +} + +// 更新渲染 +function updateUserListNode() { + if (!this._userListGroup) return + const { avatarSize } = this.mindMap.opt.cooperateStyle + this._userListGroup.clear() + // 根据当前节点长度计算最多能显示几个 + const length = this.userList.length + const maxShowCount = Math.floor(this.width / avatarSize) + const list = [] + if (length > maxShowCount) { + // 如果当前用户数量比最多能显示的多,最后需要显示一个提示信息 + list.push(...this.userList.slice(0, maxShowCount - 1), { + isMore: true, + name: '+' + (length - maxShowCount + 1) + }) + } else { + list.push(...this.userList) + } + list.forEach((item, index) => { + let node = null + if (item.avatar) { + node = this.createImageAvatar(item) + } else { + node = this.createTextAvatar(item) + } + node.x(index * avatarSize).cy(-avatarSize / 2) + this._userListGroup.add(node) + }) +} + +// 添加用户 +function addUser(userInfo) { + if ( + this.userList.find(item => { + return item.id == userInfo.id + }) + ) + return + this.userList.push(userInfo) + this.updateUserListNode() +} + +// 移除用户 +function removeUser(userInfo) { + const index = this.userList.findIndex(item => { + return item.id == userInfo.id + }) + if (index === -1) return + this.userList.splice(index, 1) + this.updateUserListNode() +} + +export default { + createUserListNode, + updateUserListNode, + createTextAvatar, + createImageAvatar, + addUser, + removeUser +} diff --git a/simple-mind-map/src/layouts/Base.js b/simple-mind-map/src/layouts/Base.js index 6199059f..a446acfb 100644 --- a/simple-mind-map/src/layouts/Base.js +++ b/simple-mind-map/src/layouts/Base.js @@ -129,6 +129,10 @@ class Base { this.renderer.addActiveNode(newNode) } } + // 如果当前节点在激活节点列表里,那么添加上激活的状态 + if (this.mindMap.renderer.findActiveNodeIndex(newNode) !== -1) { + newNode.nodeData.data.isActive = true + } // 根节点 if (isRoot) { newNode.isRoot = true diff --git a/simple-mind-map/src/plugins/Cooperate.js b/simple-mind-map/src/plugins/Cooperate.js new file mode 100644 index 00000000..d72377ab --- /dev/null +++ b/simple-mind-map/src/plugins/Cooperate.js @@ -0,0 +1,322 @@ +import * as Y from 'yjs' +import { WebrtcProvider } from 'y-webrtc' +import { isSameObject, simpleDeepClone, getType, isUndef } from '../utils/index' + +// 协同插件 +class Cooperate { + constructor(opt) { + this.opt = opt + this.mindMap = opt.mindMap + // yjs文档 + this.ydoc = new Y.Doc() + // 共享数据 + this.ymap = null + // 连接提供者 + this.provider = null + // 感知数据 + this.awareness = null + this.currentAwarenessData = [] + // 当前的平级对象类型的思维导图数据 + this.currentData = null + // 用户信息 + this.userInfo = null + // 绑定事件 + this.bindEvent() + // 处理实例化时传入的思维导图数据 + if (this.mindMap.opt.data) { + this.initData(this.mindMap.opt.data) + } + } + + // 初始化数据 + initData(data) { + data = simpleDeepClone(data) + // 解绑原来的数据 + if (this.ymap) { + this.ymap.unobserve(this.onObserve) + } + // 创建共享数据 + this.ymap = this.ydoc.getMap() + // 思维导图树结构转平级对象结构 + this.currentData = this.transformTreeDataToObject(data) + // 将思维导图数据添加到共享数据中 + Object.keys(this.currentData).forEach(uid => { + this.ymap.set(uid, this.currentData[uid]) + }) + // 监听数据同步 + this.onObserve = this.onObserve.bind(this) + this.ymap.observe(this.onObserve) + } + + // 获取yjs doc实例 + getDoc() { + return this.ydoc + } + + // 设置连接提供者 + setProvider(provider, webrtcProviderConfig = {}) { + const { roomName, signalingList, ...otherConfig } = webrtcProviderConfig + this.provider = + provider || + new WebrtcProvider(roomName, this.ydoc, { + signaling: signalingList, + ...otherConfig + }) + this.awareness = this.provider.awareness + + // 监听状态同步事件 + this.onAwareness = this.onAwareness.bind(this) + this.awareness.on('change', this.onAwareness) + } + + // 绑定事件 + bindEvent() { + // 监听思维导图改变 + this.onDataChange = this.onDataChange.bind(this) + this.mindMap.on('data_change', this.onDataChange) + + // 监听思维导图节点激活事件 + this.onNodeActive = this.onNodeActive.bind(this) + this.mindMap.on('node_active', this.onNodeActive) + + // 监听设置思维导图数据事件 + this.initData = this.initData.bind(this) + this.mindMap.on('set_data', this.initData) + } + + // 解绑事件 + unBindEvent() { + if (this.ymap) { + this.ymap.unobserve(this.onObserve) + } + this.mindMap.off('data_change', this.onDataChange) + this.mindMap.off('node_active', this.onNodeActive) + this.mindMap.off('set_data', this.initData) + this.ydoc.destroy() + } + + // 数据同步时的处理,更新当前思维导图 + onObserve(event) { + const data = event.target.toJSON() + // 如果数据没有改变直接返回 + if (isSameObject(data, this.currentData)) return + this.currentData = data + // 平级对象转树结构 + const res = this.transformObjectToTreeData(data) + if (!res) return + // 更新思维导图画布 + this.mindMap.renderer.setData(res) + this.mindMap.render() + this.mindMap.command.addHistory() + } + + // 当前思维导图改变后的处理,触发同步 + onDataChange(data) { + const res = this.transformTreeDataToObject(data) + this.updateChanges(res) + } + + // 找出更新点 + updateChanges(data) { + const oldData = this.currentData + this.currentData = data + this.ydoc.transact(() => { + // 找出新增的或修改的 + Object.keys(data).forEach(uid => { + // 新增的或已经存在的,如果数据发生了改变 + if (!oldData[uid] || !isSameObject(oldData[uid], data[uid])) { + this.ymap.set(uid, data[uid]) + } + }) + // 找出删除的 + Object.keys(oldData).forEach(uid => { + if (!data[uid]) { + this.ymap.delete(uid) + } + }) + }) + } + + // 节点激活状态改变后触发感知数据同步 + onNodeActive(node, nodeList) { + if (this.userInfo) { + this.awareness.setLocalStateField(this.userInfo.name, { + // 用户信息 + userInfo: { + ...this.userInfo + }, + // 当前激活的节点id列表 + nodeIdList: nodeList.map(item => { + return item.uid + }) + }) + } + } + + // 设置用户信息 + /** + * { + * id: '', // 必传,用户唯一的id + * name: '', // 用户名称。name和avatar两个只传一个即可,如果都传了,会显示avatar + * avatar: '', // 用户头像 + * color: '' // 如果没有传头像,那么会以一个圆形来显示名称的第一个字,文字的颜色为白色,圆的颜色可以通过该字段设置 + * } + **/ + setUserInfo(userInfo) { + if ( + getType(userInfo) !== 'Object' || + isUndef(userInfo.id) || + (isUndef(userInfo.name) && isUndef(userInfo.avatar)) + ) + return + this.userInfo = userInfo || null + } + + // 监听感知数据同步事件 + onAwareness() { + const walk = (list, callback) => { + list.forEach(value => { + const userName = Object.keys(value)[0] + if (!userName) return + const data = value[userName] + const userInfo = data.userInfo + const nodeIdList = data.nodeIdList + nodeIdList.forEach(uid => { + const node = this.mindMap.renderer.findNodeByUid(uid) + if (node) { + callback(node, userInfo) + } + }) + }) + } + // 清除之前的数据 + walk(this.currentAwarenessData, (node, userInfo) => { + node.removeUser(userInfo) + }) + // 设置当前数据 + const data = Array.from(this.awareness.getStates().values()) + this.currentAwarenessData = data + walk(data, (node, userInfo) => { + // 不显示自己 + if (userInfo.id === this.userInfo.id) return + node.addUser(userInfo) + }) + } + + // 将树结构转平级对象 + /* + { + data: { + uid: 'xxx' + }, + children: [ + { + data: { + uid: 'xxx' + }, + children: [] + } + ] + } + 转为: + { + uid: { + children: [uid1, uid2], + data: {} + } + } + */ + transformTreeDataToObject(data) { + const res = {} + const walk = (root, parent) => { + const uid = root.data.uid + if (parent) { + parent.children.push(uid) + } + res[uid] = { + isRoot: !parent, + data: { + ...root.data + }, + children: [] + } + if (root.children && root.children.length > 0) { + root.children.forEach(item => { + walk(item, res[uid]) + }) + } + } + walk(data, null) + return res + } + + // 找到父节点的uid + findParentUid(data, targetUid) { + const uids = Object.keys(data) + let res = '' + uids.forEach(uid => { + const children = data[uid].children + const isParent = + children.findIndex(childUid => { + return childUid === targetUid + }) !== -1 + if (isParent) { + res = uid + } + }) + return res + } + + // 将平级对象转树结构 + transformObjectToTreeData(data) { + const uids = Object.keys(data) + if (uids.length <= 0) return null + const rootKey = uids.find(uid => { + return data[uid].isRoot + }) + if (!rootKey || !data[rootKey]) return null + // 根节点 + const res = { + data: simpleDeepClone(data[rootKey].data), + children: [] + } + const map = {} + map[rootKey] = res + uids.forEach(uid => { + const parentUid = this.findParentUid(data, uid) + const cur = data[uid] + const node = map[uid] || { + data: simpleDeepClone(cur.data), + children: [] + } + if (!map[uid]) { + map[uid] = node + } + if (parentUid) { + if (map[parentUid]) { + map[parentUid].children.push(node) + } else { + map[parentUid] = { + data: simpleDeepClone(data[parentUid].data), + children: [node] + } + } + } + }) + return res + } + + // 插件被移除前做的事情 + beforePluginRemove() { + this.unBindEvent() + } + + // 插件被卸载前做的事情 + beforePluginDestroy() { + this.unBindEvent() + } +} + +Cooperate.instanceName = 'cooperate' + +export default Cooperate diff --git a/web/src/pages/Edit/components/Edit.vue b/web/src/pages/Edit/components/Edit.vue index da5fb909..5696a551 100644 --- a/web/src/pages/Edit/components/Edit.vue +++ b/web/src/pages/Edit/components/Edit.vue @@ -45,6 +45,7 @@ import SearchPlugin from 'simple-mind-map/src/plugins/Search.js' import Painter from 'simple-mind-map/src/plugins/Painter.js' import ScrollbarPlugin from 'simple-mind-map/src/plugins/Scrollbar.js' import Formula from 'simple-mind-map/src/plugins/Formula.js' +import Cooperate from 'simple-mind-map/src/plugins/Cooperate.js' import OutlineSidebar from './OutlineSidebar' import Style from './Style' import BaseStyle from './BaseStyle' @@ -95,6 +96,7 @@ MindMap.usePlugin(MiniMap) .usePlugin(Painter) .usePlugin(ScrollbarPlugin) .usePlugin(Formula) + // .usePlugin(Cooperate)// 协同插件 // 注册自定义主题 customThemeList.forEach(item => { @@ -387,6 +389,8 @@ export default { if (hasFileURL) { this.$bus.$emit('handle_file_url') } + // 协同测试 + this.cooperateTest() }, // url中是否存在要打开的文件 @@ -476,13 +480,13 @@ export default { // 测试动态插入节点 testDynamicCreateNodes() { - return + // return setTimeout(() => { // 动态给指定节点添加子节点 // this.mindMap.execCommand( // 'INSERT_CHILD_NODE', // false, - // this.mindMap.renderer.root, + // null, // { // text: '自定义内容' // }, @@ -579,6 +583,24 @@ export default { // 动态删除指定节点 // this.mindMap.execCommand('REMOVE_NODE', this.mindMap.renderer.root.children[0]) }, 5000) + }, + + // 协同测试 + cooperateTest() { + if (this.mindMap.cooperate && this.$route.query.userName) { + this.mindMap.cooperate.setProvider(null, { + roomName: 'demo-room', + signalingList: ['ws://192.168.3.125:4444'] + }) + this.mindMap.cooperate.setUserInfo({ + id: Math.random(), + name: this.$route.query.userName, + color: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399'][ + Math.floor(Math.random() * 5) + ], + avatar: Math.random() > 0.5 ? 'https://img0.baidu.com/it/u=4270674549,2416627993&fm=253&app=138&size=w931&n=0&f=JPEG&fmt=auto?sec=1696006800&t=4d32871d14a7224a4591d0c3c7a97311' : '' + }) + } } } }