Skip to content

Commit 949c395

Browse files
committed
feat: 物料拖拽多人协作 & 选区同步功能
1 parent 18f2c80 commit 949c395

27 files changed

Lines changed: 1414 additions & 67 deletions

File tree

mockServer/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@
4040
"koa-compose": "^4.1.0",
4141
"koa-jwt": "^3.6.0",
4242
"koa-router": "^8.0.8",
43-
"koa-static2": "^0.1.8"
43+
"koa-static2": "^0.1.8",
44+
"yjs": "^13.6.8",
45+
"y-websocket": "^1.5.0",
46+
"ws": "^8.13.0",
47+
"lib0": "^0.2.55"
4448
},
4549
"devDependencies": {
4650
"@babel/cli": "^7.8.4",

mockServer/src/app.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ import Koa2 from 'koa'
1414
import KoaBody from 'koa-body'
1515
import KoaStatic from 'koa-static2'
1616
import path from 'path'
17+
import http from 'http'
18+
import WebSocket from 'ws'
19+
import { setupWSConnection, docs } from 'y-websocket/bin/utils'
1720
import { env, port } from './config/config'
1821
import ErrorRoutesCatch from './middleware/ErrorRoutesCatch'
1922
import ErrorRoutes from './routes/error-routes'
2023
import MainRoutes from './routes/main-routes'
2124

2225
const app = new Koa2()
26+
// -------------------- HTTP 相关中间件 --------------------
2327
app
2428
.use((ctx, next) => {
2529
ctx.set('Access-Control-Allow-Origin', '*')
@@ -56,6 +60,34 @@ if (env === 'development') {
5660
})
5761
}
5862

59-
app.listen(port)
63+
// -------------------- 创建 HTTP Server --------------------
64+
const server = http.createServer(app.callback())
65+
66+
// -------------------- Yjs WebSocket --------------------
67+
const wss = new WebSocket.Server({ server })
68+
69+
wss.on('connection', (conn, req) => {
70+
setupWSConnection(conn, req, { gc: true })
71+
72+
const docName = req.url.slice(1) // 去掉开头的 '/'
73+
const doc = docs.get(docName)
74+
if (doc) {
75+
doc.on('update', (update, origin, docInstance) => {
76+
console.log(`
77+
==== Yjs Server Stats (Doc Changed) ====
78+
Time: ${new Date().toLocaleString()}
79+
DocId: ${docName}
80+
Update byteLength: ${update.byteLength}
81+
Current connections: ${docInstance.conns?.size ?? 0}
82+
==========================
83+
`)
84+
})
85+
}
86+
})
87+
88+
// -------------------- 启动服务 --------------------
89+
server.listen(port, () => {
90+
console.log(`HTTP+Yjs server listening at http://localhost:${port} , WebSocket: ws://localhost:${port}/ws`)
91+
})
6092

6193
export default app

packages/build/vite-config/src/vite-plugins/devAliasPlugin.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const getDevAlias = (useSourceAlias) => {
1616
'@opentiny/tiny-engine-common/component': path.resolve(basePath, 'packages/common/component'),
1717
'@opentiny/tiny-engine-common/js': path.resolve(basePath, 'packages/common/js'),
1818
'@opentiny/tiny-engine-common': path.resolve(basePath, 'packages/common/index.ts'),
19+
'@opentiny/tiny-engine-multi-person-collaboration': path.resolve(
20+
basePath,
21+
'packages/multi-person-collaboration/src/index'
22+
),
1923
'@opentiny/tiny-engine-plugin-materials': path.resolve(basePath, 'packages/plugins/materials/index.ts'),
2024
'@opentiny/tiny-engine-plugin-block': path.resolve(basePath, 'packages/plugins/block/index.ts'),
2125
'@opentiny/tiny-engine-plugin-state': path.resolve(basePath, 'packages/plugins/state/index.ts'),

packages/canvas/container/src/CanvasContainer.vue

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div v-for="state in multiSelectedStates" :key="state.id">
2+
<div v-for="state in allMultiSelectedStates" :key="state.id">
33
<canvas-action
44
:hoverState="hoverState"
55
:inactiveHoverState="inactiveHoverState"
@@ -8,6 +8,7 @@
88
:windowGetClickEventTarget="target"
99
:resize="canvasState.type === 'absolute'"
1010
:multiStateLength="multiStateLength"
11+
:remoteStatesLength="remoteStatesLength"
1112
:isMultiDragging="isMultiDragging"
1213
@select-slot="selectSlot"
1314
@setting="settingModel"
@@ -91,8 +92,12 @@ import {
9192
clearLineState,
9293
querySelectById,
9394
getCurrent,
94-
canvasApi
95+
canvasApi,
96+
getRect
9597
} from './container'
98+
import { initHook, HOOK_NAME } from '@opentiny/tiny-engine-meta-register'
99+
import useCollabSchema from '@opentiny/tiny-engine-multi-person-collaboration'
100+
import { useRealtimeCollab } from '@opentiny/tiny-engine-meta-register'
96101
97102
export default {
98103
components: {
@@ -138,7 +143,53 @@ export default {
138143
139144
const { multiSelectedStates, isMouseDown } = useMultiSelect()
140145
146+
// Awareness 是否完成初始化,initHook之后才能得到 remoteStates数据
147+
const isReady = ref(false)
148+
149+
// Awareness 远端数据
150+
const remoteStates = ref({})
151+
152+
// 滚动保持节点位置同步
153+
const syncRemoteStatesSelections = ref([])
154+
155+
// 全部 Selection States 信息
156+
const allMultiSelectedStates = computed(() => {
157+
return [...multiSelectedStates.value, ...syncRemoteStatesSelections.value]
158+
})
159+
160+
// 将 state 映射成带位置信息的 selection
161+
const mapStateToSelection = (state) => {
162+
const element = querySelectById(state.selection.id)
163+
if (!element) return null
164+
const { top, left, width, height } = getRect(element)
165+
return {
166+
...state,
167+
isRemote: true,
168+
user: state.user,
169+
top,
170+
left,
171+
width,
172+
height
173+
}
174+
}
175+
176+
// 远端 selections,自动过滤不存在的元素
177+
const remoteStatesSelections = computed(() => {
178+
if (!remoteStates.value) return []
179+
180+
return Object.values(remoteStates.value)
181+
.filter((state) => state.selection)
182+
.map(mapStateToSelection)
183+
.filter(Boolean)
184+
})
185+
186+
// 手动刷新远端节点位置
187+
const syncRemoteNode = () => {
188+
syncRemoteStatesSelections.value = syncRemoteStatesSelections.value.map(mapStateToSelection).filter(Boolean)
189+
}
190+
141191
const multiStateLength = computed(() => multiSelectedStates.value.length)
192+
const remoteStatesLength = computed(() => syncRemoteStatesSelections.value.length)
142193
const {
143194
startMultiDrag,
144195
moveMultiDrag,
@@ -397,6 +448,26 @@ export default {
397448
registerHotkeyEvent(doc)
398449
399450
win.addEventListener('scroll', syncNodeScroll, true)
451+
win.addEventListener('scroll', syncRemoteNode, true)
452+
453+
const { insertSharedNode, deleteSharedNode, updateUserSelection, remoteStates } = useCollabSchema({
454+
roomId: 'schema-yjs',
455+
currentUser: {
456+
id: 2,
457+
name: 'Bob',
458+
color: '#4ECDC4',
459+
avatarUrl: 'https://i.pravatar.cc/150?img=2'
460+
}
461+
})
462+
463+
initHook(HOOK_NAME.useRealtimeCollab, {
464+
insertSharedNode,
465+
deleteSharedNode,
466+
updateUserSelection,
467+
remoteStates
468+
})
469+
470+
isReady.value = true
400471
}
401472
}
402473
// 设置弹窗
@@ -452,6 +523,30 @@ export default {
452523
document.addEventListener('beforeCanvasReady', beforeCanvasReady)
453524
document.addEventListener('canvasReady', canvasReady)
454525
526+
watch(isReady, (newVal) => {
527+
if (newVal) {
528+
const newStates = useRealtimeCollab().remoteStates
529+
remoteStates.value = newStates
530+
}
531+
})
532+
533+
watch(
534+
remoteStatesSelections,
535+
(newVal) => {
536+
syncRemoteStatesSelections.value = newVal
537+
},
538+
{ immediate: true }
539+
)
540+
541+
watch(
542+
remoteStates,
543+
(newVal) => {
544+
// eslint-disable-next-line no-console
545+
console.log('remoteStates变动', newVal)
546+
},
547+
{ deep: true }
548+
)
549+
455550
return {
456551
isMouseDown,
457552
iframe,
@@ -477,7 +572,12 @@ export default {
477572
srcAttrName,
478573
isMultiDragging,
479574
multiDragState,
480-
getMultiDragPositionText
575+
getMultiDragPositionText,
576+
remoteStates,
577+
allMultiSelectedStates,
578+
remoteStatesSelections,
579+
remoteStatesLength,
580+
syncRemoteStatesSelections
481581
}
482582
}
483583
}

packages/canvas/container/src/components/CanvasAction.vue

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
width: selectState.width + 'px'
1010
}"
1111
>
12-
<div v-if="showQuickAction" ref="labelRef" class="corner-mark-left" :style="labelStyle">
12+
<div v-if="showQuickAction && !haveRemoteState" ref="labelRef" class="corner-mark-left" :style="labelStyle">
1313
<span>{{ selectState.componentName }}</span>
1414
<TinyPopover
1515
v-model="showPopover"
@@ -25,6 +25,9 @@
2525
</template>
2626
</TinyPopover>
2727
</div>
28+
<div v-else-if="haveRemoteState" ref="labelRef" class="corner-mark-left" :style="labelStyle">
29+
<span> {{ selectState.user.name }} 正在编辑 </span>
30+
</div>
2831
<!-- 绝对定位画布时调节元素大小 -->
2932
<template v-else>
3033
<div
@@ -68,7 +71,7 @@
6871
@mousedown.stop="onMousedown($event, 'end', 'end')"
6972
></div>
7073
</template>
71-
<div v-if="showAction" ref="optionRef" class="corner-mark-right" :style="fixStyle">
74+
<div v-if="showAction && !haveRemoteState" ref="optionRef" class="corner-mark-right" :style="fixStyle">
7275
<template v-if="!isModal">
7376
<div v-if="showToParent" title="选择父级">
7477
<icon-chevron-left class="svg-currentcolor" @click.stop="selectParent"></icon-chevron-left>
@@ -134,7 +137,7 @@ import {
134137
querySelectById
135138
} from '../container'
136139
import { useLayout, useMaterial, useCanvas, useMessage } from '@opentiny/tiny-engine-meta-register'
137-
import { Popover } from '@opentiny/vue'
140+
import { Numeric, Popover } from '@opentiny/vue'
138141
import shortCutPopover from './shortCutPopover.vue'
139142
140143
// 工具操作条高度
@@ -186,6 +189,10 @@ export default {
186189
type: Number,
187190
default: () => 0
188191
},
192+
remoteStatesLength: {
193+
type: Numeric,
194+
default: () => {}
195+
},
189196
resize: {
190197
type: Boolean,
191198
default: false
@@ -259,6 +266,10 @@ export default {
259266
return props.multiStateLength < 2
260267
})
261268
269+
const haveRemoteState = computed(() => {
270+
return props.remoteStatesLength > 0
271+
})
272+
262273
const showAction = computed(() => {
263274
const { schema, parent } = getCurrent()
264275
if (schema?.props?.['data-id'] === 'root-container') {
@@ -543,6 +554,7 @@ export default {
543554
fixStyle,
544555
showAction,
545556
showQuickAction,
557+
haveRemoteState,
546558
showPopover,
547559
showToParent,
548560
activeSetting,

packages/canvas/container/src/composables/useMultiSelect.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { ref } from 'vue'
2-
import { useCanvas, useMessage, useHistory, getOptions } from '@opentiny/tiny-engine-meta-register'
2+
import { useCanvas, useMessage, useHistory, getOptions, useRealtimeCollab } from '@opentiny/tiny-engine-meta-register'
33
import { utils } from '@opentiny/tiny-engine-utils'
44
import { getRect, querySelectById, POSITION, insertNode, selectNode, canvasState } from '../container'
55
import type { Node } from '../../../types'
66

7-
interface SelectionState {
7+
export interface SelectionState {
88
id: string
99
top?: number
1010
left?: number
@@ -114,6 +114,9 @@ export const useMultiSelect = () => {
114114
// 单选
115115
multiSelectedStates.value = [selectState]
116116

117+
// 多人协作
118+
useRealtimeCollab().updateUserSelection(selectState)
119+
117120
return true
118121
}
119122

packages/canvas/container/src/container.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { isVsCodeEnv } from '@opentiny/tiny-engine-common/js/environments'
2626
import Builtin from '../../render/src/builtin/builtin.json' //TODO 画布内外应该分开
2727
import { useMultiSelect } from './composables/useMultiSelect'
2828
import type { Node, RootNode } from '../../types'
29+
import { useRealtimeCollab } from '@opentiny/tiny-engine-meta-register'
2930

3031
export interface DragOffset {
3132
offsetX: number
@@ -310,6 +311,9 @@ const insertAfter = ({ parent, node, data }: InsertOptions) => {
310311
position: 'after',
311312
referTargetNodeId: node.id
312313
})
314+
315+
// 多人协作同步
316+
useRealtimeCollab().insertSharedNode({ node, parent, data }, POSITION.BOTTOM)
313317
}
314318

315319
const insertReplace = ({ parent, node, data }: InsertOptions) => {
@@ -327,6 +331,9 @@ const insertReplace = ({ parent, node, data }: InsertOptions) => {
327331
position: 'replace',
328332
referTargetNodeId: node.id
329333
})
334+
335+
// 多人协作同步
336+
useRealtimeCollab().insertSharedNode({ node, parent, data }, POSITION.REPLACE)
330337
}
331338
}
332339

@@ -342,6 +349,9 @@ const insertBefore = ({ parent, node, data }: InsertOptions) => {
342349
position: 'before',
343350
referTargetNodeId: node.id
344351
})
352+
353+
// 多人协作同步
354+
useRealtimeCollab().insertSharedNode({ node, parent, data }, POSITION.TOP)
345355
}
346356

347357
const insertInner = ({ node, data }: Omit<InsertOptions, 'parent'>, position: string = '') => {
@@ -355,13 +365,18 @@ const insertInner = ({ node, data }: Omit<InsertOptions, 'parent'>, position: st
355365
newNodeData: data,
356366
position: ([POSITION.TOP, POSITION.LEFT] as string[]).includes(position) ? 'before' : 'after'
357367
})
368+
369+
// 多人协作同步
370+
useRealtimeCollab().insertSharedNode({ node, parent: node, data }, POSITION.TOP)
358371
}
359372

360373
export const removeNode = (id: string) => {
361374
useCanvas().operateNode({
362375
type: 'delete',
363376
id
364377
})
378+
// 多人协作同步
379+
useRealtimeCollab().deleteSharedNode(id)
365380
}
366381

367382
// 添加外部容器
@@ -377,6 +392,9 @@ const insertContainer = ({ parent, node, data }: InsertOptions) => {
377392
position: POSITION.OUT,
378393
referTargetNodeId: node.id
379394
})
395+
396+
// 多人协作同步
397+
useRealtimeCollab().insertSharedNode({ node, parent, data }, POSITION.OUT)
380398
}
381399

382400
export const removeNodeById = (id: string) => {

0 commit comments

Comments
 (0)