parent
53ee5646e4
commit
7b6b494bae
@ -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",
|
||||
|
||||
@ -7,6 +7,21 @@
|
||||
:connection-line-type="ConnectionLineType.Step"
|
||||
>
|
||||
<Background />
|
||||
|
||||
<RemoteCursor
|
||||
v-for="client in remoteClients"
|
||||
:key="client.id"
|
||||
:username="client.id.toString()"
|
||||
:position="client.cursorPosition ?? { x: 0, y: 0 }"
|
||||
:style="{
|
||||
backgroundColor: client.color,
|
||||
zIndex: 999,
|
||||
}"
|
||||
/>
|
||||
|
||||
<div v-if="clientId" class="info">
|
||||
Ваш ID: {{ clientId }} | Клиентов: {{ clients.length }}
|
||||
</div>
|
||||
</VueFlow>
|
||||
|
||||
<div class="menu">
|
||||
@ -23,10 +38,6 @@
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="clientId" class="info">
|
||||
Ваш ID: {{ clientId }} | Клиентов: {{ clients.size }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -35,6 +46,7 @@ import { Background } from '@vue-flow/background'
|
||||
import { ConnectionLineType, ConnectionMode, useVueFlow, VueFlow } from '@vue-flow/core'
|
||||
import { ref } from 'vue'
|
||||
import { useCollaborativeFlow } from '../composables/useCollaborativeFlow.js'
|
||||
import RemoteCursor from './RemoteCursor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
diagramId: string
|
||||
@ -47,7 +59,7 @@ const { addNodes, onConnect, addEdges } = useVueFlow()
|
||||
|
||||
onConnect(addEdges)
|
||||
|
||||
const { clientId, clients, reset } = useCollaborativeFlow(() => props.diagramId)
|
||||
const { clientId, clients, remoteClients, reset } = useCollaborativeFlow(() => props.diagramId)
|
||||
|
||||
function addRandomNode(type?: Node['type']) {
|
||||
addNodes({
|
||||
@ -77,5 +89,7 @@ function addRandomNode(type?: Node['type']) {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
|
||||
42
src/components/RemoteCursor.vue
Normal file
42
src/components/RemoteCursor.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
class="remote-cursor"
|
||||
:style="{
|
||||
transform: `translate(${x}px, ${y}px)`,
|
||||
}"
|
||||
:data-username="username"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { XYPosition } from '@vue-flow/core'
|
||||
import { toRef } from 'vue'
|
||||
import { usePerfectCursors } from '../composables/usePerfectCursors.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
username: string
|
||||
position: XYPosition
|
||||
}>()
|
||||
|
||||
const { x, y } = usePerfectCursors(toRef(() => props.position))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.remote-cursor {
|
||||
position: fixed;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
content: attr(data-username);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -15px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
translate: -50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -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<string>) {
|
||||
const yDoc = new Y.Doc()
|
||||
|
||||
@ -15,12 +27,18 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
||||
const yEdges = yDoc.getMap<Edge>('edges')
|
||||
|
||||
const clientId = ref<number>()
|
||||
const clients = shallowRef(new Map())
|
||||
const clients = shallowRef<Client[]>([])
|
||||
|
||||
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<string>) {
|
||||
removeEdges,
|
||||
} = useVueFlow()
|
||||
|
||||
const { x, y } = usePointer({ target: viewportRef })
|
||||
|
||||
let isApplyingFromY = false
|
||||
let isApplyingEdgesFromY = false
|
||||
|
||||
@ -62,7 +82,11 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
||||
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<string>) {
|
||||
// ---------------------------
|
||||
// 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<string>) {
|
||||
yNodes.delete(change.id)
|
||||
break
|
||||
|
||||
case 'select':
|
||||
case 'position': {
|
||||
const node = yNodes.get(change.id)
|
||||
|
||||
if (!node)
|
||||
return
|
||||
|
||||
if (type === 'position') {
|
||||
if (change.position) {
|
||||
node.position = change.position
|
||||
|
||||
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<string>) {
|
||||
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<string>) {
|
||||
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<string>) {
|
||||
return {
|
||||
clientId,
|
||||
clients,
|
||||
remoteClients,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
27
src/composables/usePerfectCursors.ts
Normal file
27
src/composables/usePerfectCursors.ts
Normal file
@ -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<XYPosition>) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
22
yarn.lock
22
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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user