@echovisionlab/geul-common 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.3",
5
+ "version": "0.3.0",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "repository": {
@@ -40,6 +40,7 @@
40
40
  "./collaboration/document": "./src/collaboration/document.ts",
41
41
  "./collaboration/email-layout": "./src/collaboration/email-layout.ts",
42
42
  "./collaboration/form": "./src/collaboration/form.ts",
43
+ "./collaboration/form-schema-delta": "./src/collaboration/form-schema-delta.ts",
43
44
  "./collaboration/label": "./src/collaboration/label.ts",
44
45
  "./collaboration/map-theme": "./src/collaboration/map-theme.ts",
45
46
  "./collaboration/menu": "./src/collaboration/menu.ts",
@@ -62,7 +63,7 @@
62
63
  },
63
64
  "dependencies": {
64
65
  "@bufbuild/protobuf": "^2.14.0",
65
- "@echovisionlab/geul-proto": "^0.2.0",
66
+ "@echovisionlab/geul-proto": "^0.3.0",
66
67
  "zod": "^4.4.3"
67
68
  },
68
69
  "peerDependencies": {
@@ -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,98 +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 isSurrogatePairBoundary(value: string, boundary: number): boolean {
417
- if (boundary <= 0 || boundary >= value.length) {
418
- return false;
419
- }
420
- const before = value.charCodeAt(boundary - 1);
421
- const after = value.charCodeAt(boundary);
422
- return (
423
- before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff
424
- );
425
- }
426
-
427
- function minimalDiff(
428
- before: string,
429
- after: string,
430
- ): {
431
- from: number;
432
- oldTo: number;
433
- newTo: number;
434
- } {
435
- let from = 0;
436
- while (
437
- from < before.length &&
438
- from < after.length &&
439
- before[from] === after[from]
440
- ) {
441
- from += 1;
442
- }
443
- if (
444
- isSurrogatePairBoundary(before, from) ||
445
- isSurrogatePairBoundary(after, from)
446
- ) {
447
- from -= 1;
448
- }
449
- let oldTo = before.length;
450
- let newTo = after.length;
451
- while (
452
- oldTo > from &&
453
- newTo > from &&
454
- before[oldTo - 1] === after[newTo - 1]
455
- ) {
456
- oldTo -= 1;
457
- newTo -= 1;
458
- }
459
- if (
460
- isSurrogatePairBoundary(before, oldTo) ||
461
- isSurrogatePairBoundary(after, newTo)
462
- ) {
463
- oldTo += 1;
464
- newTo += 1;
465
- }
466
- return { from, oldTo, newTo };
467
- }
468
-
469
183
  function segmentText(segment: RawTextSegment): string {
470
184
  return segment.text?.toString() ?? "";
471
185
  }
@@ -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 };
@@ -369,10 +362,6 @@ export function fromYRichTextBlockPayload(
369
362
  return projected as JsonValue;
370
363
  }
371
364
 
372
- export function richTextStyleKeys(): readonly RichTextStyleKey[] {
373
- return RICH_TEXT_STYLE_KEYS;
374
- }
375
-
376
365
  export function projectedStyledTextRuns(
377
366
  value: unknown,
378
367
  reason = "inline_content:styled_text",