@echovisionlab/geul-common 0.2.1 → 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 CHANGED
@@ -26,6 +26,27 @@ pnpm package:smoke
26
26
  local changes, `typecheck:local-contracts` and `test:local-contracts` resolve it
27
27
  from the sibling `geul-event-contracts` checkout.
28
28
 
29
+ ## Block Room inline content
30
+
31
+ Use `reconcileBlockRoomInlineContent` to set rich-text inline runs, including
32
+ their marks. It keeps the existing collaborative `Y.Text` handles and projects
33
+ character-level Y.Text attributes over the legacy per-run `styles` maps.
34
+ Deploy readers of character-level inline attributes before enabling writers.
35
+ The Web and Collab consumers use Block-room protocol version 2 to reject an
36
+ older editor before admission; the persisted canonical protobuf schema stays
37
+ unchanged.
38
+
39
+ `setAIDocumentField` routes whole inline `content` updates through this same
40
+ reconciler. `setBlockRoomAtomicValue` only writes catalog-declared scalar
41
+ fields; there is no generic payload-field setter for inline styles.
42
+
43
+ `replaceBlockRoomCollaborativeText` replaces the entire value of one
44
+ collaborative text leaf. It is intended for scalar text fields such as Page
45
+ section captions. On an inline text leaf, replacing the whole value also
46
+ removes that leaf's character-level Y.Text formatting while leaving any legacy
47
+ `styles` map in place. Use the inline reconciler when replacing inline text
48
+ that must retain or change marks.
49
+
29
50
  ## Release
30
51
 
31
52
  Release Please creates releases from `main`. npm publication uses GitHub
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@echovisionlab/geul-common",
3
3
  "description": "Shared TypeScript contracts and helpers for Geul editors, collaboration, localized content, media blocks, pages, posts, and runtime events.",
4
4
  "author": "state303 <state303@dsub.io>",
