Skip to content

Commit a3b42d5

Browse files
author
yinlin124
committed
feat: collaborative-editing modules init and import quill-cursor modules
2 parents 75a3f4f + b63e91d commit a3b42d5

12 files changed

Lines changed: 546 additions & 1 deletion

File tree

packages/fluent-editor/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,15 @@
3434
"dependencies": {
3535
"lodash-es": "^4.17.15",
3636
"quill": "^2.0.0",
37+
"quill-cursors": "^4.0.4",
3738
"quill-easy-color": "^0.0.9",
38-
"quill-shortcut-key": "^0.0.5"
39+
"quill-shortcut-key": "^0.0.5",
40+
"y-indexeddb": "^9.0.12",
41+
"y-protocols": "^1.0.6",
42+
"y-quill": "^1.0.0",
43+
"y-webrtc": "10.3.0",
44+
"y-websocket": "^3.0.0",
45+
"yjs": "^13.6.27"
3946
},
4047
"devDependencies": {
4148
"@emoji-mart/data": "^1.2.1",

packages/fluent-editor/src/fluent-editor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import QuillCursors from 'quill-cursors'
12
import { FontStyle, LineHeightStyle, SizeStyle, TextIndentStyle } from './attributors'
23
import { EN_US } from './config/i18n/en-us'
34
import { ZH_CN } from './config/i18n/zh-cn'
45
import FluentEditor from './core/fluent-editor'
6+
import CollaborativeEditor from './modules/collaborative-editing'
57
import { EmojiBlot, SoftBreak, StrikeBlot, Video } from './formats'
68
import Counter from './modules/counter' // 字符统计
79
import { CustomClipboard } from './modules/custom-clipboard' // 粘贴板
@@ -45,6 +47,7 @@ FluentEditor.register(
4547
'formats/divider': DividerBlot,
4648
'formats/link': LinkBlot,
4749

50+
'modules/collaboration': CollaborativeEditor,
4851
'modules/clipboard': CustomClipboard,
4952
'modules/counter': Counter,
5053
'modules/emoji': EmojiModule,
@@ -57,6 +60,7 @@ FluentEditor.register(
5760
'modules/toolbar': BetterToolbar,
5861
'modules/uploader': FileUploader,
5962
'modules/shortcut-key': ShortCutKey,
63+
'modules/cursors': QuillCursors,
6064

6165
'themes/snow': SnowTheme,
6266

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { Awareness } from 'y-protocols/awareness'
2+
3+
export interface AwarenessState {
4+
name?: string
5+
color?: string
6+
}
7+
8+
export interface AwarenessEvents {
9+
change?: (changes: { added: number[], updated: number[], removed: number[] }, transactionOrigin: any) => void
10+
update?: ({ added, updated, removed }: { added: number[], updated: number[], removed: number[] }, origin: any) => void
11+
destroy?: () => void
12+
}
13+
14+
export interface AwarenessOptions {
15+
state?: AwarenessState
16+
events?: AwarenessEvents
17+
timeout?: number | undefined
18+
}
19+
20+
export function setupAwareness(options?: AwarenessOptions, defaultAwareness?: Awareness): Awareness | null {
21+
if (!defaultAwareness) return null
22+
23+
const awareness = defaultAwareness
24+
25+
if (options?.state) {
26+
awareness.setLocalStateField('user', options.state)
27+
}
28+
29+
return awareness
30+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './awareness'
2+
export * from './y-indexeddb'
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Doc } from 'yjs'
2+
import { IndexeddbPersistence } from 'y-indexeddb'
3+
4+
export interface IndexedDBOptions {
5+
dbName: string
6+
}
7+
8+
export function setupIndexedDB(doc: Doc, options?: IndexedDBOptions) {
9+
const id = 'tiny-editor'
10+
const dbName = options?.dbName || 'document'
11+
return new IndexeddbPersistence(`${id}-${dbName}`, doc)
12+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type FluentEditor from '../../fluent-editor'
2+
import type { YjsOptions } from './types'
3+
import { Awareness } from 'y-protocols/awareness'
4+
import { QuillBinding } from 'y-quill'
5+
import * as Y from 'yjs'
6+
import { setupAwareness } from './awareness'
7+
import { setupIndexedDB } from './awareness/y-indexeddb'
8+
import { createProvider } from './provider/customProvider'
9+
10+
export class CollaborativeEditor {
11+
private ydoc: Y.Doc = new Y.Doc()
12+
private provider: any
13+
private awareness: Awareness
14+
private _isConnected = false // 插件级别
15+
private _isSynced = false
16+
17+
constructor(
18+
public quill: FluentEditor,
19+
private options: YjsOptions,
20+
) {
21+
this.ydoc = this.options.ydoc || new Y.Doc()
22+
23+
if (this.options.awareness) {
24+
this.awareness = setupAwareness(this.options.awareness, new Awareness(this.ydoc))
25+
}
26+
27+
if (this.options.provider) {
28+
const providerConfig = this.options.provider
29+
try {
30+
// Create provider with shared handlers, Y.Doc, and Awareness
31+
const provider = createProvider({
32+
doc: this.ydoc,
33+
options: providerConfig.options,
34+
type: providerConfig.type,
35+
awareness: this.awareness,
36+
onConnect: () => {
37+
this._isConnected = true
38+
this.options.onConnect?.()
39+
},
40+
onDisconnect: () => {
41+
this._isConnected = false
42+
this.options.onDisconnect?.()
43+
},
44+
onError: (error) => {
45+
this.options.onError?.(error)
46+
},
47+
onSyncChange: (isSynced) => {
48+
this._isSynced = isSynced
49+
this.options.onSyncChange?.(isSynced)
50+
},
51+
})
52+
this.provider = provider
53+
}
54+
catch (error) {
55+
console.warn(
56+
`[yjs] Error creating provider of type ${providerConfig.type}:`,
57+
error,
58+
)
59+
}
60+
}
61+
62+
if (this.provider) {
63+
const ytext = this.ydoc.getText('tiny-editor')
64+
new QuillBinding(
65+
ytext,
66+
this.quill,
67+
this.awareness,
68+
)
69+
}
70+
else {
71+
console.error('Failed to initialize collaborative editor: no valid provider configured')
72+
}
73+
74+
if (this.options.offline) {
75+
setupIndexedDB(this.ydoc, typeof this.options.offline === 'object' ? this.options.offline : undefined)
76+
}
77+
}
78+
79+
public getAwareness(): Awareness {
80+
return this.awareness
81+
}
82+
83+
public getProvider() {
84+
return this.provider
85+
}
86+
87+
public getYDoc() {
88+
return this.ydoc
89+
}
90+
91+
get isConnected() {
92+
return this._isConnected
93+
}
94+
95+
get isSynced() {
96+
return this._isSynced
97+
}
98+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { CollaborativeEditor } from './collaborative-editing'
2+
3+
export const collaborationModule = {
4+
name: 'collaboration',
5+
component: CollaborativeEditor,
6+
}
7+
8+
export default CollaborativeEditor
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { Awareness } from 'y-protocols/awareness'
2+
import type * as Y from 'yjs'
3+
import type { ProviderEventHandlers } from '../types'
4+
import { WebRTCProviderWrapper } from './webrtc'
5+
import { WebsocketProviderWrapper } from './websocket'
6+
7+
export type ProviderRegistry = Record<string, ProviderConstructor>
8+
9+
export type ProviderConstructor<T = any> = new (
10+
props: ProviderConstructorProps<T>
11+
) => UnifiedProvider
12+
13+
export type ProviderConstructorProps<T = any> = {
14+
options: T
15+
awareness?: Awareness
16+
doc?: Y.Doc
17+
} & ProviderEventHandlers
18+
19+
export interface UnifiedProvider {
20+
awareness: Awareness
21+
document: Y.Doc
22+
type: 'webrtc' | 'websocket' | string
23+
connect: () => void
24+
destroy: () => void
25+
disconnect: () => void
26+
isConnected: boolean
27+
isSynced: boolean
28+
}
29+
30+
const providerRegistry: ProviderRegistry = {
31+
websocket: WebsocketProviderWrapper,
32+
webrtc: WebRTCProviderWrapper,
33+
}
34+
35+
export function registerProviderType<T>(type: string, providerClass: ProviderConstructor<T>) {
36+
providerRegistry[type as string]
37+
= providerClass as ProviderConstructor
38+
}
39+
40+
export function getProviderClass(type: string): ProviderConstructor | undefined {
41+
return providerRegistry[type]
42+
}
43+
44+
export function createProvider({
45+
type,
46+
...props
47+
}: ProviderConstructorProps & {
48+
type: string
49+
}) {
50+
const ProviderClass = getProviderClass(type)
51+
52+
if (!ProviderClass) {
53+
throw new Error(`Provider type "${type}" not found in registry`)
54+
}
55+
56+
return new ProviderClass(props)
57+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './customProvider'
2+
export * from './webrtc'
3+
export * from './websocket'
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { Awareness } from 'y-protocols/awareness'
2+
import type { ProviderEventHandlers } from '../types'
3+
import type { UnifiedProvider } from './customProvider'
4+
import { WebrtcProvider } from 'y-webrtc'
5+
import * as Y from 'yjs'
6+
7+
export interface WebRTCProviderOptions {
8+
roomname: string
9+
filterBcConns?: boolean
10+
maxConns?: number
11+
password?: string
12+
peerOpts?: Record<string, unknown>
13+
signaling?: string[]
14+
}
15+
16+
export class WebRTCProviderWrapper implements UnifiedProvider {
17+
private provider: WebrtcProvider
18+
private _isConnected = false
19+
private _isSynced = false
20+
private doc: Y.Doc
21+
22+
private onConnect?: () => void
23+
private onDisconnect?: () => void
24+
private onError?: (error: Error) => void
25+
private onSyncChange?: (isSynced: boolean) => void
26+
27+
connect = () => {
28+
try {
29+
this.provider.connect()
30+
}
31+
catch (error) {
32+
console.warn('[yjs] Error connecting WebRTC provider:', error)
33+
}
34+
}
35+
36+
destroy = () => {
37+
try {
38+
this.provider.destroy()
39+
}
40+
catch (error) {
41+
console.warn('[yjs] Error destroying WebRTC provider:', error)
42+
}
43+
}
44+
45+
disconnect = () => {
46+
try {
47+
this.provider.disconnect()
48+
this._isConnected = false
49+
this._isSynced = false
50+
}
51+
catch (error) {
52+
console.warn('[yjs] Error disconnecting WebRTC provider:', error)
53+
}
54+
}
55+
56+
constructor({
57+
awareness,
58+
doc,
59+
options,
60+
onConnect,
61+
onDisconnect,
62+
onError,
63+
onSyncChange,
64+
}: {
65+
options: WebRTCProviderOptions
66+
awareness?: Awareness
67+
doc?: Y.Doc
68+
} & ProviderEventHandlers) {
69+
this.onConnect = onConnect
70+
this.onDisconnect = onDisconnect
71+
this.onError = onError
72+
this.onSyncChange = onSyncChange
73+
74+
this.doc = doc || new Y.Doc()
75+
try {
76+
this.provider = new WebrtcProvider(options.roomname, this.doc, {
77+
awareness,
78+
...options,
79+
})
80+
81+
this.provider.on('status', (status: { connected: boolean }) => {
82+
const wasConnected = this._isConnected
83+
this._isConnected = status.connected
84+
if (status.connected) {
85+
if (!wasConnected) {
86+
this.onConnect?.()
87+
}
88+
if (!this._isSynced) {
89+
this._isSynced = true
90+
this.onSyncChange?.(true)
91+
}
92+
}
93+
else {
94+
if (wasConnected) {
95+
this.onDisconnect?.()
96+
97+
if (this._isSynced) {
98+
this._isSynced = false
99+
onSyncChange?.(false)
100+
}
101+
}
102+
}
103+
})
104+
}
105+
catch (error) {
106+
console.warn('[yjs] Error creating WebRTC provider:', error)
107+
onError?.(error instanceof Error ? error : new Error(String(error)))
108+
}
109+
}
110+
111+
type: 'webrtc'
112+
113+
get awareness(): Awareness {
114+
return this.provider!.awareness
115+
}
116+
117+
get document() {
118+
return this.doc
119+
}
120+
121+
get isConnected() {
122+
return this._isConnected
123+
}
124+
125+
get isSynced() {
126+
return this._isSynced
127+
}
128+
129+
getProvider() {
130+
return this.provider
131+
}
132+
}

0 commit comments

Comments
 (0)