@abraca/orchestrator 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +237 -0
- package/dist/abracadabra-orchestrator.cjs +2709 -0
- package/dist/abracadabra-orchestrator.cjs.map +1 -0
- package/dist/abracadabra-orchestrator.esm.js +2665 -0
- package/dist/abracadabra-orchestrator.esm.js.map +1 -0
- package/dist/index.d.ts +261 -0
- package/package.json +39 -0
- package/src/actions/awareness.ts +101 -0
- package/src/actions/chat.ts +30 -0
- package/src/actions/connect.ts +16 -0
- package/src/actions/content.ts +46 -0
- package/src/actions/cursor.ts +30 -0
- package/src/actions/document.ts +120 -0
- package/src/actions/flow.ts +46 -0
- package/src/actions/index.ts +197 -0
- package/src/actions/navigate.ts +15 -0
- package/src/actions/type.ts +113 -0
- package/src/actor-connection.ts +246 -0
- package/src/converters/markdownToYjs.ts +932 -0
- package/src/converters/types.ts +179 -0
- package/src/crypto.ts +71 -0
- package/src/define.ts +147 -0
- package/src/easing.ts +15 -0
- package/src/index.ts +105 -0
- package/src/orchestrator.ts +153 -0
- package/src/timeline-runner.ts +84 -0
- package/src/types.ts +245 -0
- package/src/utils.ts +50 -0
- package/src/yjs-utils.ts +238 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action dispatcher — routes Action.type to the correct executor.
|
|
3
|
+
*/
|
|
4
|
+
import type { ActorConnection } from '../actor-connection.ts'
|
|
5
|
+
import type { Action, TimelineEntry, ServerConfig } from '../types.ts'
|
|
6
|
+
import { executeConnect, executeDisconnect } from './connect.ts'
|
|
7
|
+
import { executeNavigate } from './navigate.ts'
|
|
8
|
+
import { executeType, executeTypeDelete } from './type.ts'
|
|
9
|
+
import { executeMoveCursor, executeSelect } from './cursor.ts'
|
|
10
|
+
import { executeWriteContent, executeDeleteContent } from './content.ts'
|
|
11
|
+
import {
|
|
12
|
+
executeSetStatus,
|
|
13
|
+
executeSetAwareness,
|
|
14
|
+
executeClearAwareness,
|
|
15
|
+
executePointerMove,
|
|
16
|
+
executeScrollTo,
|
|
17
|
+
executeKanbanHover,
|
|
18
|
+
executeKanbanDrag,
|
|
19
|
+
} from './awareness.ts'
|
|
20
|
+
import { executeCreateDocument, executeMoveDocument, executeSetMeta, executeRenameDocument } from './document.ts'
|
|
21
|
+
import { executeSendChat } from './chat.ts'
|
|
22
|
+
import { executeWait, executeParallel, executeSequence, executeRepeat } from './flow.ts'
|
|
23
|
+
|
|
24
|
+
export type ActionExecutor = (entry: TimelineEntry) => Promise<void>
|
|
25
|
+
|
|
26
|
+
export interface ActionContext {
|
|
27
|
+
actors: Map<string, ActorConnection>
|
|
28
|
+
serverConfig: ServerConfig
|
|
29
|
+
vars: Map<string, string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve variable references in string values.
|
|
34
|
+
* Replaces ${varName} with the value from vars map.
|
|
35
|
+
*/
|
|
36
|
+
function resolveVars(value: string, vars: Map<string, string>): string {
|
|
37
|
+
return value.replace(/\$\{(\w+)\}/g, (_, key) => vars.get(key) ?? `\${${key}}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveActionVars(action: Action, vars: Map<string, string>): Action {
|
|
41
|
+
if (vars.size === 0) return action
|
|
42
|
+
|
|
43
|
+
// Clone to avoid mutating the original scene definition
|
|
44
|
+
const a = { ...action } as any
|
|
45
|
+
|
|
46
|
+
// Resolve ID fields
|
|
47
|
+
if (a.docId && typeof a.docId === 'string') a.docId = resolveVars(a.docId, vars)
|
|
48
|
+
if (a.parentId && typeof a.parentId === 'string') a.parentId = resolveVars(a.parentId, vars)
|
|
49
|
+
if (a.newParentId && typeof a.newParentId === 'string') a.newParentId = resolveVars(a.newParentId, vars)
|
|
50
|
+
if (a.cardId && typeof a.cardId === 'string') a.cardId = resolveVars(a.cardId, vars)
|
|
51
|
+
if (a.toColumnId && typeof a.toColumnId === 'string') a.toColumnId = resolveVars(a.toColumnId, vars)
|
|
52
|
+
|
|
53
|
+
// Resolve content fields
|
|
54
|
+
if (a.text && typeof a.text === 'string') a.text = resolveVars(a.text, vars)
|
|
55
|
+
if (a.label && typeof a.label === 'string') a.label = resolveVars(a.label, vars)
|
|
56
|
+
if (a.markdown && typeof a.markdown === 'string') a.markdown = resolveVars(a.markdown, vars)
|
|
57
|
+
if (a.channel && typeof a.channel === 'string') a.channel = resolveVars(a.channel, vars)
|
|
58
|
+
if (a.message && typeof a.message === 'string') a.message = resolveVars(a.message, vars)
|
|
59
|
+
|
|
60
|
+
return a
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createExecutor(ctx: ActionContext): ActionExecutor {
|
|
64
|
+
const execute: ActionExecutor = async (entry: TimelineEntry) => {
|
|
65
|
+
const action = resolveActionVars(entry.action, ctx.vars)
|
|
66
|
+
const actor = entry.actor ? ctx.actors.get(entry.actor) : undefined
|
|
67
|
+
|
|
68
|
+
switch (action.type) {
|
|
69
|
+
case 'connect': {
|
|
70
|
+
if (!actor) throw new Error('connect requires an actor')
|
|
71
|
+
await executeConnect(actor, ctx.serverConfig)
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
case 'disconnect': {
|
|
75
|
+
if (!actor) throw new Error('disconnect requires an actor')
|
|
76
|
+
await executeDisconnect(actor)
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
case 'navigate': {
|
|
80
|
+
if (!actor) throw new Error('navigate requires an actor')
|
|
81
|
+
await executeNavigate(actor, action)
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
case 'type': {
|
|
85
|
+
if (!actor) throw new Error('type requires an actor')
|
|
86
|
+
await executeType(actor, action)
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
case 'typeDelete': {
|
|
90
|
+
if (!actor) throw new Error('typeDelete requires an actor')
|
|
91
|
+
await executeTypeDelete(actor, action)
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
case 'select': {
|
|
95
|
+
if (!actor) throw new Error('select requires an actor')
|
|
96
|
+
await executeSelect(actor, action)
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
case 'moveCursor': {
|
|
100
|
+
if (!actor) throw new Error('moveCursor requires an actor')
|
|
101
|
+
await executeMoveCursor(actor, action)
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
case 'setStatus': {
|
|
105
|
+
if (!actor) throw new Error('setStatus requires an actor')
|
|
106
|
+
await executeSetStatus(actor, action)
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
case 'setAwareness': {
|
|
110
|
+
if (!actor) throw new Error('setAwareness requires an actor')
|
|
111
|
+
await executeSetAwareness(actor, action)
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
case 'clearAwareness': {
|
|
115
|
+
if (!actor) throw new Error('clearAwareness requires an actor')
|
|
116
|
+
await executeClearAwareness(actor, action)
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
case 'createDocument': {
|
|
120
|
+
if (!actor) throw new Error('createDocument requires an actor')
|
|
121
|
+
await executeCreateDocument(actor, action, ctx.vars)
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
case 'moveDocument': {
|
|
125
|
+
if (!actor) throw new Error('moveDocument requires an actor')
|
|
126
|
+
await executeMoveDocument(actor, action)
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
case 'renameDocument': {
|
|
130
|
+
if (!actor) throw new Error('renameDocument requires an actor')
|
|
131
|
+
await executeRenameDocument(actor, action)
|
|
132
|
+
break
|
|
133
|
+
}
|
|
134
|
+
case 'writeContent': {
|
|
135
|
+
if (!actor) throw new Error('writeContent requires an actor')
|
|
136
|
+
await executeWriteContent(actor, action)
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
case 'deleteContent': {
|
|
140
|
+
if (!actor) throw new Error('deleteContent requires an actor')
|
|
141
|
+
await executeDeleteContent(actor, action)
|
|
142
|
+
break
|
|
143
|
+
}
|
|
144
|
+
case 'setMeta': {
|
|
145
|
+
if (!actor) throw new Error('setMeta requires an actor')
|
|
146
|
+
await executeSetMeta(actor, action)
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
case 'pointerMove': {
|
|
150
|
+
if (!actor) throw new Error('pointerMove requires an actor')
|
|
151
|
+
await executePointerMove(actor, action)
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
case 'scrollTo': {
|
|
155
|
+
if (!actor) throw new Error('scrollTo requires an actor')
|
|
156
|
+
await executeScrollTo(actor, action)
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
case 'kanbanHover': {
|
|
160
|
+
if (!actor) throw new Error('kanbanHover requires an actor')
|
|
161
|
+
await executeKanbanHover(actor, action)
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
case 'kanbanDrag': {
|
|
165
|
+
if (!actor) throw new Error('kanbanDrag requires an actor')
|
|
166
|
+
await executeKanbanDrag(actor, action)
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
case 'sendChat': {
|
|
170
|
+
if (!actor) throw new Error('sendChat requires an actor')
|
|
171
|
+
await executeSendChat(actor, action)
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
case 'wait': {
|
|
175
|
+
await executeWait(action)
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
case 'parallel': {
|
|
179
|
+
await executeParallel(action, execute)
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
case 'sequence': {
|
|
183
|
+
await executeSequence(action, execute)
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
case 'repeat': {
|
|
187
|
+
await executeRepeat(action, execute)
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
default: {
|
|
191
|
+
throw new Error(`Unknown action type: ${(action as any).type}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return execute
|
|
197
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigate action — sets which document an actor is viewing.
|
|
3
|
+
*/
|
|
4
|
+
import type { ActorConnection } from '../actor-connection.ts'
|
|
5
|
+
import type { NavigateAction } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
export async function executeNavigate(
|
|
8
|
+
actor: ActorConnection,
|
|
9
|
+
action: NavigateAction
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
// Set docId in root awareness (this is what the dashboard watches for presence)
|
|
12
|
+
actor.setRootAwareness('docId', action.docId)
|
|
13
|
+
// Pre-load the child provider so subsequent actions on this doc are fast
|
|
14
|
+
await actor.getChildProvider(action.docId)
|
|
15
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typewriter action — character-by-character text insertion with live cursor.
|
|
3
|
+
*
|
|
4
|
+
* TipTap Y.js structure: XmlFragment('default') contains:
|
|
5
|
+
* [documentHeader, documentMeta, ...body blocks]
|
|
6
|
+
* Each paragraph is an XmlElement('paragraph') containing XmlText nodes.
|
|
7
|
+
*
|
|
8
|
+
* All character indices are relative to body content only (schema nodes skipped).
|
|
9
|
+
*
|
|
10
|
+
* TypeDelete action — backspace simulation, deletes characters one at a time.
|
|
11
|
+
*/
|
|
12
|
+
import * as Y from 'yjs'
|
|
13
|
+
import type { ActorConnection } from '../actor-connection.ts'
|
|
14
|
+
import type { TypeAction, TypeDeleteAction } from '../types.ts'
|
|
15
|
+
import { sleep } from '../utils.ts'
|
|
16
|
+
import {
|
|
17
|
+
ensureDocumentStructure,
|
|
18
|
+
findTextPosition,
|
|
19
|
+
fragmentTextLength,
|
|
20
|
+
getOrCreateLastParagraph,
|
|
21
|
+
insertParagraphAfter,
|
|
22
|
+
globalIndexOf,
|
|
23
|
+
} from '../yjs-utils.ts'
|
|
24
|
+
|
|
25
|
+
export async function executeType(
|
|
26
|
+
actor: ActorConnection,
|
|
27
|
+
action: TypeAction
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const speed = action.speed ?? 80
|
|
30
|
+
const variance = action.variance ?? 30
|
|
31
|
+
const provider = await actor.getChildProvider(action.docId)
|
|
32
|
+
const doc = provider.document
|
|
33
|
+
const fragment = doc.getXmlFragment('default')
|
|
34
|
+
|
|
35
|
+
// Ensure TipTap schema nodes exist
|
|
36
|
+
doc.transact(() => ensureDocumentStructure(fragment))
|
|
37
|
+
|
|
38
|
+
// Determine starting position
|
|
39
|
+
const startIndex = action.position ?? fragmentTextLength(fragment)
|
|
40
|
+
|
|
41
|
+
// Find the XmlText node at the starting position
|
|
42
|
+
let currentText: Y.XmlText
|
|
43
|
+
let currentLocalOffset: number
|
|
44
|
+
|
|
45
|
+
const pos = findTextPosition(fragment, startIndex)
|
|
46
|
+
if (pos) {
|
|
47
|
+
currentText = pos.text
|
|
48
|
+
currentLocalOffset = pos.offset
|
|
49
|
+
} else {
|
|
50
|
+
const last = getOrCreateLastParagraph(fragment)
|
|
51
|
+
currentText = last.text
|
|
52
|
+
currentLocalOffset = currentText.length
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const char of action.text) {
|
|
56
|
+
const delay = Math.max(10, speed + (Math.random() * 2 - 1) * variance)
|
|
57
|
+
await sleep(delay)
|
|
58
|
+
|
|
59
|
+
if (char === '\n') {
|
|
60
|
+
// Re-derive global index from current node position (concurrent-safe)
|
|
61
|
+
const globalIdx = globalIndexOf(fragment, currentText, currentLocalOffset)
|
|
62
|
+
doc.transact(() => {
|
|
63
|
+
currentText = insertParagraphAfter(fragment, globalIdx)
|
|
64
|
+
currentLocalOffset = 0
|
|
65
|
+
})
|
|
66
|
+
} else {
|
|
67
|
+
doc.transact(() => {
|
|
68
|
+
currentText.insert(currentLocalOffset, char)
|
|
69
|
+
})
|
|
70
|
+
currentLocalOffset++
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Update cursor — re-derive global position for accuracy
|
|
74
|
+
const cursorGlobal = globalIndexOf(fragment, currentText, currentLocalOffset)
|
|
75
|
+
actor.setCursor(action.docId, cursorGlobal)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function executeTypeDelete(
|
|
80
|
+
actor: ActorConnection,
|
|
81
|
+
action: TypeDeleteAction
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const speed = action.speed ?? 60
|
|
84
|
+
const variance = action.variance ?? 20
|
|
85
|
+
const provider = await actor.getChildProvider(action.docId)
|
|
86
|
+
const doc = provider.document
|
|
87
|
+
const fragment = doc.getXmlFragment('default')
|
|
88
|
+
|
|
89
|
+
// Find starting position
|
|
90
|
+
const startIndex = action.position ?? fragmentTextLength(fragment)
|
|
91
|
+
let remaining = action.count
|
|
92
|
+
|
|
93
|
+
while (remaining > 0) {
|
|
94
|
+
const delay = Math.max(10, speed + (Math.random() * 2 - 1) * variance)
|
|
95
|
+
await sleep(delay)
|
|
96
|
+
|
|
97
|
+
// Re-find position each iteration (concurrent-safe)
|
|
98
|
+
const deleteAt = Math.max(0, startIndex - (action.count - remaining) - 1)
|
|
99
|
+
const pos = findTextPosition(fragment, deleteAt)
|
|
100
|
+
if (!pos || deleteAt < 0) break
|
|
101
|
+
|
|
102
|
+
doc.transact(() => {
|
|
103
|
+
if (pos.offset > 0) {
|
|
104
|
+
pos.text.delete(pos.offset - 1, 1)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
remaining--
|
|
109
|
+
|
|
110
|
+
// Update cursor
|
|
111
|
+
actor.setCursor(action.docId, Math.max(0, deleteAt))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActorConnection — manages a single actor's connection to the Abracadabra server.
|
|
3
|
+
* One instance per actor. Handles auth, providers, child caching, awareness, and cursors.
|
|
4
|
+
*/
|
|
5
|
+
import * as Y from 'yjs'
|
|
6
|
+
import { AbracadabraProvider, AbracadabraClient } from '@abraca/dabra'
|
|
7
|
+
import type { ActorDef, ServerConfig } from './types.ts'
|
|
8
|
+
import { loadOrCreateKeypair, deterministicKeypair, signChallenge } from './crypto.ts'
|
|
9
|
+
import { waitForSync, sleep, log } from './utils.ts'
|
|
10
|
+
import { findTextPosition, fragmentTextLength } from './yjs-utils.ts'
|
|
11
|
+
|
|
12
|
+
interface CachedChild {
|
|
13
|
+
provider: AbracadabraProvider
|
|
14
|
+
lastAccessed: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ActorConnection {
|
|
18
|
+
readonly actor: ActorDef
|
|
19
|
+
readonly client: AbracadabraClient
|
|
20
|
+
private _rootDocId: string | null = null
|
|
21
|
+
private _rootDoc: Y.Doc | null = null
|
|
22
|
+
private _rootProvider: AbracadabraProvider | null = null
|
|
23
|
+
private _publicKey: string | null = null
|
|
24
|
+
private _childCache = new Map<string, CachedChild>()
|
|
25
|
+
|
|
26
|
+
constructor(actor: ActorDef, serverConfig: ServerConfig) {
|
|
27
|
+
this.actor = actor
|
|
28
|
+
// AbracadabraClient expects an HTTP(S) URL — normalize ws(s):// if provided
|
|
29
|
+
const httpUrl = serverConfig.url
|
|
30
|
+
.replace(/^wss:\/\//, 'https://')
|
|
31
|
+
.replace(/^ws:\/\//, 'http://')
|
|
32
|
+
this.client = new AbracadabraClient({ url: httpUrl })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get rootProvider(): AbracadabraProvider | null {
|
|
36
|
+
return this._rootProvider
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get rootDocId(): string | null {
|
|
40
|
+
return this._rootDocId
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Connect, authenticate, discover root doc, sync, and set awareness. */
|
|
44
|
+
async connect(serverConfig: ServerConfig): Promise<void> {
|
|
45
|
+
// Step 1: Get keypair (from file or deterministic from name)
|
|
46
|
+
const keypair = this.actor.keyFile
|
|
47
|
+
? await loadOrCreateKeypair(this.actor.keyFile)
|
|
48
|
+
: deterministicKeypair(this.actor.name)
|
|
49
|
+
this._publicKey = keypair.publicKeyB64
|
|
50
|
+
const signFn = (challenge: string) =>
|
|
51
|
+
Promise.resolve(signChallenge(challenge, keypair.privateKey))
|
|
52
|
+
|
|
53
|
+
// Step 2: Auth (auto-register on first run)
|
|
54
|
+
try {
|
|
55
|
+
await this.client.loginWithKey(keypair.publicKeyB64, signFn)
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
const status = err?.status ?? err?.response?.status
|
|
58
|
+
if (status === 404 || status === 422) {
|
|
59
|
+
log(`${this.actor.name}: registering new account...`)
|
|
60
|
+
await this.client.registerWithKey({
|
|
61
|
+
publicKey: keypair.publicKeyB64,
|
|
62
|
+
username: this.actor.name.replace(/\s+/g, '-').toLowerCase(),
|
|
63
|
+
displayName: this.actor.name,
|
|
64
|
+
deviceName: 'Orchestrator Actor',
|
|
65
|
+
inviteCode: serverConfig.inviteCode,
|
|
66
|
+
})
|
|
67
|
+
await this.client.loginWithKey(keypair.publicKeyB64, signFn)
|
|
68
|
+
} else {
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
log(`${this.actor.name}: authenticated (${keypair.publicKeyB64.slice(0, 12)}...)`)
|
|
73
|
+
|
|
74
|
+
// Step 3: Pick an entry-point doc — first Space under the server root,
|
|
75
|
+
// falling back to the first top-level doc of any kind.
|
|
76
|
+
const roots = await this.client.listChildren()
|
|
77
|
+
const firstSpace = roots.find((d) => d.kind === 'space')
|
|
78
|
+
const rootDocId = (firstSpace ?? roots[0])?.id ?? null
|
|
79
|
+
|
|
80
|
+
if (!rootDocId) {
|
|
81
|
+
throw new Error(`${this.actor.name}: no root document found`)
|
|
82
|
+
}
|
|
83
|
+
this._rootDocId = rootDocId
|
|
84
|
+
|
|
85
|
+
// Step 4: Connect provider
|
|
86
|
+
const doc = new Y.Doc({ guid: rootDocId })
|
|
87
|
+
const provider = new AbracadabraProvider({
|
|
88
|
+
name: rootDocId,
|
|
89
|
+
document: doc,
|
|
90
|
+
client: this.client,
|
|
91
|
+
disableOfflineStore: true,
|
|
92
|
+
subdocLoading: 'lazy',
|
|
93
|
+
})
|
|
94
|
+
await waitForSync(provider)
|
|
95
|
+
|
|
96
|
+
this._rootDoc = doc
|
|
97
|
+
this._rootProvider = provider
|
|
98
|
+
|
|
99
|
+
// Step 5: Set awareness — actors appear as humans (isAgent: false)
|
|
100
|
+
provider.awareness.setLocalStateField('user', {
|
|
101
|
+
name: this.actor.name,
|
|
102
|
+
color: this.actor.color,
|
|
103
|
+
publicKey: this._publicKey,
|
|
104
|
+
isAgent: false,
|
|
105
|
+
})
|
|
106
|
+
provider.awareness.setLocalStateField('status', null)
|
|
107
|
+
|
|
108
|
+
log(`${this.actor.name}: connected to space ${rootDocId}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Get or create a child provider for a document. Caches and sets awareness. */
|
|
112
|
+
async getChildProvider(docId: string): Promise<AbracadabraProvider> {
|
|
113
|
+
const cached = this._childCache.get(docId)
|
|
114
|
+
if (cached) {
|
|
115
|
+
cached.lastAccessed = Date.now()
|
|
116
|
+
return cached.provider
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!this._rootProvider) {
|
|
120
|
+
throw new Error(`${this.actor.name}: not connected`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const childProvider = await this._rootProvider.loadChild(docId)
|
|
124
|
+
await waitForSync(childProvider)
|
|
125
|
+
|
|
126
|
+
childProvider.awareness.setLocalStateField('user', {
|
|
127
|
+
name: this.actor.name,
|
|
128
|
+
color: this.actor.color,
|
|
129
|
+
publicKey: this._publicKey,
|
|
130
|
+
isAgent: false,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
this._childCache.set(docId, {
|
|
134
|
+
provider: childProvider,
|
|
135
|
+
lastAccessed: Date.now(),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return childProvider
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Set cursor position in a document (collapsed — anchor = head).
|
|
143
|
+
*
|
|
144
|
+
* TipTap's yCursorPlugin reads awareness field 'cursor' as
|
|
145
|
+
* { anchor: RelativePosJSON, head: RelativePosJSON }.
|
|
146
|
+
* Positions must point into XmlText nodes (not the fragment) with assoc=-1.
|
|
147
|
+
*/
|
|
148
|
+
setCursor(docId: string, index: number): void {
|
|
149
|
+
const cached = this._childCache.get(docId)
|
|
150
|
+
if (!cached) return
|
|
151
|
+
|
|
152
|
+
const fragment = cached.provider.document.getXmlFragment('default')
|
|
153
|
+
const relPos = this._charIndexToRelativePosition(fragment, index)
|
|
154
|
+
if (!relPos) return
|
|
155
|
+
|
|
156
|
+
const json = Y.relativePositionToJSON(relPos)
|
|
157
|
+
cached.provider.awareness.setLocalStateField('cursor', {
|
|
158
|
+
anchor: json,
|
|
159
|
+
head: json,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Set text selection range in a document (anchor != head). */
|
|
164
|
+
setSelection(docId: string, anchor: number, head: number): void {
|
|
165
|
+
const cached = this._childCache.get(docId)
|
|
166
|
+
if (!cached) return
|
|
167
|
+
|
|
168
|
+
const fragment = cached.provider.document.getXmlFragment('default')
|
|
169
|
+
const anchorPos = this._charIndexToRelativePosition(fragment, anchor)
|
|
170
|
+
const headPos = this._charIndexToRelativePosition(fragment, head)
|
|
171
|
+
if (!anchorPos || !headPos) return
|
|
172
|
+
|
|
173
|
+
cached.provider.awareness.setLocalStateField('cursor', {
|
|
174
|
+
anchor: Y.relativePositionToJSON(anchorPos),
|
|
175
|
+
head: Y.relativePositionToJSON(headPos),
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Convert a character index (body text only, skipping schema nodes)
|
|
181
|
+
* to a Y.js RelativePosition pointing into the correct XmlText node.
|
|
182
|
+
* Uses assoc=-1 (associate-left) to match TipTap's cursor behavior.
|
|
183
|
+
*/
|
|
184
|
+
private _charIndexToRelativePosition(
|
|
185
|
+
fragment: Y.XmlFragment,
|
|
186
|
+
charIndex: number
|
|
187
|
+
): Y.RelativePosition | null {
|
|
188
|
+
const totalLen = fragmentTextLength(fragment)
|
|
189
|
+
const clamped = Math.max(0, Math.min(charIndex, totalLen))
|
|
190
|
+
|
|
191
|
+
if (clamped === 0) {
|
|
192
|
+
// Position at start of fragment — use assoc=-1 on the fragment itself
|
|
193
|
+
return Y.createRelativePositionFromTypeIndex(fragment, 0, -1)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pos = findTextPosition(fragment, clamped)
|
|
197
|
+
if (pos) {
|
|
198
|
+
return Y.createRelativePositionFromTypeIndex(pos.text, pos.offset, -1)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Fallback: end of fragment
|
|
202
|
+
return Y.createRelativePositionFromTypeIndex(fragment, fragment.length, -1)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Set a field on root awareness. */
|
|
206
|
+
setRootAwareness(field: string, value: unknown): void {
|
|
207
|
+
this._rootProvider?.awareness.setLocalStateField(field, value)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Set a field on a child document's awareness. */
|
|
211
|
+
setChildAwareness(docId: string, field: string, value: unknown): void {
|
|
212
|
+
const cached = this._childCache.get(docId)
|
|
213
|
+
cached?.provider.awareness.setLocalStateField(field, value)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Disconnect and clean up all providers. */
|
|
217
|
+
async disconnect(): Promise<void> {
|
|
218
|
+
// Clear awareness on children
|
|
219
|
+
for (const [, cached] of this._childCache) {
|
|
220
|
+
cached.provider.awareness.setLocalStateField('cursor', null)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Clear root awareness
|
|
224
|
+
if (this._rootProvider) {
|
|
225
|
+
this._rootProvider.awareness.setLocalStateField('status', null)
|
|
226
|
+
this._rootProvider.awareness.setLocalStateField('docId', null)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Wait for awareness updates to propagate before destroying
|
|
230
|
+
await sleep(150)
|
|
231
|
+
|
|
232
|
+
for (const [, cached] of this._childCache) {
|
|
233
|
+
cached.provider.destroy()
|
|
234
|
+
}
|
|
235
|
+
this._childCache.clear()
|
|
236
|
+
|
|
237
|
+
if (this._rootProvider) {
|
|
238
|
+
this._rootProvider.destroy()
|
|
239
|
+
this._rootProvider = null
|
|
240
|
+
}
|
|
241
|
+
this._rootDoc = null
|
|
242
|
+
this._rootDocId = null
|
|
243
|
+
|
|
244
|
+
log(`${this.actor.name}: disconnected`)
|
|
245
|
+
}
|
|
246
|
+
}
|