5
- "version": "0.2.1",
5
+ "version": "0.2.2",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "repository": {
@@ -15,6 +15,11 @@ import type {
15
15
  import * as Y from "yjs";
16
16
  import { fromYValue, isCollaborativeTextPath, toYValue } from "./internal.ts";
17
17
  import { blockRoomBaseNodes } from "./materialization.ts";
18
+ import { fromYInlineContent } from "./inline-content-projection.ts";
19
+ import {
20
+ isBlockRoomInlineContentRef,
21
+ reconcileBlockRoomInlineContent,
22
+ } from "./inline-content-mutations.ts";
18
23
  import {
19
24
  blockRoomLocaleValueRef,
20
25
  deleteBlockRoomLocalePresenceForBlocks,
@@ -166,6 +171,7 @@ function ensureYParent(node: Y.Map<unknown>, path: string): void {
166
171
  }
167
172
 
168
173
  function replaceYValue(
174
+ yDocument: Y.Doc,
169
175
  node: Y.Map<unknown>,
170
176
  ref: BlockRoomPayloadRef,
171
177
  value: JsonValue,
@@ -185,6 +191,15 @@ function replaceYValue(
185
191
  return;
186
192
  }
187
193
  if (current instanceof Y.Array && Array.isArray(value)) {
194
+ if (isBlockRoomInlineContentRef(ref)) {
195
+ reconcileBlockRoomInlineContent(
196
+ yDocument,
197
+ ref,
198
+ fromYInlineContent(current),
199
+ value,
200
+ );
201
+ return;
202
+ }
188
203
  if (current.length > 0) current.delete(0, current.length);
189
204
  if (value.length > 0) {
190
205
  current.insert(
@@ -301,6 +316,7 @@ function reconcileSourceTableLocale(yDocument: Y.Doc, blockId: string): void {
301
316
  };
302
317
  });
303
318
  replaceYValue(
319
+ yDocument,
304
320
  localeNode,
305
321
  { id: blockId, family: "rich_text", locale: true, path: "content" },
306
322
  { rows } as JsonValue,
@@ -346,6 +362,7 @@ function reconcileSourceImmersiveLocale(
346
362
  },
347
363
  );
348
364
  replaceYValue(
365
+ yDocument,
349
366
  localeNode,
350
367
  {
351
368
  id: blockId,
@@ -500,6 +517,7 @@ export function setAIDocumentField(
500
517
  operationFail("target_locale_scalar_required");
501
518
  }
502
519
  replaceYValue(
520
+ yDocument,
503
521
  node,
504
522
  ref,
505
523
  target.field === "table" ? sourceTableValue(next) : next,
@@ -541,7 +559,7 @@ export function attachAIDocumentFile(
541
559
  const target = blockTarget(targetValue);
542
560
  const { ref, descriptor } = fieldRef(yDocument, target, targetValue, true);
543
561
  if (descriptor?.type !== "file_attachment") operationFail("file:field");
544
- replaceYValue(roomNode(yDocument, ref), ref, {
562
+ replaceYValue(yDocument, roomNode(yDocument, ref), ref, {
545
563
  activeFileId: requiredHandle(file, "file:handle"),
546
564
  });
547
565
  }
@@ -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
+ }
@@ -0,0 +1,378 @@
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
+ fail,
10
+ fromYValue,
11
+ type BlockRoomNodeFamily,
12
+ type JsonObject,
13
+ } from "./internal.ts";
14
+
15
+ const RICH_TEXT_STYLE_KEYS = [
16
+ "bold",
17
+ "italic",
18
+ "underline",
19
+ "strike",
20
+ "code",
21
+ "textColor",
22
+ "backgroundColor",
23
+ ] as const;
24
+
25
+ type RichTextStyleKey = (typeof RICH_TEXT_STYLE_KEYS)[number];
26
+ type RichTextStyleValue = boolean | string;
27
+ type RichTextStyle = Partial<Record<RichTextStyleKey, RichTextStyleValue>>;
28
+ type ProjectedTextRun = JsonObject & { text: string };
29
+
30
+ type RichTextCatalogEntry = {
31
+ readonly content: string;
32
+ };
33
+
34
+ const richTextCatalog = richTextBlockCatalog as Readonly<
35
+ Record<string, RichTextCatalogEntry>
36
+ >;
37
+
38
+ function record(value: unknown, reason: string): Record<string, unknown> {
39
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
40
+ return fail(reason);
41
+ }
42
+ return value as Record<string, unknown>;
43
+ }
44
+
45
+ function yRecord(value: unknown, reason: string): Record<string, unknown> {
46
+ if (value instanceof Y.Map) {
47
+ return Object.fromEntries(value.entries());
48
+ }
49
+ return record(value, reason);
50
+ }
51
+
52
+ function textStyle(value: unknown, reason: string): RichTextStyle {
53
+ if (value === undefined) {
54
+ return {};
55
+ }
56
+ let raw: Record<string, unknown>;
57
+ if (value instanceof Y.Map) {
58
+ raw = Object.fromEntries(value.entries());
59
+ } else {
60
+ raw = record(value, reason);
61
+ }
62
+ const result: RichTextStyle = {};
63
+ for (const key of RICH_TEXT_STYLE_KEYS) {
64
+ const style = raw[key];
65
+ if (typeof style === "boolean" || typeof style === "string") {
66
+ result[key] = style;
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+
72
+ function mergedTextStyle(
73
+ legacy: RichTextStyle,
74
+ attributes: Readonly<Record<string, unknown>> | undefined,
75
+ ): RichTextStyle {
76
+ const result: RichTextStyle = { ...legacy };
77
+ if (attributes === undefined) {
78
+ return result;
79
+ }
80
+ const raw = attributes;
81
+ for (const key of RICH_TEXT_STYLE_KEYS) {
82
+ const value = raw[key];
83
+ if (typeof value === "boolean" || typeof value === "string") {
84
+ result[key] = value;
85
+ }
86
+ }
87
+ return result;
88
+ }
89
+
90
+ function jsonStyle(style: RichTextStyle): JsonObject | undefined {
91
+ const result: JsonObject = {};
92
+ for (const key of RICH_TEXT_STYLE_KEYS) {
93
+ const value = style[key];
94
+ if (value !== undefined && value !== false && value !== "") {
95
+ result[key] = value;
96
+ }
97
+ }
98
+ return Object.keys(result).length > 0 ? result : undefined;
99
+ }
100
+
101
+ function appendProjectedText(
102
+ target: ProjectedTextRun[],
103
+ value: ProjectedTextRun,
104
+ ): void {
105
+ const text = value.text;
106
+ if (text.length === 0) {
107
+ target.push(value);
108
+ return;
109
+ }
110
+ const previous = target[target.length - 1];
111
+ if (previous) {
112
+ const previousText = previous.text;
113
+ if (
114
+ JSON.stringify(previous.styles ?? {}) ===
115
+ JSON.stringify(value.styles ?? {})
116
+ ) {
117
+ previous.text = previousText + text;
118
+ return;
119
+ }
120
+ }
121
+ target.push(value);
122
+ }
123
+
124
+ function styledTextMap(value: unknown, reason: string): Y.Map<unknown> {
125
+ if (!(value instanceof Y.Map)) {
126
+ return fail(reason);
127
+ }
128
+ return value;
129
+ }
130
+
131
+ function projectedTextField(
132
+ value: unknown,
133
+ styleValue: unknown,
134
+ reason: string,
135
+ ): ProjectedTextRun[] {
136
+ const text = value;
137
+ if (
138
+ text !== undefined &&
139
+ typeof text !== "string" &&
140
+ !(text instanceof Y.Text)
141
+ ) {
142
+ return fail(`${reason}:text`);
143
+ }
144
+ const legacyStyle = textStyle(styleValue, `${reason}:styles`);
145
+ if (typeof text === "string" || text === undefined) {
146
+ const emptyOrLegacy: ProjectedTextRun = {
147
+ text: typeof text === "string" ? text : "",
148
+ };
149
+ const style = jsonStyle(legacyStyle);
150
+ if (style) {
151
+ emptyOrLegacy.styles = style;
152
+ }
153
+ return [emptyOrLegacy];
154
+ }
155
+ const result: ProjectedTextRun[] = [];
156
+ const delta = text.toDelta();
157
+ if (delta.length === 0) {
158
+ const empty: ProjectedTextRun = { text: "" };
159
+ const style = jsonStyle(legacyStyle);
160
+ if (style) {
161
+ empty.styles = style;
162
+ }
163
+ return [empty];
164
+ }
165
+ for (const operation of delta) {
166
+ if (typeof operation.insert !== "string") {
167
+ return fail(`${reason}:embed`);
168
+ }
169
+ const item: ProjectedTextRun = { text: operation.insert };
170
+ const style = jsonStyle(mergedTextStyle(legacyStyle, operation.attributes));
171
+ if (style) {
172
+ item.styles = style;
173
+ }
174
+ appendProjectedText(result, item);
175
+ }
176
+ return result;
177
+ }
178
+
179
+ function projectedStyledText(
180
+ value: unknown,
181
+ reason: string,
182
+ ): ProjectedTextRun[] {
183
+ const styled = styledTextMap(value, reason);
184
+ const text = styled.get("text");
185
+ if (text === undefined) {
186
+ return projectedTextField(undefined, styled.get("styles"), reason);
187
+ }
188
+ if (text instanceof Y.Text || typeof text === "string") {
189
+ return projectedTextField(text, styled.get("styles"), reason);
190
+ }
191
+ if (!(text instanceof Y.Map)) {
192
+ return fail(`${reason}:text`);
193
+ }
194
+ const nestedText = text.get("text");
195
+ const legacyStyle = text.get("styles") ?? styled.get("styles");
196
+ return projectedTextField(nestedText, legacyStyle, reason);
197
+ }
198
+
199
+ function projectedLinkStyledText(
200
+ value: unknown,
201
+ reason: string,
202
+ ): ProjectedTextRun[] {
203
+ const styled = styledTextMap(value, reason);
204
+ return projectedTextField(styled.get("text"), styled.get("styles"), reason);
205
+ }
206
+
207
+ function projectedInlineItem(value: unknown, reason: string): JsonValue[] {
208
+ if (!(value instanceof Y.Map)) {
209
+ return fail(reason);
210
+ }
211
+ const cases = ["text", "link", "mathInline", "hardBreak"].filter((key) =>
212
+ value.has(key),
213
+ );
214
+ if (cases.length !== 1) {
215
+ return fail(`${reason}:oneof`);
216
+ }
217
+ const selected = cases[0]!;
218
+ if (selected === "text") {
219
+ return projectedStyledText(value.get("text"), `${reason}:text`).map(
220
+ (styledText) => ({ text: styledText }),
221
+ );
222
+ }
223
+ if (selected === "link") {
224
+ const link = yRecord(value.get("link"), `${reason}:link`);
225
+ const content = link.content;
226
+ if (content !== undefined && !(content instanceof Y.Array)) {
227
+ return fail(`${reason}:link_content`);
228
+ }
229
+ const projectedContent: ProjectedTextRun[] = [];
230
+ if (content instanceof Y.Array) {
231
+ content.toArray().forEach((child, index) => {
232
+ for (const run of projectedLinkStyledText(
233
+ child,
234
+ `${reason}:link_content:${index}`,
235
+ )) {
236
+ appendProjectedText(projectedContent, run);
237
+ }
238
+ });
239
+ }
240
+ const projectedLink: JsonObject = {
241
+ href: typeof link.href === "string" ? link.href : fail(`${reason}:href`),
242
+ content: projectedContent,
243
+ };
244
+ return [{ link: projectedLink }];
245
+ }
246
+ return [fromYValue(value)];
247
+ }
248
+
249
+ export function fromYInlineContent(value: unknown): JsonValue[] {
250
+ if (!(value instanceof Y.Array)) {
251
+ return fail("inline_content:array");
252
+ }
253
+ return value
254
+ .toArray()
255
+ .flatMap((item, index) =>
256
+ projectedInlineItem(item, `inline_content:${index}`),
257
+ );
258
+ }
259
+
260
+ function decodeOtherFields(
261
+ value: unknown,
262
+ excluded: string,
263
+ reason: string,
264
+ ): JsonObject {
265
+ if (!(value instanceof Y.Map)) {
266
+ return fail(reason);
267
+ }
268
+ return Object.fromEntries(
269
+ [...value.entries()]
270
+ .sort(([left], [right]) => left.localeCompare(right))
271
+ .filter(([key]) => key !== excluded)
272
+ .map(([key, child]) => [key, fromYValue(child)]),
273
+ );
274
+ }
275
+
276
+ function projectedTableContent(value: unknown): JsonValue {
277
+ const rawContent = yRecord(value, "inline_content:table_content");
278
+ const projected = decodeOtherFields(
279
+ value,
280
+ "rows",
281
+ "inline_content:table_json",
282
+ );
283
+ const rows = rawContent.rows;
284
+ if (!(rows instanceof Y.Array)) {
285
+ return fail("inline_content:table_rows");
286
+ }
287
+ projected.rows = rows.toArray().map((rawRow, rowIndex) => {
288
+ if (!(rawRow instanceof Y.Map)) {
289
+ return fail(`inline_content:table_row:${rowIndex}`);
290
+ }
291
+ const row = decodeOtherFields(
292
+ rawRow,
293
+ "cells",
294
+ `inline_content:table_row_json:${rowIndex}`,
295
+ );
296
+ const rawCells = rawRow.get("cells");
297
+ if (!(rawCells instanceof Y.Array)) {
298
+ return fail(`inline_content:table_cells:${rowIndex}`);
299
+ }
300
+ row.cells = rawCells.toArray().map((rawCell, cellIndex) => {
301
+ if (!(rawCell instanceof Y.Map)) {
302
+ return fail(`inline_content:table_cell:${rowIndex}:${cellIndex}`);
303
+ }
304
+ const cell = decodeOtherFields(
305
+ rawCell,
306
+ "content",
307
+ `inline_content:table_cell_json:${rowIndex}:${cellIndex}`,
308
+ );
309
+ const cellContent = rawCell.get("content");
310
+ if (cellContent !== undefined) {
311
+ cell.content = fromYInlineContent(cellContent);
312
+ }
313
+ return cell;
314
+ });
315
+ return row;
316
+ });
317
+ return projected as JsonValue;
318
+ }
319
+
320
+ /**
321
+ * Projects formatted inline Y.Text runs into the existing protobuf JSON shape.
322
+ * The raw storage decoder intentionally stays one-to-one with physical Y.Array
323
+ * items so all existing payload paths continue to use physical indexes.
324
+ */
325
+ export function fromYRichTextBlockPayload(
326
+ family: BlockRoomNodeFamily,
327
+ kind: string,
328
+ value: unknown,
329
+ ): JsonValue {
330
+ if (family !== "rich_text") {
331
+ return fromYValue(value);
332
+ }
333
+ const catalogKind =
334
+ richTextBlockKindByProtoCase[
335
+ kind as keyof typeof richTextBlockKindByProtoCase
336
+ ];
337
+ if (!catalogKind) {
338
+ return fromYValue(value);
339
+ }
340
+ const entry = richTextCatalog[catalogKind as RichTextBlockKind];
341
+ if (entry.content !== "inline" && entry.content !== "table") {
342
+ return fromYValue(value);
343
+ }
344
+ if (!(value instanceof Y.Map)) {
345
+ return record(
346
+ fromYValue(value),
347
+ "inline_content:payload_json",
348
+ ) as JsonValue;
349
+ }
350
+ const payload = Object.fromEntries(value.entries());
351
+ const projected = decodeOtherFields(
352
+ value,
353
+ "content",
354
+ "inline_content:payload_json",
355
+ );
356
+ const rawContent = payload.content;
357
+ if (rawContent === undefined) {
358
+ return projected as JsonValue;
359
+ }
360
+ if (entry.content === "inline") {
361
+ projected.content = fromYInlineContent(rawContent);
362
+ return projected as JsonValue;
363
+ }
364
+ const content = projectedTableContent(rawContent);
365
+ projected.content = content;
366
+ return projected as JsonValue;
367
+ }
368
+
369
+ export function richTextStyleKeys(): readonly RichTextStyleKey[] {
370
+ return RICH_TEXT_STYLE_KEYS;
371
+ }
372
+
373
+ export function projectedStyledTextRuns(
374
+ value: unknown,
375
+ reason = "inline_content:styled_text",
376
+ ): JsonValue[] {
377
+ return projectedStyledText(value, reason);
378
+ }
@@ -19,6 +19,7 @@ import {
19
19
  fromYValue,
20
20
  type JsonObject,
21
21
  } from "./internal.ts";
22
+ import { fromYInlineContent } from "./inline-content-projection.ts";
22
23
  import {
23
24
  blockRoomBaseNodes,
24
25
  blockRoomLocaleOverlay,
@@ -426,9 +427,20 @@ export function blockRoomLocaleValue(
426
427
  ): unknown {
427
428
  const ref = blockRoomLocaleValueRef(yDocument, target);
428
429
  try {
429
- return fromYValue(
430
- payloadValue(roomNode(yDocument, ref), ref.path, "locale_presence:value"),
430
+ const value = payloadValue(
431
+ roomNode(yDocument, ref),
432
+ ref.path,
433
+ "locale_presence:value",
431
434
  );
435
+ if (
436
+ ref.family === "rich_text" &&
437
+ (target.fieldHandle === "content" ||
438
+ target.fieldHandle === "tableContent") &&
439
+ value instanceof Y.Array
440
+ ) {
441
+ return fromYInlineContent(value);
442
+ }
443
+ return fromYValue(value);
432
444
  } catch {
433
445
  return localeValueDefault(yDocument, target);
434
446
  }
@@ -31,11 +31,11 @@ import {
31
31
  richTextSlot,
32
32
  stringValue,
33
33
  yMap,
34
- fromYValue,
35
34
  type BlockRoomDocumentType,
36
35
  type BlockRoomNodeFamily,
37
36
  type BlockRoomTypedDocument,
38
37
  } from "./internal.ts";
38
+ import { fromYRichTextBlockPayload } from "./inline-content-projection.ts";
39
39
 
40
40
  export function nodePayload(
41
41
  node: Y.Map<unknown>,
@@ -46,10 +46,11 @@ export function nodePayload(
46
46
  const family = stringValue(node.get("family"), `${reason}:family`);
47
47
  if (family !== "page_section" && family !== "rich_text")
48
48
  fail(`${reason}:family`);
49
+ const kind = stringValue(node.get("kind"), `${reason}:kind`);
49
50
  return {
50
51
  family,
51
- kind: stringValue(node.get("kind"), `${reason}:kind`),
52
- payload: fromYValue(node.get("payload")),
52
+ kind,
53
+ payload: fromYRichTextBlockPayload(family, kind, node.get("payload")),
53
54
  };
54
55
  }
55
56
 
@@ -33,6 +33,7 @@ export {
33
33
  type BlockRoomAtomicValue,
34
34
  type ReplaceRichTextBlockDataOptions,
35
35
  } from "./block-room-codec/payload-mutations.ts";
36
+ export { reconcileBlockRoomInlineContent } from "./block-room-codec/inline-content-mutations.ts";
36
37
  export {
37
38
  roomDocumentType,
38
39
  roomLocale,