update
Some checks failed
Deploy / build (push) Failing after 22s

This commit is contained in:
Круглицкий Никита Витальевич 2025-11-27 16:30:33 +06:00
parent 53ee5646e4
commit 7b6b494bae
6 changed files with 209 additions and 22 deletions

View File

@ -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",

View File

@ -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>

View 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>

View File

@ -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,
}
}

View 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,
}
}

View File

@ -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"