@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,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TimelineRunner — schedules and executes timed actions with drift correction.
|
|
3
|
+
*/
|
|
4
|
+
import type { TimelineEntry } from './types.ts'
|
|
5
|
+
import type { ActionExecutor } from './actions/index.ts'
|
|
6
|
+
import { sleep, log } from './utils.ts'
|
|
7
|
+
|
|
8
|
+
export type ErrorStrategy = 'log' | 'abort'
|
|
9
|
+
|
|
10
|
+
export interface TimelineRunnerOptions {
|
|
11
|
+
/** How to handle action errors. 'log' continues, 'abort' stops. Default: 'log'. */
|
|
12
|
+
onError?: ErrorStrategy
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class TimelineRunner {
|
|
16
|
+
private sceneStartTime = 0
|
|
17
|
+
private options: TimelineRunnerOptions
|
|
18
|
+
readonly errors: { entry: TimelineEntry; error: Error; elapsed: number }[] = []
|
|
19
|
+
|
|
20
|
+
constructor(options?: TimelineRunnerOptions) {
|
|
21
|
+
this.options = options ?? {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Get elapsed ms since scene start. */
|
|
25
|
+
get elapsed(): number {
|
|
26
|
+
return Date.now() - this.sceneStartTime
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Execute all timeline entries in order with proper timing. */
|
|
30
|
+
async run(timeline: TimelineEntry[], execute: ActionExecutor): Promise<void> {
|
|
31
|
+
const sorted = [...timeline].sort((a, b) => (a.at ?? 0) - (b.at ?? 0))
|
|
32
|
+
const errorStrategy = this.options.onError ?? 'log'
|
|
33
|
+
|
|
34
|
+
this.sceneStartTime = Date.now()
|
|
35
|
+
const pending: Promise<void>[] = []
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
38
|
+
const entry = sorted[i]!
|
|
39
|
+
const targetTime = entry.at ?? 0
|
|
40
|
+
const elapsed = Date.now() - this.sceneStartTime
|
|
41
|
+
const delay = targetTime - elapsed
|
|
42
|
+
|
|
43
|
+
if (delay > 0) {
|
|
44
|
+
await sleep(delay)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Log progress
|
|
48
|
+
const ts = `${((Date.now() - this.sceneStartTime) / 1000).toFixed(1)}s`
|
|
49
|
+
const actorLabel = entry.actor ?? '—'
|
|
50
|
+
log(`[${ts}] ${actorLabel}: ${entry.action.type}`)
|
|
51
|
+
|
|
52
|
+
const actionPromise = execute(entry).catch((err: Error) => {
|
|
53
|
+
const errorInfo = {
|
|
54
|
+
entry,
|
|
55
|
+
error: err,
|
|
56
|
+
elapsed: Date.now() - this.sceneStartTime,
|
|
57
|
+
}
|
|
58
|
+
this.errors.push(errorInfo)
|
|
59
|
+
log(`Error in ${entry.action.type}${entry.actor ? ` for ${entry.actor}` : ''}: ${err.message}`)
|
|
60
|
+
if (errorStrategy === 'abort') {
|
|
61
|
+
throw err
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Check if the next entry has the same timestamp — if so, run in parallel
|
|
66
|
+
const nextEntry = sorted[i + 1]
|
|
67
|
+
if (nextEntry && (nextEntry.at ?? 0) === targetTime) {
|
|
68
|
+
pending.push(actionPromise)
|
|
69
|
+
} else {
|
|
70
|
+
pending.push(actionPromise)
|
|
71
|
+
await Promise.all(pending)
|
|
72
|
+
pending.length = 0
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (pending.length) {
|
|
77
|
+
await Promise.all(pending)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.errors.length) {
|
|
81
|
+
log(`Timeline finished with ${this.errors.length} error(s)`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the orchestrator scene format.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ── Server & Actor ──────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface ServerConfig {
|
|
8
|
+
url: string
|
|
9
|
+
inviteCode?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ActorDef {
|
|
13
|
+
name: string
|
|
14
|
+
color: string
|
|
15
|
+
keyFile?: string
|
|
16
|
+
avatar?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Easing ──────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export type EasingFn = (t: number) => number
|
|
22
|
+
export type EasingName = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
|
|
23
|
+
|
|
24
|
+
// ── Actions ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface ConnectAction { type: 'connect' }
|
|
27
|
+
export interface DisconnectAction { type: 'disconnect' }
|
|
28
|
+
|
|
29
|
+
export interface NavigateAction {
|
|
30
|
+
type: 'navigate'
|
|
31
|
+
docId: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TypeAction {
|
|
35
|
+
type: 'type'
|
|
36
|
+
docId: string
|
|
37
|
+
text: string
|
|
38
|
+
/** ms per character (default 80) */
|
|
39
|
+
speed?: number
|
|
40
|
+
/** random variance +/- ms (default 30) */
|
|
41
|
+
variance?: number
|
|
42
|
+
/** character index to start typing at (default: end of doc) */
|
|
43
|
+
position?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SelectAction {
|
|
47
|
+
type: 'select'
|
|
48
|
+
docId: string
|
|
49
|
+
anchor: number
|
|
50
|
+
head: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MoveCursorAction {
|
|
54
|
+
type: 'moveCursor'
|
|
55
|
+
docId: string
|
|
56
|
+
from: number
|
|
57
|
+
to: number
|
|
58
|
+
duration: number
|
|
59
|
+
easing?: EasingName
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SetStatusAction {
|
|
63
|
+
type: 'setStatus'
|
|
64
|
+
status: string | null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SetAwarenessAction {
|
|
68
|
+
type: 'setAwareness'
|
|
69
|
+
/** If set, applies to child provider; otherwise root */
|
|
70
|
+
docId?: string
|
|
71
|
+
fields: Record<string, unknown>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ClearAwarenessAction {
|
|
75
|
+
type: 'clearAwareness'
|
|
76
|
+
docId?: string
|
|
77
|
+
fields: string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CreateDocumentAction {
|
|
81
|
+
type: 'createDocument'
|
|
82
|
+
parentId: string
|
|
83
|
+
label: string
|
|
84
|
+
docType?: string
|
|
85
|
+
meta?: Record<string, unknown>
|
|
86
|
+
/** Store created doc ID under this key in vars for later reference */
|
|
87
|
+
assignId?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface MoveDocumentAction {
|
|
91
|
+
type: 'moveDocument'
|
|
92
|
+
docId: string
|
|
93
|
+
newParentId: string
|
|
94
|
+
order?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface WriteContentAction {
|
|
98
|
+
type: 'writeContent'
|
|
99
|
+
docId: string
|
|
100
|
+
markdown: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DeleteContentAction {
|
|
104
|
+
type: 'deleteContent'
|
|
105
|
+
docId: string
|
|
106
|
+
from: number
|
|
107
|
+
length: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface SetMetaAction {
|
|
111
|
+
type: 'setMeta'
|
|
112
|
+
docId: string
|
|
113
|
+
meta: Record<string, unknown>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface PointerMoveAction {
|
|
117
|
+
type: 'pointerMove'
|
|
118
|
+
docId: string
|
|
119
|
+
from: { x: number; y: number }
|
|
120
|
+
to: { x: number; y: number }
|
|
121
|
+
duration: number
|
|
122
|
+
easing?: EasingName
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ScrollToAction {
|
|
126
|
+
type: 'scrollTo'
|
|
127
|
+
docId: string
|
|
128
|
+
/** 0-1 normalized scroll position */
|
|
129
|
+
position: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface KanbanHoverAction {
|
|
133
|
+
type: 'kanbanHover'
|
|
134
|
+
docId: string
|
|
135
|
+
cardId: string | null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface KanbanDragAction {
|
|
139
|
+
type: 'kanbanDrag'
|
|
140
|
+
docId: string
|
|
141
|
+
cardId: string
|
|
142
|
+
toColumnId: string
|
|
143
|
+
duration: number
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface RenameDocumentAction {
|
|
147
|
+
type: 'renameDocument'
|
|
148
|
+
docId: string
|
|
149
|
+
label: string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface SendChatAction {
|
|
153
|
+
type: 'sendChat'
|
|
154
|
+
/** Channel key (e.g. 'group:docId' or 'dm:key1:key2') */
|
|
155
|
+
channel: string
|
|
156
|
+
message: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface TypeDeleteAction {
|
|
160
|
+
type: 'typeDelete'
|
|
161
|
+
docId: string
|
|
162
|
+
/** Number of characters to delete (backspace) */
|
|
163
|
+
count: number
|
|
164
|
+
/** ms per deletion (default 60) */
|
|
165
|
+
speed?: number
|
|
166
|
+
/** random variance +/- ms (default 20) */
|
|
167
|
+
variance?: number
|
|
168
|
+
/** character index to start deleting from (default: end of doc) */
|
|
169
|
+
position?: number
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface RepeatAction {
|
|
173
|
+
type: 'repeat'
|
|
174
|
+
/** Number of times to repeat */
|
|
175
|
+
times: number
|
|
176
|
+
actions: TimelineEntry[]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface WaitAction {
|
|
180
|
+
type: 'wait'
|
|
181
|
+
duration: number
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ParallelAction {
|
|
185
|
+
type: 'parallel'
|
|
186
|
+
actions: TimelineEntry[]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface SequenceAction {
|
|
190
|
+
type: 'sequence'
|
|
191
|
+
actions: TimelineEntry[]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type Action =
|
|
195
|
+
| ConnectAction
|
|
196
|
+
| DisconnectAction
|
|
197
|
+
| NavigateAction
|
|
198
|
+
| TypeAction
|
|
199
|
+
| TypeDeleteAction
|
|
200
|
+
| SelectAction
|
|
201
|
+
| MoveCursorAction
|
|
202
|
+
| SetStatusAction
|
|
203
|
+
| SetAwarenessAction
|
|
204
|
+
| ClearAwarenessAction
|
|
205
|
+
| CreateDocumentAction
|
|
206
|
+
| MoveDocumentAction
|
|
207
|
+
| RenameDocumentAction
|
|
208
|
+
| WriteContentAction
|
|
209
|
+
| DeleteContentAction
|
|
210
|
+
| SetMetaAction
|
|
211
|
+
| PointerMoveAction
|
|
212
|
+
| ScrollToAction
|
|
213
|
+
| KanbanHoverAction
|
|
214
|
+
| KanbanDragAction
|
|
215
|
+
| SendChatAction
|
|
216
|
+
| WaitAction
|
|
217
|
+
| ParallelAction
|
|
218
|
+
| SequenceAction
|
|
219
|
+
| RepeatAction
|
|
220
|
+
|
|
221
|
+
// ── Timeline ────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
export interface TimelineEntry {
|
|
224
|
+
/** ms from scene start (or parent start for nested entries) */
|
|
225
|
+
at?: number
|
|
226
|
+
/** Which actor performs this action */
|
|
227
|
+
actor?: string
|
|
228
|
+
action: Action
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Scene ───────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
export interface Scene {
|
|
234
|
+
server: ServerConfig
|
|
235
|
+
actors: ActorDef[]
|
|
236
|
+
timeline: TimelineEntry[]
|
|
237
|
+
/** Pre-defined variables (e.g. known doc IDs) */
|
|
238
|
+
vars?: Record<string, string>
|
|
239
|
+
/** Max scene duration in ms. Auto-cleanup after this time. */
|
|
240
|
+
duration?: number
|
|
241
|
+
/** Called after all actors are prepared but before timeline runs. */
|
|
242
|
+
onStart?: () => Promise<void> | void
|
|
243
|
+
/** Called after timeline completes (before cleanup). */
|
|
244
|
+
onEnd?: () => Promise<void> | void
|
|
245
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for the orchestrator.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Wait for a provider's `synced` event with a timeout. Resolves immediately if already synced. */
|
|
6
|
+
export function waitForSync(
|
|
7
|
+
provider: { on(event: string, cb: () => void): void; off(event: string, cb: () => void): void; synced?: boolean },
|
|
8
|
+
timeoutMs = 15000
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
// If already synced, resolve immediately
|
|
11
|
+
if (provider.synced) return Promise.resolve()
|
|
12
|
+
|
|
13
|
+
return new Promise<void>((resolve, reject) => {
|
|
14
|
+
const timer = setTimeout(() => {
|
|
15
|
+
provider.off('synced', handler)
|
|
16
|
+
reject(new Error(`Sync timed out after ${timeoutMs}ms`))
|
|
17
|
+
}, timeoutMs)
|
|
18
|
+
|
|
19
|
+
function handler() {
|
|
20
|
+
clearTimeout(timer)
|
|
21
|
+
resolve()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
provider.on('synced', handler)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sleep for a given number of milliseconds. */
|
|
29
|
+
export function sleep(ms: number): Promise<void> {
|
|
30
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Wraps a promise with a timeout. */
|
|
34
|
+
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message?: string): Promise<T> {
|
|
35
|
+
return new Promise<T>((resolve, reject) => {
|
|
36
|
+
const timer = setTimeout(
|
|
37
|
+
() => reject(new Error(message ?? `Operation timed out after ${timeoutMs}ms`)),
|
|
38
|
+
timeoutMs
|
|
39
|
+
)
|
|
40
|
+
promise.then(
|
|
41
|
+
(val) => { clearTimeout(timer); resolve(val) },
|
|
42
|
+
(err) => { clearTimeout(timer); reject(err) }
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Log to stderr with [orchestrator] prefix. */
|
|
48
|
+
export function log(msg: string): void {
|
|
49
|
+
console.error(`[orchestrator] ${msg}`)
|
|
50
|
+
}
|
package/src/yjs-utils.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Y.js document structure utilities for TipTap-compatible editing.
|
|
3
|
+
*
|
|
4
|
+
* TipTap documents have the structure:
|
|
5
|
+
* XmlFragment('default') → [documentHeader, documentMeta, ...body paragraphs]
|
|
6
|
+
*
|
|
7
|
+
* All text position functions skip documentHeader and documentMeta —
|
|
8
|
+
* character index 0 maps to the first character of the first body paragraph.
|
|
9
|
+
*/
|
|
10
|
+
import * as Y from 'yjs'
|
|
11
|
+
|
|
12
|
+
const SCHEMA_NODES = new Set(['documentHeader', 'documentMeta'])
|
|
13
|
+
|
|
14
|
+
/** Check if an XmlElement is a TipTap schema node (not body content). */
|
|
15
|
+
function isSchemaNode(el: Y.XmlElement): boolean {
|
|
16
|
+
return SCHEMA_NODES.has(el.nodeName)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the fragment has documentHeader and documentMeta nodes.
|
|
21
|
+
* Creates them if missing. Idempotent — safe to call multiple times.
|
|
22
|
+
*/
|
|
23
|
+
export function ensureDocumentStructure(fragment: Y.XmlFragment): void {
|
|
24
|
+
let hasHeader = false
|
|
25
|
+
let hasMeta = false
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
28
|
+
const child = fragment.get(i)
|
|
29
|
+
if (child instanceof Y.XmlElement) {
|
|
30
|
+
if (child.nodeName === 'documentHeader') hasHeader = true
|
|
31
|
+
if (child.nodeName === 'documentMeta') hasMeta = true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Insert in reverse order so indices stay correct
|
|
36
|
+
if (!hasMeta) {
|
|
37
|
+
const meta = new Y.XmlElement('documentMeta')
|
|
38
|
+
fragment.insert(hasHeader ? 1 : 0, [meta])
|
|
39
|
+
}
|
|
40
|
+
if (!hasHeader) {
|
|
41
|
+
const header = new Y.XmlElement('documentHeader')
|
|
42
|
+
fragment.insert(0, [header])
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Find the index where body content starts (after documentHeader + documentMeta).
|
|
48
|
+
*/
|
|
49
|
+
export function bodyStartIndex(fragment: Y.XmlFragment): number {
|
|
50
|
+
let idx = 0
|
|
51
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
52
|
+
const child = fragment.get(i)
|
|
53
|
+
if (child instanceof Y.XmlElement && isSchemaNode(child)) {
|
|
54
|
+
idx = i + 1
|
|
55
|
+
} else {
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return idx
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Walk the XmlFragment body (skipping schema nodes) to find the XmlText node
|
|
64
|
+
* and local offset for a given global character index.
|
|
65
|
+
*/
|
|
66
|
+
export function findTextPosition(
|
|
67
|
+
fragment: Y.XmlFragment,
|
|
68
|
+
globalIndex: number
|
|
69
|
+
): { text: Y.XmlText; offset: number } | null {
|
|
70
|
+
let consumed = 0
|
|
71
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
72
|
+
const child = fragment.get(i)
|
|
73
|
+
if (child instanceof Y.XmlElement) {
|
|
74
|
+
if (isSchemaNode(child)) continue
|
|
75
|
+
const result = findTextInElement(child, globalIndex - consumed)
|
|
76
|
+
if (result) return result
|
|
77
|
+
consumed += elementTextLength(child)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findTextInElement(
|
|
84
|
+
el: Y.XmlElement,
|
|
85
|
+
remaining: number
|
|
86
|
+
): { text: Y.XmlText; offset: number } | null {
|
|
87
|
+
for (let i = 0; i < el.length; i++) {
|
|
88
|
+
const child = el.get(i)
|
|
89
|
+
if (child instanceof Y.XmlText) {
|
|
90
|
+
if (remaining <= child.length) {
|
|
91
|
+
return { text: child, offset: remaining }
|
|
92
|
+
}
|
|
93
|
+
remaining -= child.length
|
|
94
|
+
} else if (child instanceof Y.XmlElement) {
|
|
95
|
+
const result = findTextInElement(child, remaining)
|
|
96
|
+
if (result) return result
|
|
97
|
+
remaining -= elementTextLength(child)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Total text length of an XmlElement (recursive). */
|
|
104
|
+
export function elementTextLength(el: Y.XmlElement): number {
|
|
105
|
+
let len = 0
|
|
106
|
+
for (let i = 0; i < el.length; i++) {
|
|
107
|
+
const child = el.get(i)
|
|
108
|
+
if (child instanceof Y.XmlText) {
|
|
109
|
+
len += child.length
|
|
110
|
+
} else if (child instanceof Y.XmlElement) {
|
|
111
|
+
len += elementTextLength(child)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return len
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Total text length of all body paragraphs (skips schema nodes). */
|
|
118
|
+
export function fragmentTextLength(fragment: Y.XmlFragment): number {
|
|
119
|
+
let len = 0
|
|
120
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
121
|
+
const child = fragment.get(i)
|
|
122
|
+
if (child instanceof Y.XmlElement && !isSchemaNode(child)) {
|
|
123
|
+
len += elementTextLength(child)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return len
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compute the global character index of a position within a known XmlText node.
|
|
131
|
+
* Re-derives from current document state — safe under concurrent edits.
|
|
132
|
+
* Only counts body content (skips schema nodes).
|
|
133
|
+
*/
|
|
134
|
+
export function globalIndexOf(
|
|
135
|
+
fragment: Y.XmlFragment,
|
|
136
|
+
targetText: Y.XmlText,
|
|
137
|
+
localOffset: number
|
|
138
|
+
): number {
|
|
139
|
+
let index = 0
|
|
140
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
141
|
+
const child = fragment.get(i)
|
|
142
|
+
if (child instanceof Y.XmlElement) {
|
|
143
|
+
if (isSchemaNode(child)) continue
|
|
144
|
+
const result = globalIndexInElement(child, targetText, localOffset, index)
|
|
145
|
+
if (result !== null) return result
|
|
146
|
+
index += elementTextLength(child)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return index + localOffset // fallback
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function globalIndexInElement(
|
|
153
|
+
el: Y.XmlElement,
|
|
154
|
+
targetText: Y.XmlText,
|
|
155
|
+
localOffset: number,
|
|
156
|
+
base: number
|
|
157
|
+
): number | null {
|
|
158
|
+
for (let i = 0; i < el.length; i++) {
|
|
159
|
+
const child = el.get(i)
|
|
160
|
+
if (child instanceof Y.XmlText) {
|
|
161
|
+
if (child === targetText) {
|
|
162
|
+
return base + localOffset
|
|
163
|
+
}
|
|
164
|
+
base += child.length
|
|
165
|
+
} else if (child instanceof Y.XmlElement) {
|
|
166
|
+
const result = globalIndexInElement(child, targetText, localOffset, base)
|
|
167
|
+
if (result !== null) return result
|
|
168
|
+
base += elementTextLength(child)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get or create the last body paragraph's XmlText.
|
|
176
|
+
* Skips documentHeader and documentMeta. Creates a new paragraph if none exist.
|
|
177
|
+
*/
|
|
178
|
+
export function getOrCreateLastParagraph(
|
|
179
|
+
fragment: Y.XmlFragment
|
|
180
|
+
): { text: Y.XmlText; globalOffset: number } {
|
|
181
|
+
// Search backwards for the last body paragraph
|
|
182
|
+
for (let i = fragment.length - 1; i >= 0; i--) {
|
|
183
|
+
const child = fragment.get(i)
|
|
184
|
+
if (child instanceof Y.XmlElement) {
|
|
185
|
+
if (isSchemaNode(child)) continue
|
|
186
|
+
if (child.nodeName === 'paragraph') {
|
|
187
|
+
for (let j = child.length - 1; j >= 0; j--) {
|
|
188
|
+
const grandchild = child.get(j)
|
|
189
|
+
if (grandchild instanceof Y.XmlText) {
|
|
190
|
+
return { text: grandchild, globalOffset: fragmentTextLength(fragment) }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Paragraph exists but has no XmlText — add one
|
|
194
|
+
const xt = new Y.XmlText()
|
|
195
|
+
child.insert(0, [xt])
|
|
196
|
+
return { text: xt, globalOffset: fragmentTextLength(fragment) }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// No body paragraph found — create one after schema nodes
|
|
202
|
+
const para = new Y.XmlElement('paragraph')
|
|
203
|
+
const xt = new Y.XmlText()
|
|
204
|
+
para.insert(0, [xt])
|
|
205
|
+
fragment.push([para])
|
|
206
|
+
return { text: xt, globalOffset: 0 }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Insert a new paragraph after the one containing the given global character index.
|
|
211
|
+
* Skips schema nodes when computing positions.
|
|
212
|
+
*/
|
|
213
|
+
export function insertParagraphAfter(
|
|
214
|
+
fragment: Y.XmlFragment,
|
|
215
|
+
afterGlobalIndex: number
|
|
216
|
+
): Y.XmlText {
|
|
217
|
+
let offset = 0
|
|
218
|
+
let insertAt = fragment.length
|
|
219
|
+
|
|
220
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
221
|
+
const child = fragment.get(i)
|
|
222
|
+
if (child instanceof Y.XmlElement) {
|
|
223
|
+
if (isSchemaNode(child)) continue
|
|
224
|
+
const elLen = elementTextLength(child)
|
|
225
|
+
if (offset + elLen >= afterGlobalIndex) {
|
|
226
|
+
insertAt = i + 1
|
|
227
|
+
break
|
|
228
|
+
}
|
|
229
|
+
offset += elLen
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const para = new Y.XmlElement('paragraph')
|
|
234
|
+
const xt = new Y.XmlText()
|
|
235
|
+
para.insert(0, [xt])
|
|
236
|
+
fragment.insert(insertAt, [para])
|
|
237
|
+
return xt
|
|
238
|
+
}
|