@echovisionlab/geul-common 0.2.2 → 0.2.4

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/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.2",
5
+ "version": "0.2.4",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "repository": {
@@ -0,0 +1,308 @@
1
+ import type { JsonValue } from "@bufbuild/protobuf";
2
+ import { fail } from "./internal.ts";
3
+
4
+ export const RICH_TEXT_STYLE_KEYS = [
5
+ "bold",
6
+ "italic",
7
+ "underline",
8
+ "strike",
9
+ "code",
10
+ "textColor",
11
+ "backgroundColor",
12
+ ] as const;
13
+ const styleKeys = RICH_TEXT_STYLE_KEYS;
14
+
15
+ export type RichTextStyleKey = (typeof styleKeys)[number];
16
+
17
+ export type StyleValue = boolean | string;
18
+ export type Style = Partial<Record<(typeof styleKeys)[number], StyleValue>>;
19
+ export type StyledRun = { text: string; styles: Style };
20
+
21
+ interface DesiredTextGroup {
22
+ readonly kind: "text";
23
+ readonly runs: StyledRun[];
24
+ }
25
+
26
+ interface DesiredLinkGroup {
27
+ readonly kind: "link";
28
+ readonly href: string;
29
+ readonly runs: StyledRun[];
30
+ }
31
+
32
+ interface DesiredMathGroup {
33
+ readonly kind: "mathInline";
34
+ readonly source: string;
35
+ }
36
+
37
+ interface DesiredHardBreakGroup {
38
+ readonly kind: "hardBreak";
39
+ }
40
+
41
+ export type DesiredGroup =
42
+ | DesiredTextGroup
43
+ | DesiredLinkGroup
44
+ | DesiredMathGroup
45
+ | DesiredHardBreakGroup;
46
+
47
+ function object(value: unknown, reason: string): Record<string, unknown> {
48
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
49
+ return fail(reason);
50
+ }
51
+ return value as Record<string, unknown>;
52
+ }
53
+
54
+ function checkedStyles(value: unknown, reason: string): Style {
55
+ if (value === undefined || value === null) {
56
+ return {};
57
+ }
58
+ const raw = object(value, reason);
59
+ const result: Style = {};
60
+ for (const key of styleKeys) {
61
+ const style = raw[key];
62
+ if (style === undefined || style === null) {
63
+ continue;
64
+ }
65
+ if (key === "textColor" || key === "backgroundColor") {
66
+ if (typeof style !== "string") {
67
+ return fail(`${reason}:${key}`);
68
+ }
69
+ } else if (typeof style !== "boolean") {
70
+ return fail(`${reason}:${key}`);
71
+ }
72
+ result[key] = style;
73
+ }
74
+ return result;
75
+ }
76
+
77
+ function normalizeStyles(value: Style): Style {
78
+ const result: Style = {};
79
+ for (const key of styleKeys) {
80
+ const style = value[key];
81
+ if (style === undefined || style === false || style === "") {
82
+ continue;
83
+ }
84
+ result[key] = style;
85
+ }
86
+ return result;
87
+ }
88
+
89
+ function styleKey(value: Style): string {
90
+ return JSON.stringify(normalizeStyles(value));
91
+ }
92
+
93
+ function appendRun(target: StyledRun[], next: StyledRun): void {
94
+ const previous = target[target.length - 1];
95
+ if (previous && styleKey(previous.styles) === styleKey(next.styles)) {
96
+ previous.text += next.text;
97
+ return;
98
+ }
99
+ target.push({ text: next.text, styles: { ...next.styles } });
100
+ }
101
+
102
+ function appendSourceRun(target: StyledRun[], next: StyledRun): void {
103
+ if (next.text.length > 0) {
104
+ target.push({ text: next.text, styles: { ...next.styles } });
105
+ }
106
+ }
107
+
108
+ export function styledInput(value: unknown, reason: string): StyledRun {
109
+ if (typeof value === "string") {
110
+ return { text: value, styles: {} };
111
+ }
112
+ const styled = object(value, reason);
113
+ if (typeof styled.text !== "string") {
114
+ return fail(`${reason}:text`);
115
+ }
116
+ return {
117
+ text: styled.text,
118
+ styles: checkedStyles(styled.styles, `${reason}:styles`),
119
+ };
120
+ }
121
+
122
+ function inputItem(value: JsonValue, reason: string): DesiredGroup {
123
+ const item = object(value, reason);
124
+ const cases = ["text", "link", "mathInline", "hardBreak"].filter(
125
+ (key) => item[key] !== undefined,
126
+ );
127
+ if (cases.length !== 1) {
128
+ return fail(`${reason}:oneof`);
129
+ }
130
+ const selected = cases[0]!;
131
+ if (selected === "text") {
132
+ const run = styledInput(item.text, `${reason}:text`);
133
+ return { kind: "text", runs: run.text ? [run] : [] };
134
+ }
135
+ if (selected === "link") {
136
+ const link = object(item.link, `${reason}:link`);
137
+ if (typeof link.href !== "string" || !Array.isArray(link.content)) {
138
+ return fail(`${reason}:link_shape`);
139
+ }
140
+ const runs: StyledRun[] = [];
141
+ link.content.forEach((child, index) => {
142
+ appendSourceRun(
143
+ runs,
144
+ styledInput(child, `${reason}:link_content:${index}`),
145
+ );
146
+ });
147
+ return { kind: "link", href: link.href, runs };
148
+ }
149
+ if (selected === "mathInline") {
150
+ const math = object(item.mathInline, `${reason}:math`);
151
+ if (math.source !== undefined && typeof math.source !== "string") {
152
+ return fail(`${reason}:math_source`);
153
+ }
154
+ return {
155
+ kind: "mathInline",
156
+ source: (math.source as string | undefined) ?? "",
157
+ };
158
+ }
159
+ return { kind: "hardBreak" };
160
+ }
161
+
162
+ export function desiredGroups(values: readonly JsonValue[]): DesiredGroup[] {
163
+ const result: DesiredGroup[] = [];
164
+ values.forEach((value, index) => {
165
+ const group = inputItem(value, `inline_content:input:${index}`);
166
+ const previous = result[result.length - 1];
167
+ if (group.kind === "text" && previous?.kind === "text") {
168
+ for (const run of group.runs) {
169
+ appendSourceRun(previous.runs, run);
170
+ }
171
+ return;
172
+ }
173
+ if (group.kind === "text" && group.runs.length === 0) {
174
+ return;
175
+ }
176
+ result.push(group);
177
+ });
178
+ return result;
179
+ }
180
+
181
+ export function normalizeInlineContent(values: readonly JsonValue[]): string {
182
+ const normalized = desiredGroups(values).map((group) => {
183
+ if (group.kind === "text") {
184
+ const canonicalRuns: StyledRun[] = [];
185
+ for (const run of group.runs) {
186
+ appendRun(canonicalRuns, run);
187
+ }
188
+ return {
189
+ text: canonicalRuns.map((run) => ({
190
+ text: run.text,
191
+ styles: normalizeStyles(run.styles),
192
+ })),
193
+ };
194
+ }
195
+ if (group.kind === "link") {
196
+ const canonicalRuns: StyledRun[] = [];
197
+ for (const run of group.runs) {
198
+ appendRun(canonicalRuns, run);
199
+ }
200
+ return {
201
+ link: {
202
+ href: group.href,
203
+ content: canonicalRuns.map((run) => ({
204
+ text: run.text,
205
+ styles: normalizeStyles(run.styles),
206
+ })),
207
+ },
208
+ };
209
+ }
210
+ if (group.kind === "mathInline") {
211
+ return { mathInline: { source: group.source } };
212
+ }
213
+ return { hardBreak: {} };
214
+ });
215
+ return JSON.stringify(normalized);
216
+ }
217
+
218
+ export interface StyleSpan {
219
+ readonly from: number;
220
+ readonly to: number;
221
+ readonly styles: Style;
222
+ }
223
+
224
+ export function styleSpans(runs: readonly StyledRun[], start = 0): StyleSpan[] {
225
+ const result: StyleSpan[] = [];
226
+ let from = start;
227
+ for (const run of runs) {
228
+ const to = from + run.text.length;
229
+ result.push({ from, to, styles: normalizeStyles(run.styles) });
230
+ from = to;
231
+ }
232
+ return result;
233
+ }
234
+
235
+ export function changedStyleAttributes(
236
+ before: Style,
237
+ after: Style,
238
+ ): Record<string, StyleValue> {
239
+ const current = normalizeStyles(before);
240
+ const desired = normalizeStyles(after);
241
+ const result: Record<string, StyleValue> = {};
242
+ for (const key of styleKeys) {
243
+ if (current[key] === desired[key]) {
244
+ continue;
245
+ }
246
+ const value = desired[key];
247
+ result[key] =
248
+ value !== undefined
249
+ ? value
250
+ : key === "textColor" || key === "backgroundColor"
251
+ ? ""
252
+ : false;
253
+ }
254
+ return result;
255
+ }
256
+
257
+ function isSurrogatePairBoundary(value: string, boundary: number): boolean {
258
+ if (boundary <= 0 || boundary >= value.length) {
259
+ return false;
260
+ }
261
+ const before = value.charCodeAt(boundary - 1);
262
+ const after = value.charCodeAt(boundary);
263
+ return (
264
+ before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff
265
+ );
266
+ }
267
+
268
+ export function minimalDiff(
269
+ before: string,
270
+ after: string,
271
+ ): {
272
+ from: number;
273
+ oldTo: number;
274
+ newTo: number;
275
+ } {
276
+ let from = 0;
277
+ while (
278
+ from < before.length &&
279
+ from < after.length &&
280
+ before[from] === after[from]
281
+ ) {
282
+ from += 1;
283
+ }
284
+ if (
285
+ isSurrogatePairBoundary(before, from) ||
286
+ isSurrogatePairBoundary(after, from)
287
+ ) {
288
+ from -= 1;
289
+ }
290
+ let oldTo = before.length;
291
+ let newTo = after.length;
292
+ while (
293
+ oldTo > from &&
294
+ newTo > from &&
295
+ before[oldTo - 1] === after[newTo - 1]
296
+ ) {
297
+ oldTo -= 1;
298
+ newTo -= 1;
299
+ }
300
+ if (
301
+ isSurrogatePairBoundary(before, oldTo) ||
302
+ isSurrogatePairBoundary(after, newTo)
303
+ ) {
304
+ oldTo += 1;
305
+ newTo += 1;
306
+ }
307
+ return { from, oldTo, newTo };
308
+ }
@@ -14,8 +14,17 @@ import {
14
14
  import {
15
15
  fromYInlineContent,
16
16
  projectedStyledTextRuns,
17
- richTextStyleKeys,
18
17
  } from "./inline-content-projection.ts";
18
+ import {
19
+ changedStyleAttributes,
20
+ desiredGroups,
21
+ minimalDiff,
22
+ normalizeInlineContent,
23
+ styledInput,
24
+ styleSpans,
25
+ type DesiredGroup,
26
+ type StyledRun,
27
+ } from "./inline-content-model.ts";
19
28
  import {
20
29
  nodeKind,
21
30
  payloadArray,
@@ -32,12 +41,6 @@ type RichTextCatalogEntry = {
32
41
  const richTextCatalog = richTextBlockCatalog as Readonly<
33
42
  Record<string, RichTextCatalogEntry>
34
43
  >;
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
44
  interface RawTextSegment {
42
45
  text?: Y.Text;
43
46
  readonly styledText: Y.Map<unknown>;
@@ -75,39 +78,6 @@ interface RawHardBreakGroup {
75
78
 
76
79
  type RawGroup = RawTextGroup | RawLinkGroup | RawMathGroup | RawHardBreakGroup;
77
80
 
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
81
  function yMap(value: unknown, reason: string): Y.Map<unknown> {
112
82
  if (!(value instanceof Y.Map)) {
113
83
  return fail(reason);
@@ -115,170 +85,6 @@ function yMap(value: unknown, reason: string): Y.Map<unknown> {
115
85
  return value;
116
86
  }
117
87
 
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
88
  function rawStyledText(
283
89
  value: unknown,
284
90
  rawIndex: number,
@@ -374,74 +180,6 @@ function rawGroupKind(group: RawGroup): DesiredGroup["kind"] {
374
180
  return group.kind;
375
181
  }
376
182
 
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
183
  function segmentText(segment: RawTextSegment): string {
446
184
  return segment.text?.toString() ?? "";
447
185
  }
@@ -621,7 +359,9 @@ function reconcileRawGroup(
621
359
  return;
622
360
  }
623
361
  if (raw.kind === "link" && desired.kind === "link") {
624
- raw.link.set("href", desired.href);
362
+ if (raw.link.get("href") !== desired.href) {
363
+ raw.link.set("href", desired.href);
364
+ }
625
365
  const content = raw.content ?? new Y.Array<unknown>();
626
366
  if (!raw.content && desired.runs.some((run) => run.text.length > 0)) {
627
367
  raw.link.set("content", content);
@@ -12,17 +12,10 @@ import {
12
12
  type JsonObject,
13
13
  } from "./internal.ts";
14
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];
15
+ import {
16
+ RICH_TEXT_STYLE_KEYS,
17
+ type RichTextStyleKey,
18
+ } from "./inline-content-model.ts";
26
19
  type RichTextStyleValue = boolean | string;
27
20
  type RichTextStyle = Partial<Record<RichTextStyleKey, RichTextStyleValue>>;
28
21
  type ProjectedTextRun = JsonObject & { text: string };
@@ -167,9 +160,13 @@ function projectedTextField(
167
160
  return fail(`${reason}:embed`);
168
161
  }
169
162
  const item: ProjectedTextRun = { text: operation.insert };
170
- const style = jsonStyle(mergedTextStyle(legacyStyle, operation.attributes));
171
- if (style) {
172
- item.styles = style;
163
+ if (operation.attributes !== undefined || styleValue !== undefined) {
164
+ const style = jsonStyle(
165
+ mergedTextStyle(legacyStyle, operation.attributes),
166
+ );
167
+ if (style) {
168
+ item.styles = style;
169
+ }
173
170
  }
174
171
  appendProjectedText(result, item);
175
172
  }
@@ -347,13 +344,12 @@ export function fromYRichTextBlockPayload(
347
344
  "inline_content:payload_json",
348
345
  ) as JsonValue;
349
346
  }
350
- const payload = Object.fromEntries(value.entries());
351
347
  const projected = decodeOtherFields(
352
348
  value,
353
349
  "content",
354
350
  "inline_content:payload_json",
355
351
  );
356
- const rawContent = payload.content;
352
+ const rawContent = value.get("content");
357
353
  if (rawContent === undefined) {
358
354
  return projected as JsonValue;
359
355
  }
@@ -366,10 +362,6 @@ export function fromYRichTextBlockPayload(
366
362
  return projected as JsonValue;
367
363
  }
368
364
 
369
- export function richTextStyleKeys(): readonly RichTextStyleKey[] {
370
- return RICH_TEXT_STYLE_KEYS;
371
- }
372
-
373
365
  export function projectedStyledTextRuns(
374
366
  value: unknown,
375
367
  reason = "inline_content:styled_text",