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"