@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.
@@ -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,
@@ -463,12 +463,6 @@ export const PAGE_BLOCK_FIXTURE_SECTIONS: PageBlockFixtureSection[] = [
463
463
  caption: "",
464
464
  },
465
465
  },
466
- {
467
- id: "fixture-section-mermaid",
468
- type: "mermaid",
469
- settings: {},
470
- props: { source: "flowchart LR\n A --> B", title: "Diagram" },
471
- },
472
466
  {
473
467
  id: "fixture-section-external-video",
474
468
  type: "external-video",
@@ -572,6 +566,12 @@ export const PAGE_BLOCK_FIXTURE_SECTIONS: PageBlockFixtureSection[] = [
572
566
  },
573
567
  ],
574
568
  },
569
+ {
570
+ id: "fixture-section-mermaid",
571
+ type: "mermaid",
572
+ settings: {},
573
+ props: { source: "flowchart LR\n A --> B", title: "Diagram" },
574
+ },
575
575
  ];
576
576
 
577
577
  export function createPageBlockFixtureSections(): PageBlockFixtureSection[] {