parent
53ee5646e4
commit
7b6b494bae
@ -12,6 +12,9 @@
|
|||||||
"@vue-flow/background": "^1.3.2",
|
"@vue-flow/background": "^1.3.2",
|
||||||
"@vue-flow/core": "^1.47.0",
|
"@vue-flow/core": "^1.47.0",
|
||||||
"@vueuse/core": "^14.0.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": "^3.5.24",
|
||||||
"vue-router": "4",
|
"vue-router": "4",
|
||||||
"y-websocket": "^3.0.0",
|
"y-websocket": "^3.0.0",
|
||||||
|
|||||||
@ -7,6 +7,21 @@
|
|||||||
:connection-line-type="ConnectionLineType.Step"
|
:connection-line-type="ConnectionLineType.Step"
|
||||||
>
|
>
|
||||||
<Background />
|
<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>
|
</VueFlow>
|
||||||
|
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
@ -23,10 +38,6 @@
|
|||||||
Сбросить
|
Сбросить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="clientId" class="info">
|
|
||||||
Ваш ID: {{ clientId }} | Клиентов: {{ clients.size }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -35,6 +46,7 @@ import { Background } from '@vue-flow/background'
|
|||||||
import { ConnectionLineType, ConnectionMode, useVueFlow, VueFlow } from '@vue-flow/core'
|
import { ConnectionLineType, ConnectionMode, useVueFlow, VueFlow } from '@vue-flow/core'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useCollaborativeFlow } from '../composables/useCollaborativeFlow.js'
|
import { useCollaborativeFlow } from '../composables/useCollaborativeFlow.js'
|
||||||
|
import RemoteCursor from './RemoteCursor.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
diagramId: string
|
diagramId: string
|
||||||
@ -47,7 +59,7 @@ const { addNodes, onConnect, addEdges } = useVueFlow()
|
|||||||
|
|
||||||
onConnect(addEdges)
|
onConnect(addEdges)
|
||||||
|
|
||||||
const { clientId, clients, reset } = useCollaborativeFlow(() => props.diagramId)
|
const { clientId, clients, remoteClients, reset } = useCollaborativeFlow(() => props.diagramId)
|
||||||
|
|
||||||
function addRandomNode(type?: Node['type']) {
|
function addRandomNode(type?: Node['type']) {
|
||||||
addNodes({
|
addNodes({
|
||||||
@ -77,5 +89,7 @@ function addRandomNode(type?: Node['type']) {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
</style>
|
</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 { MaybeRefOrGetter } from 'vue'
|
||||||
import type { YMapEvent } from 'yjs'
|
import type { YMapEvent } from 'yjs'
|
||||||
import { useVueFlow } from '@vue-flow/core'
|
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 { WebsocketProvider } from 'y-websocket'
|
||||||
import * as Y from 'yjs'
|
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>) {
|
export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
||||||
const yDoc = new Y.Doc()
|
const yDoc = new Y.Doc()
|
||||||
|
|
||||||
@ -15,12 +27,18 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
|||||||
const yEdges = yDoc.getMap<Edge>('edges')
|
const yEdges = yDoc.getMap<Edge>('edges')
|
||||||
|
|
||||||
const clientId = ref<number>()
|
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 {
|
const {
|
||||||
|
viewportRef,
|
||||||
addNodes,
|
addNodes,
|
||||||
addEdges,
|
addEdges,
|
||||||
updateNode,
|
updateNode,
|
||||||
|
updateNodeData,
|
||||||
onNodesChange,
|
onNodesChange,
|
||||||
onEdgesChange,
|
onEdgesChange,
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
@ -29,6 +47,8 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
|||||||
removeEdges,
|
removeEdges,
|
||||||
} = useVueFlow()
|
} = useVueFlow()
|
||||||
|
|
||||||
|
const { x, y } = usePointer({ target: viewportRef })
|
||||||
|
|
||||||
let isApplyingFromY = false
|
let isApplyingFromY = false
|
||||||
let isApplyingEdgesFromY = false
|
let isApplyingEdgesFromY = false
|
||||||
|
|
||||||
@ -62,7 +82,11 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
|||||||
if (!node)
|
if (!node)
|
||||||
continue
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -104,6 +128,10 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
|||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Vue Flow -> Yjs
|
// Vue Flow -> Yjs
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
const updateNodeDebounced = debounce((id: string, node: Node) => {
|
||||||
|
yNodes.set(id, node)
|
||||||
|
}, DEBOUNCE_TIME, { leading: true, maxWait: DEBOUNCE_TIME })
|
||||||
|
|
||||||
onNodesChange((changes) => {
|
onNodesChange((changes) => {
|
||||||
console.log('onNodesChange', changes)
|
console.log('onNodesChange', changes)
|
||||||
|
|
||||||
@ -129,17 +157,28 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
|||||||
yNodes.delete(change.id)
|
yNodes.delete(change.id)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'select':
|
||||||
case 'position': {
|
case 'position': {
|
||||||
const node = yNodes.get(change.id)
|
const node = yNodes.get(change.id)
|
||||||
|
|
||||||
if (!node)
|
if (!node)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (change.position) {
|
if (type === 'position') {
|
||||||
node.position = change.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
|
break
|
||||||
}
|
}
|
||||||
@ -189,25 +228,42 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
|||||||
return
|
return
|
||||||
|
|
||||||
provider.value = new WebsocketProvider(
|
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,
|
diagramId,
|
||||||
yDoc,
|
yDoc,
|
||||||
)
|
)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
clientId.value = provider.value.awareness.clientID
|
watch(provider, (provider) => {
|
||||||
clients.value = provider.value.awareness.getStates()
|
if (!provider) {
|
||||||
|
clients.value = []
|
||||||
|
|
||||||
// provider.value.awareness.setLocalStateField('nigger', true)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
provider.value.awareness.on('update', () => {
|
clientId.value = provider.awareness.clientID
|
||||||
// console.log('update', added, updated, removed)
|
|
||||||
|
|
||||||
// const changedClients = added.concat(updated).concat(removed)
|
initLocalState()
|
||||||
// broadcastAwarenessMessage(awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients))
|
clients.value = getClientsState()
|
||||||
clients.value = provider.value!.awareness.getStates()
|
|
||||||
|
provider.awareness.on('change', () => {
|
||||||
|
// TODO: оптимизировать обновление состояния (не генерить каждый раз новый массив)
|
||||||
|
clients.value = getClientsState()
|
||||||
})
|
})
|
||||||
}, { immediate: true })
|
}, { 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(() => {
|
onScopeDispose(() => {
|
||||||
yNodes.unobserve(nodesObserver)
|
yNodes.unobserve(nodesObserver)
|
||||||
yEdges.unobserve(edgesObserver)
|
yEdges.unobserve(edgesObserver)
|
||||||
@ -215,6 +271,28 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
|||||||
provider.value?.destroy()
|
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() {
|
function reset() {
|
||||||
// yDoc.destroy()
|
// yDoc.destroy()
|
||||||
yNodes.clear()
|
yNodes.clear()
|
||||||
@ -224,6 +302,7 @@ export function useCollaborativeFlow(diagramId: MaybeRefOrGetter<string>) {
|
|||||||
return {
|
return {
|
||||||
clientId,
|
clientId,
|
||||||
clients,
|
clients,
|
||||||
|
remoteClients,
|
||||||
reset,
|
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"
|
estraverse "^5.3.0"
|
||||||
picomatch "^4.0.3"
|
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":
|
"@types/debug@^4.0.0":
|
||||||
version "4.1.12"
|
version "4.1.12"
|
||||||
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
|
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
|
||||||
@ -1230,6 +1235,11 @@ color-convert@^2.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
color-name "~1.1.4"
|
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:
|
color-name@~1.1.4:
|
||||||
version "1.1.4"
|
version "1.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||||
@ -2153,6 +2163,11 @@ locate-path@^6.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^5.0.0"
|
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:
|
lodash.merge@^4.6.2:
|
||||||
version "4.6.2"
|
version "4.6.2"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
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"
|
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
|
||||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
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:
|
picocolors@^1.0.0, picocolors@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user