@echovisionlab/geul-common 0.2.0 → 0.2.2
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 +21 -0
- package/package.json +1 -1
- package/src/collaboration/block-room-codec/ai-document-field-mutations.ts +19 -1
- package/src/collaboration/block-room-codec/inline-content-mutations.ts +857 -0
- package/src/collaboration/block-room-codec/inline-content-projection.ts +378 -0
- package/src/collaboration/block-room-codec/locale-presence.ts +14 -2
- package/src/collaboration/block-room-codec/materialization.ts +4 -3
- package/src/collaboration/block-room-codec.ts +1 -0
- package/src/page/block-fixtures.ts +6 -6
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
import type { JsonValue } from "@bufbuild/protobuf";
|
|
2
|
+
import {
|
|
3
|
+
richTextBlockCatalog,
|
|
4
|
+
richTextBlockKindByProtoCase,
|
|
5
|
+
type RichTextBlockKind,
|
|
6
|
+
} from "@echovisionlab/geul-proto/content/block_catalog.ts";
|
|
7
|
+
import * as Y from "yjs";
|
|
8
|
+
import {
|
|
9
|
+
arrayPath,
|
|
10
|
+
fail,
|
|
11
|
+
isCollaborativeTextPath,
|
|
12
|
+
toYValue,
|
|
13
|
+
} from "./internal.ts";
|
|
14
|
+
import {
|
|
15
|
+
fromYInlineContent,
|
|
16
|
+
projectedStyledTextRuns,
|
|
17
|
+
richTextStyleKeys,
|
|
18
|
+
} from "./inline-content-projection.ts";
|
|
19
|
+
import {
|
|
20
|
+
nodeKind,
|
|
21
|
+
payloadArray,
|
|
22
|
+
payloadParent,
|
|
23
|
+
roomNode,
|
|
24
|
+
type BlockRoomMutationOptions,
|
|
25
|
+
type BlockRoomPayloadRef,
|
|
26
|
+
} from "./room-access.ts";
|
|
27
|
+
|
|
28
|
+
type RichTextCatalogEntry = {
|
|
29
|
+
readonly content: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const richTextCatalog = richTextBlockCatalog as Readonly<
|
|
33
|
+
Record<string, RichTextCatalogEntry>
|
|
34
|
+
>;
|
|
35
|
+
const styleKeys = richTextStyleKeys();
|
|
36
|
+
|
|
37
|
+
type StyleValue = boolean | string;
|
|
38
|
+
type Style = Partial<Record<(typeof styleKeys)[number], StyleValue>>;
|
|
39
|
+
type StyledRun = { text: string; styles: Style };
|
|
40
|
+
|
|
41
|
+
interface RawTextSegment {
|
|
42
|
+
text?: Y.Text;
|
|
43
|
+
readonly styledText: Y.Map<unknown>;
|
|
44
|
+
readonly rawIndex: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface RawTextGroup {
|
|
48
|
+
readonly kind: "text";
|
|
49
|
+
rawStart: number;
|
|
50
|
+
rawEnd: number;
|
|
51
|
+
readonly segments: RawTextSegment[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface RawLinkGroup {
|
|
55
|
+
readonly kind: "link";
|
|
56
|
+
readonly rawStart: number;
|
|
57
|
+
readonly rawEnd: number;
|
|
58
|
+
readonly link: Y.Map<unknown>;
|
|
59
|
+
readonly content?: Y.Array<unknown>;
|
|
60
|
+
readonly segments: RawTextSegment[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface RawMathGroup {
|
|
64
|
+
readonly kind: "mathInline";
|
|
65
|
+
readonly rawStart: number;
|
|
66
|
+
readonly rawEnd: number;
|
|
67
|
+
readonly text: Y.Text;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RawHardBreakGroup {
|
|
71
|
+
readonly kind: "hardBreak";
|
|
72
|
+
readonly rawStart: number;
|
|
73
|
+
readonly rawEnd: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type RawGroup = RawTextGroup | RawLinkGroup | RawMathGroup | RawHardBreakGroup;
|
|
77
|
+
|
|
78
|
+
interface DesiredTextGroup {
|
|
79
|
+
readonly kind: "text";
|
|
80
|
+
readonly runs: StyledRun[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface DesiredLinkGroup {
|
|
84
|
+
readonly kind: "link";
|
|
85
|
+
readonly href: string;
|
|
86
|
+
readonly runs: StyledRun[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface DesiredMathGroup {
|
|
90
|
+
readonly kind: "mathInline";
|
|
91
|
+
readonly source: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface DesiredHardBreakGroup {
|
|
95
|
+
readonly kind: "hardBreak";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type DesiredGroup =
|
|
99
|
+
| DesiredTextGroup
|
|
100
|
+
| DesiredLinkGroup
|
|
101
|
+
| DesiredMathGroup
|
|
102
|
+
| DesiredHardBreakGroup;
|
|
103
|
+
|
|
104
|
+
function object(value: unknown, reason: string): Record<string, unknown> {
|
|
105
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
106
|
+
return fail(reason);
|
|
107
|
+
}
|
|
108
|
+
return value as Record<string, unknown>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function yMap(value: unknown, reason: string): Y.Map<unknown> {
|
|
112
|
+
if (!(value instanceof Y.Map)) {
|
|
113
|
+
return fail(reason);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkedStyles(value: unknown, reason: string): Style {
|
|
119
|
+
if (value === undefined || value === null) {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
const raw = object(value, reason);
|
|
123
|
+
const result: Style = {};
|
|
124
|
+
for (const key of styleKeys) {
|
|
125
|
+
const style = raw[key];
|
|
126
|
+
if (style === undefined || style === null) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (key === "textColor" || key === "backgroundColor") {
|
|
130
|
+
if (typeof style !== "string") {
|
|
131
|
+
return fail(`${reason}:${key}`);
|
|
132
|
+
}
|
|
133
|
+
} else if (typeof style !== "boolean") {
|
|
134
|
+
return fail(`${reason}:${key}`);
|
|
135
|
+
}
|
|
136
|
+
result[key] = style;
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeStyles(value: Style): Style {
|
|
142
|
+
const result: Style = {};
|
|
143
|
+
for (const key of styleKeys) {
|
|
144
|
+
const style = value[key];
|
|
145
|
+
if (style === undefined || style === false || style === "") {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
result[key] = style;
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function styleKey(value: Style): string {
|
|
154
|
+
return JSON.stringify(normalizeStyles(value));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function appendRun(target: StyledRun[], next: StyledRun): void {
|
|
158
|
+
const previous = target[target.length - 1];
|
|
159
|
+
if (previous && styleKey(previous.styles) === styleKey(next.styles)) {
|
|
160
|
+
previous.text += next.text;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
target.push({ text: next.text, styles: { ...next.styles } });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function appendSourceRun(target: StyledRun[], next: StyledRun): void {
|
|
167
|
+
if (next.text.length > 0) {
|
|
168
|
+
target.push({ text: next.text, styles: { ...next.styles } });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function styledInput(value: unknown, reason: string): StyledRun {
|
|
173
|
+
if (typeof value === "string") {
|
|
174
|
+
return { text: value, styles: {} };
|
|
175
|
+
}
|
|
176
|
+
const styled = object(value, reason);
|
|
177
|
+
if (typeof styled.text !== "string") {
|
|
178
|
+
return fail(`${reason}:text`);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
text: styled.text,
|
|
182
|
+
styles: checkedStyles(styled.styles, `${reason}:styles`),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function inputItem(value: JsonValue, reason: string): DesiredGroup {
|
|
187
|
+
const item = object(value, reason);
|
|
188
|
+
const cases = ["text", "link", "mathInline", "hardBreak"].filter(
|
|
189
|
+
(key) => item[key] !== undefined,
|
|
190
|
+
);
|
|
191
|
+
if (cases.length !== 1) {
|
|
192
|
+
return fail(`${reason}:oneof`);
|
|
193
|
+
}
|
|
194
|
+
const selected = cases[0]!;
|
|
195
|
+
if (selected === "text") {
|
|
196
|
+
const run = styledInput(item.text, `${reason}:text`);
|
|
197
|
+
return { kind: "text", runs: run.text ? [run] : [] };
|
|
198
|
+
}
|
|
199
|
+
if (selected === "link") {
|
|
200
|
+
const link = object(item.link, `${reason}:link`);
|
|
201
|
+
if (typeof link.href !== "string" || !Array.isArray(link.content)) {
|
|
202
|
+
return fail(`${reason}:link_shape`);
|
|
203
|
+
}
|
|
204
|
+
const runs: StyledRun[] = [];
|
|
205
|
+
link.content.forEach((child, index) => {
|
|
206
|
+
appendSourceRun(
|
|
207
|
+
runs,
|
|
208
|
+
styledInput(child, `${reason}:link_content:${index}`),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
return { kind: "link", href: link.href, runs };
|
|
212
|
+
}
|
|
213
|
+
if (selected === "mathInline") {
|
|
214
|
+
const math = object(item.mathInline, `${reason}:math`);
|
|
215
|
+
if (math.source !== undefined && typeof math.source !== "string") {
|
|
216
|
+
return fail(`${reason}:math_source`);
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
kind: "mathInline",
|
|
220
|
+
source: (math.source as string | undefined) ?? "",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { kind: "hardBreak" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function desiredGroups(values: readonly JsonValue[]): DesiredGroup[] {
|
|
227
|
+
const result: DesiredGroup[] = [];
|
|
228
|
+
values.forEach((value, index) => {
|
|
229
|
+
const group = inputItem(value, `inline_content:input:${index}`);
|
|
230
|
+
const previous = result[result.length - 1];
|
|
231
|
+
if (group.kind === "text" && previous?.kind === "text") {
|
|
232
|
+
for (const run of group.runs) {
|
|
233
|
+
appendSourceRun(previous.runs, run);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (group.kind === "text" && group.runs.length === 0) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
result.push(group);
|
|
241
|
+
});
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeInlineContent(values: readonly JsonValue[]): string {
|
|
246
|
+
const normalized = desiredGroups(values).map((group) => {
|
|
247
|
+
if (group.kind === "text") {
|
|
248
|
+
const canonicalRuns: StyledRun[] = [];
|
|
249
|
+
for (const run of group.runs) {
|
|
250
|
+
appendRun(canonicalRuns, run);
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
text: canonicalRuns.map((run) => ({
|
|
254
|
+
text: run.text,
|
|
255
|
+
styles: normalizeStyles(run.styles),
|
|
256
|
+
})),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (group.kind === "link") {
|
|
260
|
+
const canonicalRuns: StyledRun[] = [];
|
|
261
|
+
for (const run of group.runs) {
|
|
262
|
+
appendRun(canonicalRuns, run);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
link: {
|
|
266
|
+
href: group.href,
|
|
267
|
+
content: canonicalRuns.map((run) => ({
|
|
268
|
+
text: run.text,
|
|
269
|
+
styles: normalizeStyles(run.styles),
|
|
270
|
+
})),
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (group.kind === "mathInline") {
|
|
275
|
+
return { mathInline: { source: group.source } };
|
|
276
|
+
}
|
|
277
|
+
return { hardBreak: {} };
|
|
278
|
+
});
|
|
279
|
+
return JSON.stringify(normalized);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function rawStyledText(
|
|
283
|
+
value: unknown,
|
|
284
|
+
rawIndex: number,
|
|
285
|
+
reason: string,
|
|
286
|
+
): RawTextSegment {
|
|
287
|
+
const styledText = yMap(value, reason);
|
|
288
|
+
const text = styledText.get("text");
|
|
289
|
+
if (text !== undefined && !(text instanceof Y.Text)) {
|
|
290
|
+
return fail(`${reason}:text`);
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
...(text instanceof Y.Text ? { text } : {}),
|
|
294
|
+
styledText,
|
|
295
|
+
rawIndex,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function rawInlineItem(
|
|
300
|
+
array: Y.Array<unknown>,
|
|
301
|
+
index: number,
|
|
302
|
+
reason: string,
|
|
303
|
+
): RawGroup {
|
|
304
|
+
// reconcileArray first runs fromYInlineContent, which validates this map and
|
|
305
|
+
// its oneof before the raw handles are resolved.
|
|
306
|
+
const item = array.get(index) as Y.Map<unknown>;
|
|
307
|
+
const selected = ["text", "link", "mathInline", "hardBreak"].find((key) =>
|
|
308
|
+
item.has(key),
|
|
309
|
+
)!;
|
|
310
|
+
if (selected === "text") {
|
|
311
|
+
const segment = rawStyledText(item.get("text"), index, `${reason}:text`);
|
|
312
|
+
return {
|
|
313
|
+
kind: "text",
|
|
314
|
+
rawStart: index,
|
|
315
|
+
rawEnd: index + 1,
|
|
316
|
+
segments: [segment],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (selected === "link") {
|
|
320
|
+
const link = yMap(item.get("link"), `${reason}:link`);
|
|
321
|
+
const rawContent = link.get("content");
|
|
322
|
+
const content = rawContent as Y.Array<unknown> | undefined;
|
|
323
|
+
const segments =
|
|
324
|
+
content
|
|
325
|
+
?.toArray()
|
|
326
|
+
.map((child, childIndex) =>
|
|
327
|
+
rawStyledText(
|
|
328
|
+
child,
|
|
329
|
+
childIndex,
|
|
330
|
+
`${reason}:link_content:${childIndex}`,
|
|
331
|
+
),
|
|
332
|
+
) ?? [];
|
|
333
|
+
return {
|
|
334
|
+
kind: "link",
|
|
335
|
+
rawStart: index,
|
|
336
|
+
rawEnd: index + 1,
|
|
337
|
+
link,
|
|
338
|
+
content,
|
|
339
|
+
segments,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (selected === "mathInline") {
|
|
343
|
+
const math = yMap(item.get("mathInline"), `${reason}:math`);
|
|
344
|
+
const text = math.get("source");
|
|
345
|
+
if (!(text instanceof Y.Text)) {
|
|
346
|
+
return fail(`${reason}:math_source`);
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
kind: "mathInline",
|
|
350
|
+
rawStart: index,
|
|
351
|
+
rawEnd: index + 1,
|
|
352
|
+
text,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return { kind: "hardBreak", rawStart: index, rawEnd: index + 1 };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function rawGroups(array: Y.Array<unknown>): RawGroup[] {
|
|
359
|
+
const result: RawGroup[] = [];
|
|
360
|
+
for (let index = 0; index < array.length; index += 1) {
|
|
361
|
+
const item = rawInlineItem(array, index, `inline_content:raw:${index}`);
|
|
362
|
+
const previous = result[result.length - 1];
|
|
363
|
+
if (item.kind === "text" && previous?.kind === "text") {
|
|
364
|
+
previous.segments.push(...item.segments);
|
|
365
|
+
previous.rawEnd = item.rawEnd;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
result.push(item);
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function rawGroupKind(group: RawGroup): DesiredGroup["kind"] {
|
|
374
|
+
return group.kind;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
interface StyleSpan {
|
|
378
|
+
readonly from: number;
|
|
379
|
+
readonly to: number;
|
|
380
|
+
readonly styles: Style;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function styleSpans(runs: readonly StyledRun[], start = 0): StyleSpan[] {
|
|
384
|
+
const result: StyleSpan[] = [];
|
|
385
|
+
let from = start;
|
|
386
|
+
for (const run of runs) {
|
|
387
|
+
const to = from + run.text.length;
|
|
388
|
+
result.push({ from, to, styles: normalizeStyles(run.styles) });
|
|
389
|
+
from = to;
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function changedStyleAttributes(
|
|
395
|
+
before: Style,
|
|
396
|
+
after: Style,
|
|
397
|
+
): Record<string, StyleValue> {
|
|
398
|
+
const current = normalizeStyles(before);
|
|
399
|
+
const desired = normalizeStyles(after);
|
|
400
|
+
const result: Record<string, StyleValue> = {};
|
|
401
|
+
for (const key of styleKeys) {
|
|
402
|
+
if (current[key] === desired[key]) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const value = desired[key];
|
|
406
|
+
result[key] =
|
|
407
|
+
value !== undefined
|
|
408
|
+
? value
|
|
409
|
+
: key === "textColor" || key === "backgroundColor"
|
|
410
|
+
? ""
|
|
411
|
+
: false;
|
|
412
|
+
}
|
|
413
|
+
return result;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function minimalDiff(
|
|
417
|
+
before: string,
|
|
418
|
+
after: string,
|
|
419
|
+
): {
|
|
420
|
+
from: number;
|
|
421
|
+
oldTo: number;
|
|
422
|
+
newTo: number;
|
|
423
|
+
} {
|
|
424
|
+
let from = 0;
|
|
425
|
+
while (
|
|
426
|
+
from < before.length &&
|
|
427
|
+
from < after.length &&
|
|
428
|
+
before[from] === after[from]
|
|
429
|
+
) {
|
|
430
|
+
from += 1;
|
|
431
|
+
}
|
|
432
|
+
let oldTo = before.length;
|
|
433
|
+
let newTo = after.length;
|
|
434
|
+
while (
|
|
435
|
+
oldTo > from &&
|
|
436
|
+
newTo > from &&
|
|
437
|
+
before[oldTo - 1] === after[newTo - 1]
|
|
438
|
+
) {
|
|
439
|
+
oldTo -= 1;
|
|
440
|
+
newTo -= 1;
|
|
441
|
+
}
|
|
442
|
+
return { from, oldTo, newTo };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function segmentText(segment: RawTextSegment): string {
|
|
446
|
+
return segment.text?.toString() ?? "";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function ensureText(segment: RawTextSegment): Y.Text {
|
|
450
|
+
if (segment.text) {
|
|
451
|
+
return segment.text;
|
|
452
|
+
}
|
|
453
|
+
const text = new Y.Text();
|
|
454
|
+
segment.styledText.set("text", text);
|
|
455
|
+
segment.text = text;
|
|
456
|
+
return text;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function applyDesiredTextStyles(
|
|
460
|
+
segments: readonly RawTextSegment[],
|
|
461
|
+
runs: readonly StyledRun[],
|
|
462
|
+
): void {
|
|
463
|
+
const desiredSpans = styleSpans(runs);
|
|
464
|
+
let globalStart = 0;
|
|
465
|
+
for (const segment of segments) {
|
|
466
|
+
const segmentLength = segment.text!.length;
|
|
467
|
+
const segmentEnd = globalStart + segmentLength;
|
|
468
|
+
const currentRuns = projectedStyledTextRuns(
|
|
469
|
+
segment.styledText,
|
|
470
|
+
"inline_content:current_styles",
|
|
471
|
+
).map((value) => styledInput(value, "inline_content:current_style_run"));
|
|
472
|
+
const currentSpans = styleSpans(currentRuns, globalStart);
|
|
473
|
+
let position = globalStart;
|
|
474
|
+
let currentIndex = 0;
|
|
475
|
+
let desiredIndex = 0;
|
|
476
|
+
while (position < segmentEnd) {
|
|
477
|
+
while (
|
|
478
|
+
currentSpans[currentIndex] &&
|
|
479
|
+
currentSpans[currentIndex]!.to <= position
|
|
480
|
+
) {
|
|
481
|
+
currentIndex += 1;
|
|
482
|
+
}
|
|
483
|
+
while (
|
|
484
|
+
desiredSpans[desiredIndex] &&
|
|
485
|
+
desiredSpans[desiredIndex]!.to <= position
|
|
486
|
+
) {
|
|
487
|
+
desiredIndex += 1;
|
|
488
|
+
}
|
|
489
|
+
const currentSpan = currentSpans[currentIndex]!;
|
|
490
|
+
const desiredSpan = desiredSpans[desiredIndex]!;
|
|
491
|
+
const end = Math.min(segmentEnd, currentSpan.to, desiredSpan.to);
|
|
492
|
+
const attributes = changedStyleAttributes(
|
|
493
|
+
currentSpan.styles,
|
|
494
|
+
desiredSpan.styles,
|
|
495
|
+
);
|
|
496
|
+
if (Object.keys(attributes).length > 0) {
|
|
497
|
+
ensureText(segment).format(
|
|
498
|
+
position - globalStart,
|
|
499
|
+
end - position,
|
|
500
|
+
attributes,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
position = end;
|
|
504
|
+
}
|
|
505
|
+
globalStart = segmentEnd;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function reconcileTextSegments(
|
|
510
|
+
segments: RawTextSegment[],
|
|
511
|
+
array: Y.Array<unknown>,
|
|
512
|
+
rawInsertIndex: number,
|
|
513
|
+
desiredRuns: readonly StyledRun[],
|
|
514
|
+
path: string,
|
|
515
|
+
predicate: (path: string) => boolean,
|
|
516
|
+
): void {
|
|
517
|
+
const before = segments.map(segmentText).join("");
|
|
518
|
+
const after = desiredRuns.map((run) => run.text).join("");
|
|
519
|
+
|
|
520
|
+
const currentRunsBySegment = segments.map((segment) =>
|
|
521
|
+
projectedStyledTextRuns(
|
|
522
|
+
segment.styledText,
|
|
523
|
+
"inline_content:current_styles",
|
|
524
|
+
).map((value) => styledInput(value, "inline_content:current_style_run")),
|
|
525
|
+
);
|
|
526
|
+
const projectedRunCount = currentRunsBySegment.reduce(
|
|
527
|
+
(count, runs) => count + runs.length,
|
|
528
|
+
0,
|
|
529
|
+
);
|
|
530
|
+
if (
|
|
531
|
+
segments.length === desiredRuns.length &&
|
|
532
|
+
segments.length === projectedRunCount &&
|
|
533
|
+
currentRunsBySegment.every((runs) => runs.length === 1)
|
|
534
|
+
) {
|
|
535
|
+
segments.forEach((segment, index) => {
|
|
536
|
+
const currentText = segmentText(segment);
|
|
537
|
+
const desiredText = desiredRuns[index]!.text;
|
|
538
|
+
const diff = minimalDiff(currentText, desiredText);
|
|
539
|
+
if (diff.oldTo > diff.from) {
|
|
540
|
+
segment.text!.delete(diff.from, diff.oldTo - diff.from);
|
|
541
|
+
}
|
|
542
|
+
const inserted = desiredText.slice(diff.from, diff.newTo);
|
|
543
|
+
if (inserted.length > 0) {
|
|
544
|
+
ensureText(segment).insert(diff.from, inserted);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
applyDesiredTextStyles(segments, desiredRuns);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const diff = minimalDiff(before, after);
|
|
552
|
+
const starts: number[] = [];
|
|
553
|
+
let position = 0;
|
|
554
|
+
for (const segment of segments) {
|
|
555
|
+
starts.push(position);
|
|
556
|
+
position += segment.text?.length ?? 0;
|
|
557
|
+
}
|
|
558
|
+
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
|
559
|
+
const segment = segments[index]!;
|
|
560
|
+
const start = starts[index]!;
|
|
561
|
+
const end = start + (segment.text?.length ?? 0);
|
|
562
|
+
const deleteStart = Math.max(start, diff.from);
|
|
563
|
+
const deleteEnd = Math.min(end, diff.oldTo);
|
|
564
|
+
if (deleteEnd > deleteStart) {
|
|
565
|
+
segment.text!.delete(deleteStart - start, deleteEnd - deleteStart);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const inserted = desiredRuns
|
|
569
|
+
.map((run) => run.text)
|
|
570
|
+
.join("")
|
|
571
|
+
.slice(diff.from, diff.newTo);
|
|
572
|
+
if (inserted.length > 0) {
|
|
573
|
+
if (segments.length === 0) {
|
|
574
|
+
// Only an empty nested link can have no raw text segments. The top-level
|
|
575
|
+
// inline projection represents an empty text item with one segment.
|
|
576
|
+
const value = { text: inserted };
|
|
577
|
+
const raw = toYValue(value, arrayPath(path, rawInsertIndex), predicate);
|
|
578
|
+
array.insert(rawInsertIndex, [raw]);
|
|
579
|
+
segments.push(
|
|
580
|
+
rawStyledText(
|
|
581
|
+
array.get(rawInsertIndex),
|
|
582
|
+
rawInsertIndex,
|
|
583
|
+
"inline_content:new_link_text",
|
|
584
|
+
),
|
|
585
|
+
);
|
|
586
|
+
} else {
|
|
587
|
+
let consumed = 0;
|
|
588
|
+
let target = segments[segments.length - 1]!;
|
|
589
|
+
let localOffset = target.text?.length ?? 0;
|
|
590
|
+
for (const segment of segments) {
|
|
591
|
+
const length = segment.text?.length ?? 0;
|
|
592
|
+
if (diff.from <= consumed + length) {
|
|
593
|
+
target = segment;
|
|
594
|
+
localOffset = diff.from - consumed;
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
consumed += length;
|
|
598
|
+
}
|
|
599
|
+
ensureText(target).insert(localOffset, inserted);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
applyDesiredTextStyles(segments, desiredRuns);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function reconcileRawGroup(
|
|
606
|
+
raw: RawGroup,
|
|
607
|
+
desired: DesiredGroup,
|
|
608
|
+
array: Y.Array<unknown>,
|
|
609
|
+
path: string,
|
|
610
|
+
predicate: (path: string) => boolean,
|
|
611
|
+
): void {
|
|
612
|
+
if (raw.kind === "text" && desired.kind === "text") {
|
|
613
|
+
reconcileTextSegments(
|
|
614
|
+
raw.segments,
|
|
615
|
+
array,
|
|
616
|
+
raw.rawStart,
|
|
617
|
+
desired.runs,
|
|
618
|
+
path,
|
|
619
|
+
predicate,
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (raw.kind === "link" && desired.kind === "link") {
|
|
624
|
+
raw.link.set("href", desired.href);
|
|
625
|
+
const content = raw.content ?? new Y.Array<unknown>();
|
|
626
|
+
if (!raw.content && desired.runs.some((run) => run.text.length > 0)) {
|
|
627
|
+
raw.link.set("content", content);
|
|
628
|
+
}
|
|
629
|
+
reconcileTextSegments(
|
|
630
|
+
raw.segments,
|
|
631
|
+
content,
|
|
632
|
+
0,
|
|
633
|
+
desired.runs,
|
|
634
|
+
`${arrayPath(path, raw.rawStart)}.link.content`,
|
|
635
|
+
predicate,
|
|
636
|
+
);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (raw.kind === "mathInline" && desired.kind === "mathInline") {
|
|
640
|
+
const diff = minimalDiff(raw.text.toString(), desired.source);
|
|
641
|
+
if (diff.oldTo > diff.from) {
|
|
642
|
+
raw.text.delete(diff.from, diff.oldTo - diff.from);
|
|
643
|
+
}
|
|
644
|
+
const inserted = desired.source.slice(diff.from, diff.newTo);
|
|
645
|
+
if (inserted.length > 0) {
|
|
646
|
+
raw.text.insert(diff.from, inserted);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function createRawValue(group: DesiredGroup): JsonValue {
|
|
652
|
+
if (group.kind === "text") {
|
|
653
|
+
const text = group.runs.map((run) => run.text).join("");
|
|
654
|
+
return { text: { text } };
|
|
655
|
+
}
|
|
656
|
+
if (group.kind === "link") {
|
|
657
|
+
const text = group.runs.map((run) => run.text).join("");
|
|
658
|
+
return {
|
|
659
|
+
link: {
|
|
660
|
+
href: group.href,
|
|
661
|
+
content: text.length > 0 ? [{ text }] : [],
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (group.kind === "mathInline") {
|
|
666
|
+
return { mathInline: { source: group.source } };
|
|
667
|
+
}
|
|
668
|
+
return { hardBreak: {} };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function formatNewGroup(
|
|
672
|
+
array: Y.Array<unknown>,
|
|
673
|
+
index: number,
|
|
674
|
+
group: DesiredGroup,
|
|
675
|
+
): void {
|
|
676
|
+
if (group.kind !== "text" && group.kind !== "link") {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const raw = rawInlineItem(array, index, "inline_content:new_group") as
|
|
680
|
+
RawTextGroup | RawLinkGroup;
|
|
681
|
+
applyDesiredTextStyles(raw.segments, group.runs);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function inlinePathKind(kind: string, path: string): "inline" | "table" | null {
|
|
685
|
+
const catalogKind =
|
|
686
|
+
richTextBlockKindByProtoCase[
|
|
687
|
+
kind as keyof typeof richTextBlockKindByProtoCase
|
|
688
|
+
];
|
|
689
|
+
if (!catalogKind) {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
const entry = richTextCatalog[catalogKind as RichTextBlockKind]!;
|
|
693
|
+
if (entry.content === "inline" && path === "content") {
|
|
694
|
+
return "inline";
|
|
695
|
+
}
|
|
696
|
+
if (
|
|
697
|
+
entry.content === "table" &&
|
|
698
|
+
/^content\.rows\[\d+\]\.cells\[\d+\]\.content$/.test(path)
|
|
699
|
+
) {
|
|
700
|
+
return "table";
|
|
701
|
+
}
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function canonicalInlinePrevious(
|
|
706
|
+
current: readonly JsonValue[],
|
|
707
|
+
previous: readonly JsonValue[],
|
|
708
|
+
): void {
|
|
709
|
+
const currentKey = normalizeInlineContent(current);
|
|
710
|
+
const previousKey = normalizeInlineContent(previous);
|
|
711
|
+
if (currentKey !== previousKey) {
|
|
712
|
+
fail("stale_inline_content");
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function reconcileArray(
|
|
717
|
+
array: Y.Array<unknown>,
|
|
718
|
+
ref: BlockRoomPayloadRef,
|
|
719
|
+
kind: string,
|
|
720
|
+
previous: readonly JsonValue[],
|
|
721
|
+
next: readonly JsonValue[],
|
|
722
|
+
): void {
|
|
723
|
+
const current = fromYInlineContent(array);
|
|
724
|
+
canonicalInlinePrevious(current, previous);
|
|
725
|
+
const currentGroups = rawGroups(array);
|
|
726
|
+
const nextGroups = desiredGroups(next);
|
|
727
|
+
if (normalizeInlineContent(current) === normalizeInlineContent(next)) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const predicate = (textPath: string): boolean =>
|
|
731
|
+
isCollaborativeTextPath(ref.family, kind, textPath);
|
|
732
|
+
let prefix = 0;
|
|
733
|
+
while (
|
|
734
|
+
prefix < currentGroups.length &&
|
|
735
|
+
prefix < nextGroups.length &&
|
|
736
|
+
rawGroupKind(currentGroups[prefix]!) === nextGroups[prefix]!.kind
|
|
737
|
+
) {
|
|
738
|
+
reconcileRawGroup(
|
|
739
|
+
currentGroups[prefix]!,
|
|
740
|
+
nextGroups[prefix]!,
|
|
741
|
+
array,
|
|
742
|
+
ref.path,
|
|
743
|
+
predicate,
|
|
744
|
+
);
|
|
745
|
+
prefix += 1;
|
|
746
|
+
}
|
|
747
|
+
let suffix = 0;
|
|
748
|
+
while (
|
|
749
|
+
suffix < currentGroups.length - prefix &&
|
|
750
|
+
suffix < nextGroups.length - prefix &&
|
|
751
|
+
rawGroupKind(currentGroups[currentGroups.length - 1 - suffix]!) ===
|
|
752
|
+
nextGroups[nextGroups.length - 1 - suffix]!.kind
|
|
753
|
+
) {
|
|
754
|
+
suffix += 1;
|
|
755
|
+
}
|
|
756
|
+
for (let index = 0; index < suffix; index += 1) {
|
|
757
|
+
const raw = currentGroups[currentGroups.length - suffix + index]!;
|
|
758
|
+
const desired = nextGroups[nextGroups.length - suffix + index]!;
|
|
759
|
+
reconcileRawGroup(raw, desired, array, ref.path, predicate);
|
|
760
|
+
}
|
|
761
|
+
const removed = currentGroups.slice(prefix, currentGroups.length - suffix);
|
|
762
|
+
if (removed.length > 0) {
|
|
763
|
+
const start = removed[0]!.rawStart;
|
|
764
|
+
const end = removed[removed.length - 1]!.rawEnd;
|
|
765
|
+
array.delete(start, end - start);
|
|
766
|
+
}
|
|
767
|
+
const inserted = nextGroups.slice(prefix, nextGroups.length - suffix);
|
|
768
|
+
let rawIndex =
|
|
769
|
+
removed.length > 0
|
|
770
|
+
? removed[0]!.rawStart
|
|
771
|
+
: prefix < currentGroups.length
|
|
772
|
+
? currentGroups[prefix]!.rawStart
|
|
773
|
+
: array.length;
|
|
774
|
+
for (const group of inserted) {
|
|
775
|
+
const value = createRawValue(group);
|
|
776
|
+
const raw = toYValue(value, arrayPath(ref.path, rawIndex), predicate);
|
|
777
|
+
array.insert(rawIndex, [raw]);
|
|
778
|
+
formatNewGroup(array, rawIndex, group);
|
|
779
|
+
rawIndex += 1;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function ensureMissingInlineArray(
|
|
784
|
+
node: Y.Map<unknown>,
|
|
785
|
+
ref: BlockRoomPayloadRef,
|
|
786
|
+
): Y.Array<unknown> {
|
|
787
|
+
const { parent, key } = payloadParent(
|
|
788
|
+
node,
|
|
789
|
+
ref.path,
|
|
790
|
+
`node:${ref.id}:path:${ref.path}`,
|
|
791
|
+
);
|
|
792
|
+
const array = new Y.Array<unknown>();
|
|
793
|
+
(parent as Y.Map<unknown>).set(key as string, array);
|
|
794
|
+
return array;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export function isBlockRoomInlineContentRef(ref: BlockRoomPayloadRef): boolean {
|
|
798
|
+
return (
|
|
799
|
+
ref.family === "rich_text" &&
|
|
800
|
+
ref.locale === true &&
|
|
801
|
+
(ref.path === "content" ||
|
|
802
|
+
/^content\.rows\[\d+\]\.cells\[\d+\]\.content$/.test(ref.path))
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Reconciles canonical protobuf inline JSON with collaborative inline Y.Text.
|
|
808
|
+
* Text item indexes in `previous` and `next` are projections, not raw Y.Array
|
|
809
|
+
* indexes. Reconciliation therefore resolves all raw text handles from the
|
|
810
|
+
* validated current payload before applying any writes.
|
|
811
|
+
*/
|
|
812
|
+
export function reconcileBlockRoomInlineContent(
|
|
813
|
+
yDocument: Y.Doc,
|
|
814
|
+
ref: BlockRoomPayloadRef,
|
|
815
|
+
previous: readonly JsonValue[],
|
|
816
|
+
next: readonly JsonValue[],
|
|
817
|
+
options: BlockRoomMutationOptions = {},
|
|
818
|
+
): void {
|
|
819
|
+
yDocument.transact(() => {
|
|
820
|
+
if (ref.family !== "rich_text" || ref.locale !== true) {
|
|
821
|
+
fail(`node:${ref.id}:inline_content_scope:${ref.path}`);
|
|
822
|
+
}
|
|
823
|
+
const node = roomNode(yDocument, ref);
|
|
824
|
+
const kind = nodeKind(node, ref);
|
|
825
|
+
const pathKind = inlinePathKind(kind, ref.path);
|
|
826
|
+
if (!pathKind) {
|
|
827
|
+
fail(`node:${ref.id}:not_inline_content:${ref.path}`);
|
|
828
|
+
}
|
|
829
|
+
const parent = payloadParent(
|
|
830
|
+
node,
|
|
831
|
+
ref.path,
|
|
832
|
+
`node:${ref.id}:path:${ref.path}`,
|
|
833
|
+
);
|
|
834
|
+
const rawCurrent = (parent.parent as Y.Map<unknown>).get(
|
|
835
|
+
parent.key as string,
|
|
836
|
+
);
|
|
837
|
+
if (rawCurrent === undefined) {
|
|
838
|
+
const previousKey = normalizeInlineContent(previous);
|
|
839
|
+
const nextKey = normalizeInlineContent(next);
|
|
840
|
+
if (previousKey !== "[]") {
|
|
841
|
+
fail("stale_inline_content");
|
|
842
|
+
}
|
|
843
|
+
if (nextKey === "[]") {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const array = ensureMissingInlineArray(node, ref);
|
|
847
|
+
reconcileArray(array, ref, kind, previous, next);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const array = payloadArray(
|
|
851
|
+
node,
|
|
852
|
+
ref.path,
|
|
853
|
+
`node:${ref.id}:path:${ref.path}`,
|
|
854
|
+
);
|
|
855
|
+
reconcileArray(array, ref, kind, previous, next);
|
|
856
|
+
}, options.origin);
|
|
857
|
+
}
|