diff --git a/package.json b/package.json
index 7603c8e..95c6773 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,9 @@
"@vue-flow/background": "^1.3.2",
"@vue-flow/core": "^1.47.0",
"@vueuse/core": "^14.0.0",
+ "color-hash": "^2.0.2",
+ "lodash-es": "^4.17.21",
+ "perfect-cursors": "^1.0.5",
"vue": "^3.5.24",
"vue-router": "4",
"y-websocket": "^3.0.0",
diff --git a/src/components/Flowchart.vue b/src/components/Flowchart.vue
index 582f803..4d1bd08 100644
--- a/src/components/Flowchart.vue
+++ b/src/components/Flowchart.vue
@@ -7,6 +7,21 @@
:connection-line-type="ConnectionLineType.Step"
>
+
+
+
+
+ Ваш ID: {{ clientId }} | Клиентов: {{ clients.length }}
+
-
-
- Ваш ID: {{ clientId }} | Клиентов: {{ clients.size }}
-
+
+
diff --git a/src/composables/useCollaborativeFlow.ts b/src/composables/useCollaborativeFlow.ts
index 91a8e00..b5e8a24 100644
--- a/src/composables/useCollaborativeFlow.ts
+++ b/src/composables/useCollaborativeFlow.ts
@@ -1,11 +1,23 @@
-import type { Edge, Node } from '@vue-flow/core'
+import type { Edge, Node, XYPosition } from '@vue-flow/core'
import type { MaybeRefOrGetter } from 'vue'
import type { YMapEvent } from 'yjs'
import { useVueFlow } from '@vue-flow/core'
-import { onScopeDispose, ref, shallowRef, toValue, watch } from 'vue'
+import { usePointer } from '@vueuse/core'
+import ColorHash from 'color-hash'
+import debounce from 'lodash-es/debounce'
+import { computed, onScopeDispose, ref, shallowRef, toValue, watch } from 'vue'
import { WebsocketProvider } from 'y-websocket'
import * as Y from 'yjs'
+interface Client {
+ id: number
+ cursorPosition: XYPosition
+ color: string
+}
+
+const DEBOUNCE_TIME = 100
+const CURSOR_DEBOUNCE_TIME = 150
+
export function useCollaborativeFlow(diagramId: MaybeRefOrGetter) {
const yDoc = new Y.Doc()
@@ -15,12 +27,18 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter) {
const yEdges = yDoc.getMap('edges')
const clientId = ref()
- const clients = shallowRef(new Map())
+ const clients = shallowRef([])
+
+ const remoteClients = computed(() => {
+ return clients.value.filter(client => client.id !== clientId.value)
+ })
const {
+ viewportRef,
addNodes,
addEdges,
updateNode,
+ updateNodeData,
onNodesChange,
onEdgesChange,
applyNodeChanges,
@@ -29,6 +47,8 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter) {
removeEdges,
} = useVueFlow()
+ const { x, y } = usePointer({ target: viewportRef })
+
let isApplyingFromY = false
let isApplyingEdgesFromY = false
@@ -62,7 +82,11 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter) {
if (!node)
continue
- updateNode(id, { position: node.position })
+ updateNode(id, {
+ position: node.position,
+ // draggable: !node.data.selectedBy || node.data.selectedBy === clientId.value,
+ })
+ updateNodeData(id, node.data)
break
}
}
@@ -104,6 +128,10 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter) {
// ---------------------------
// Vue Flow -> Yjs
// ---------------------------
+ const updateNodeDebounced = debounce((id: string, node: Node) => {
+ yNodes.set(id, node)
+ }, DEBOUNCE_TIME, { leading: true, maxWait: DEBOUNCE_TIME })
+
onNodesChange((changes) => {
console.log('onNodesChange', changes)
@@ -129,17 +157,28 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter) {
yNodes.delete(change.id)
break
+ case 'select':
case 'position': {
const node = yNodes.get(change.id)
if (!node)
return
- if (change.position) {
- node.position = change.position
- }
+ if (type === 'position') {
+ if (change.position) {
+ node.position = change.position
- yNodes.set(change.id, node)
+ updateNodeDebounced(change.id, node)
+ }
+ }
+ else if (type === 'select') {
+ if (change.selected) {
+ node.data ??= {}
+ node.data.selectedBy = clientId.value
+
+ yNodes.set(change.id, node)
+ }
+ }
break
}
@@ -189,25 +228,42 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter) {
return
provider.value = new WebsocketProvider(
- import.meta.env.DEV ? 'ws://localhost:1234' : 'wss://api.koptilnya.xyz/bpmn',
+ 'wss://api.koptilnya.xyz/bpmn',
+ // import.meta.env.DEV ? 'ws://localhost:1234' : 'wss://api.koptilnya.xyz/bpmn',
diagramId,
yDoc,
)
+ }, { immediate: true })
- clientId.value = provider.value.awareness.clientID
- clients.value = provider.value.awareness.getStates()
+ watch(provider, (provider) => {
+ if (!provider) {
+ clients.value = []
- // provider.value.awareness.setLocalStateField('nigger', true)
+ return
+ }
- provider.value.awareness.on('update', () => {
- // console.log('update', added, updated, removed)
+ clientId.value = provider.awareness.clientID
- // const changedClients = added.concat(updated).concat(removed)
- // broadcastAwarenessMessage(awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients))
- clients.value = provider.value!.awareness.getStates()
+ initLocalState()
+ clients.value = getClientsState()
+
+ provider.awareness.on('change', () => {
+ // TODO: оптимизировать обновление состояния (не генерить каждый раз новый массив)
+ clients.value = getClientsState()
})
}, { immediate: true })
+ watch(
+ [x, y],
+ debounce(([x, y]: [XYPosition['x'], XYPosition['y']]) => {
+ if (!provider.value)
+ return
+
+ provider.value.awareness.setLocalStateField('cursorPosition', { x, y })
+ }, CURSOR_DEBOUNCE_TIME, { leading: true, maxWait: CURSOR_DEBOUNCE_TIME }),
+ { immediate: true },
+ )
+
onScopeDispose(() => {
yNodes.unobserve(nodesObserver)
yEdges.unobserve(edgesObserver)
@@ -215,6 +271,28 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter) {
provider.value?.destroy()
})
+ function initLocalState() {
+ if (!provider.value)
+ return
+
+ provider.value.awareness.setLocalState({
+ cursorPosition: { x: 0, y: 0 },
+ color: (new ColorHash()).hex(clientId.value!.toString()),
+ })
+ }
+
+ function getClientsState() {
+ if (!provider.value)
+ return []
+
+ return Array.from(provider.value.awareness.getStates()).map(([id, state]) => {
+ return {
+ id,
+ ...state,
+ } as Client
+ })
+ }
+
function reset() {
// yDoc.destroy()
yNodes.clear()
@@ -224,6 +302,7 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter) {
return {
clientId,
clients,
+ remoteClients,
reset,
}
}
diff --git a/src/composables/usePerfectCursors.ts b/src/composables/usePerfectCursors.ts
new file mode 100644
index 0000000..94f3cbf
--- /dev/null
+++ b/src/composables/usePerfectCursors.ts
@@ -0,0 +1,27 @@
+import type { XYPosition } from '@vue-flow/core'
+import type { Ref } from 'vue'
+import { PerfectCursor } from 'perfect-cursors'
+import { onScopeDispose, ref, watch } from 'vue'
+
+export function usePerfectCursors(position: Ref) {
+ const x = ref(0)
+ const y = ref(0)
+
+ const pc = new PerfectCursor(([newX, newY]) => {
+ x.value = newX!
+ y.value = newY!
+ })
+
+ watch(position, ({ x, y }) => {
+ pc.addPoint([x, y])
+ }, { immediate: true })
+
+ onScopeDispose(() => {
+ pc.dispose()
+ })
+
+ return {
+ x,
+ y,
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 508e1c0..99f91d1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -689,6 +689,11 @@
estraverse "^5.3.0"
picomatch "^4.0.3"
+"@tldraw/vec@^1.4.3":
+ version "1.9.2"
+ resolved "https://registry.yarnpkg.com/@tldraw/vec/-/vec-1.9.2.tgz#0b4719fd44dee57c414b82af8b38c2ccdbd27fef"
+ integrity sha512-k9vH52MRpJHjVcaahWu6VqvhLeE9h1qL5Z2gLobS9zTMpUJ59kBQPNo0VPzPlDYBpXdS4GxuB4jYQMnKvuPAZg==
+
"@types/debug@^4.0.0":
version "4.1.12"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
@@ -1230,6 +1235,11 @@ color-convert@^2.0.1:
dependencies:
color-name "~1.1.4"
+color-hash@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/color-hash/-/color-hash-2.0.2.tgz#abf735705da71874ddec7dcef50cd7479e7d64e9"
+ integrity sha512-6exeENAqBTuIR1wIo36mR8xVVBv6l1hSLd7Qmvf6158Ld1L15/dbahR9VUOiX7GmGJBCnQyS0EY+I8x+wa7egg==
+
color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
@@ -2153,6 +2163,11 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
+lodash-es@^4.17.21:
+ version "4.17.21"
+ resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
+ integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
+
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
@@ -2764,6 +2779,13 @@ pathe@^2.0.1, pathe@^2.0.3:
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
+perfect-cursors@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/perfect-cursors/-/perfect-cursors-1.0.5.tgz#79b8c04790172bac833a0767a83f376d7e59ca86"
+ integrity sha512-LgQJj6QtF6VzYODurlhF9Ayt2liiadJZBocK98brcCC6D/dRtZlSU/r0mXWDoCdGPiO24oJB1+PZKz4p9hblWg==
+ dependencies:
+ "@tldraw/vec" "^1.4.3"
+
picocolors@^1.0.0, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"