@bayonai/rich-text-editor 0.1.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +37 -0
  3. package/dist/index.d.ts +8 -0
  4. package/dist/index.js +5 -0
  5. package/dist/react/BlockActionTool.d.ts +15 -0
  6. package/dist/react/BlockActionTool.js +37 -0
  7. package/dist/react/EditorSessionProvider.d.ts +28 -0
  8. package/dist/react/EditorSessionProvider.js +74 -0
  9. package/dist/react/RichTextBody.d.ts +18 -0
  10. package/dist/react/RichTextBody.js +66 -0
  11. package/dist/react/RichTextDocumentSurface.d.ts +6 -0
  12. package/dist/react/RichTextDocumentSurface.js +5 -0
  13. package/dist/react/RichTextEditor.d.ts +45 -0
  14. package/dist/react/RichTextEditor.js +1096 -0
  15. package/dist/react/RichTextIcons.d.ts +21 -0
  16. package/dist/react/RichTextIcons.js +55 -0
  17. package/dist/react/RichTextRenderedBlock.d.ts +4 -0
  18. package/dist/react/RichTextRenderedBlock.js +36 -0
  19. package/dist/react/RichTextRenderer.d.ts +4 -0
  20. package/dist/react/RichTextRenderer.js +8 -0
  21. package/dist/react/RichTextStyles.d.ts +1 -0
  22. package/dist/react/RichTextStyles.js +719 -0
  23. package/dist/react/RichTextTitleInput.d.ts +20 -0
  24. package/dist/react/RichTextTitleInput.js +39 -0
  25. package/dist/react/SelectionFormatToolbar.d.ts +34 -0
  26. package/dist/react/SelectionFormatToolbar.js +18 -0
  27. package/dist/react/SpecialBlockOption.d.ts +7 -0
  28. package/dist/react/SpecialBlockOption.js +7 -0
  29. package/dist/react/SpecialBlockTool.d.ts +42 -0
  30. package/dist/react/SpecialBlockTool.js +50 -0
  31. package/dist/react/TranscriptionControl.d.ts +19 -0
  32. package/dist/react/TranscriptionControl.js +129 -0
  33. package/dist/react/UnsavedChangesDialog.d.ts +9 -0
  34. package/dist/react/UnsavedChangesDialog.js +13 -0
  35. package/dist/react/blockActionToolState.d.ts +18 -0
  36. package/dist/react/blockActionToolState.js +53 -0
  37. package/dist/react/blockActions.d.ts +8 -0
  38. package/dist/react/blockActions.js +111 -0
  39. package/dist/react/editorNavigation.d.ts +19 -0
  40. package/dist/react/editorNavigation.js +39 -0
  41. package/dist/react/editorShortcuts.d.ts +20 -0
  42. package/dist/react/editorShortcuts.js +25 -0
  43. package/dist/react/index.d.ts +9 -0
  44. package/dist/react/index.js +6 -0
  45. package/dist/react/richTextBlockStyles.d.ts +7 -0
  46. package/dist/react/richTextBlockStyles.js +7 -0
  47. package/dist/react/specialBlockStyles.d.ts +15 -0
  48. package/dist/react/specialBlockStyles.js +9 -0
  49. package/dist/richText.d.ts +15 -0
  50. package/dist/richText.js +297 -0
  51. package/dist/saveControl.d.ts +8 -0
  52. package/dist/saveControl.js +9 -0
  53. package/dist/session.d.ts +27 -0
  54. package/dist/session.js +78 -0
  55. package/dist/sessionRegistry.d.ts +24 -0
  56. package/dist/sessionRegistry.js +87 -0
  57. package/dist/types.d.ts +34 -0
  58. package/dist/types.js +1 -0
  59. package/dist/writingStats.d.ts +5 -0
  60. package/dist/writingStats.js +9 -0
  61. package/dist-cjs/index.js +22 -0
  62. package/dist-cjs/package.json +3 -0
  63. package/dist-cjs/react/BlockActionTool.js +40 -0
  64. package/dist-cjs/react/EditorSessionProvider.js +79 -0
  65. package/dist-cjs/react/RichTextBody.js +69 -0
  66. package/dist-cjs/react/RichTextDocumentSurface.js +8 -0
  67. package/dist-cjs/react/RichTextEditor.js +1108 -0
  68. package/dist-cjs/react/RichTextIcons.js +74 -0
  69. package/dist-cjs/react/RichTextRenderedBlock.js +39 -0
  70. package/dist-cjs/react/RichTextRenderer.js +11 -0
  71. package/dist-cjs/react/RichTextStyles.js +722 -0
  72. package/dist-cjs/react/RichTextTitleInput.js +44 -0
  73. package/dist-cjs/react/SelectionFormatToolbar.js +22 -0
  74. package/dist-cjs/react/SpecialBlockOption.js +10 -0
  75. package/dist-cjs/react/SpecialBlockTool.js +54 -0
  76. package/dist-cjs/react/TranscriptionControl.js +133 -0
  77. package/dist-cjs/react/UnsavedChangesDialog.js +16 -0
  78. package/dist-cjs/react/blockActionToolState.js +58 -0
  79. package/dist-cjs/react/blockActions.js +119 -0
  80. package/dist-cjs/react/editorNavigation.js +45 -0
  81. package/dist-cjs/react/editorShortcuts.js +28 -0
  82. package/dist-cjs/react/index.js +17 -0
  83. package/dist-cjs/react/richTextBlockStyles.js +10 -0
  84. package/dist-cjs/react/specialBlockStyles.js +12 -0
  85. package/dist-cjs/richText.js +307 -0
  86. package/dist-cjs/saveControl.js +12 -0
  87. package/dist-cjs/session.js +83 -0
  88. package/dist-cjs/sessionRegistry.js +90 -0
  89. package/dist-cjs/types.js +2 -0
  90. package/dist-cjs/writingStats.js +12 -0
  91. package/package.json +45 -0
@@ -0,0 +1,25 @@
1
+ const dividerMarkers = new Set(["---", "—-", "***", "___"]);
2
+ export function getTextBlockShortcut(markdown) {
3
+ const trimmed = markdown.trim();
4
+ if (dividerMarkers.has(trimmed)) {
5
+ return { type: "divider" };
6
+ }
7
+ const checkbox = trimmed.match(/^\[( |x|X)?\]\s*(.*)$/);
8
+ if (checkbox) {
9
+ return {
10
+ type: "checkbox",
11
+ checked: checkbox[1]?.toLowerCase() === "x",
12
+ markdown: checkbox[2]?.trim() ?? "",
13
+ };
14
+ }
15
+ if (trimmed.startsWith("# ")) {
16
+ return { type: "heading", markdown: trimmed.slice(2).trim() };
17
+ }
18
+ if (trimmed.startsWith("> ")) {
19
+ return { type: "quote", markdown: trimmed.slice(2).trim() };
20
+ }
21
+ if (trimmed.startsWith("```")) {
22
+ return { type: "code", text: trimmed.slice(3).trimStart() };
23
+ }
24
+ return null;
25
+ }
@@ -0,0 +1,9 @@
1
+ export { EditorSessionProvider, useEditorExitGuard, useEditorSession, } from "./EditorSessionProvider";
2
+ export type { EditorSessionControls, EditorSessionProviderProps, UseEditorSessionOptions, } from "./EditorSessionProvider";
3
+ export { RichTextDocumentSurface } from "./RichTextDocumentSurface";
4
+ export type { RichTextDocumentSurfaceBackground } from "./RichTextDocumentSurface";
5
+ export { RichTextEditor } from "./RichTextEditor";
6
+ export { RichTextReadTitle } from "./RichTextTitleInput";
7
+ export { RichTextRenderer } from "./RichTextRenderer";
8
+ export { UnsavedChangesDialog } from "./UnsavedChangesDialog";
9
+ export type { UnsavedChangesDialogProps } from "./UnsavedChangesDialog";
@@ -0,0 +1,6 @@
1
+ export { EditorSessionProvider, useEditorExitGuard, useEditorSession, } from "./EditorSessionProvider.js";
2
+ export { RichTextDocumentSurface } from "./RichTextDocumentSurface.js";
3
+ export { RichTextEditor } from "./RichTextEditor.js";
4
+ export { RichTextReadTitle } from "./RichTextTitleInput.js";
5
+ export { RichTextRenderer } from "./RichTextRenderer.js";
6
+ export { UnsavedChangesDialog } from "./UnsavedChangesDialog.js";
@@ -0,0 +1,7 @@
1
+ export declare const dividerBlockSx: {
2
+ readonly border: "0";
3
+ readonly borderTop: "1px solid currentColor";
4
+ readonly height: 0;
5
+ readonly my: "6px";
6
+ readonly py: 0;
7
+ };
@@ -0,0 +1,7 @@
1
+ export const dividerBlockSx = {
2
+ border: "0",
3
+ borderTop: "1px solid currentColor",
4
+ height: 0,
5
+ my: "6px",
6
+ py: 0,
7
+ };
@@ -0,0 +1,15 @@
1
+ export declare const toolButtonSx: {
2
+ backgroundColor: string;
3
+ color: string;
4
+ height: {
5
+ xs: number;
6
+ sm: number;
7
+ };
8
+ width: {
9
+ xs: number;
10
+ sm: number;
11
+ };
12
+ "&:hover": {
13
+ backgroundColor: string;
14
+ };
15
+ };
@@ -0,0 +1,9 @@
1
+ export const toolButtonSx = {
2
+ backgroundColor: "#ffffff",
3
+ color: "#7f38d8",
4
+ height: { xs: 28, sm: 39 },
5
+ width: { xs: 28, sm: 39 },
6
+ "&:hover": {
7
+ backgroundColor: "#fbf6ff",
8
+ },
9
+ };
@@ -0,0 +1,15 @@
1
+ import type { RichTextBlock } from "./types";
2
+ export declare function createEmptyRichTextBlocks(): RichTextBlock[];
3
+ export declare function createRichTextBlockId(): string;
4
+ /**
5
+ * Sanitizes an array of RichTextBlock objects by:
6
+ * - Filtering out invalid blocks
7
+ * - Ensuring each block has a unique ID
8
+ * - Returning an empty array if no valid blocks remain
9
+ */
10
+ export declare function sanitizeRichTextBlocks(value: unknown): RichTextBlock[];
11
+ export declare function isRichTextBlocksEmpty(blocks: RichTextBlock[]): boolean;
12
+ export declare function richTextBlocksToPlainText(blocks: RichTextBlock[]): string;
13
+ export declare function editorHtmlToMarkdown(html: string): string;
14
+ export declare function markdownToEditorHtml(markdown: string): string;
15
+ export declare function sanitizeRichTextHtml(html: string): string;
@@ -0,0 +1,297 @@
1
+ const allowedInlineTags = new Set(["a", "br", "code", "em", "strong"]);
2
+ const textBlockTypes = new Set(["paragraph", "heading", "quote", "checkbox"]);
3
+ export function createEmptyRichTextBlocks() {
4
+ return [{ id: createRichTextBlockId(), type: "paragraph", markdown: "" }];
5
+ }
6
+ export function createRichTextBlockId() {
7
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
8
+ return crypto.randomUUID();
9
+ }
10
+ return `block-${Date.now()}-${Math.random().toString(16).slice(2)}`;
11
+ }
12
+ /**
13
+ * Sanitizes an array of RichTextBlock objects by:
14
+ * - Filtering out invalid blocks
15
+ * - Ensuring each block has a unique ID
16
+ * - Returning an empty array if no valid blocks remain
17
+ */
18
+ export function sanitizeRichTextBlocks(value) {
19
+ const blocks = Array.isArray(value) ? value : [];
20
+ const seenBlockIds = new Set();
21
+ const sanitized = blocks
22
+ .flatMap((block) => sanitizeRichTextBlock(block))
23
+ .map((block) => {
24
+ if (!seenBlockIds.has(block.id)) {
25
+ seenBlockIds.add(block.id);
26
+ return block;
27
+ }
28
+ const blockWithUniqueId = {
29
+ ...block,
30
+ id: createUniqueRichTextBlockId(seenBlockIds),
31
+ };
32
+ seenBlockIds.add(blockWithUniqueId.id);
33
+ return blockWithUniqueId;
34
+ });
35
+ return sanitized.length > 0 ? sanitized : createEmptyRichTextBlocks();
36
+ }
37
+ export function isRichTextBlocksEmpty(blocks) {
38
+ return sanitizeRichTextBlocks(blocks).every((block) => {
39
+ if (block.type === "divider") {
40
+ return false;
41
+ }
42
+ if (block.type === "checkbox") {
43
+ return markdownToPlainText(block.markdown).trim() === "";
44
+ }
45
+ if (block.type === "image") {
46
+ return !block.assetId && !block.alt?.trim();
47
+ }
48
+ const text = block.type === "code" ? block.text : block.markdown;
49
+ return markdownToPlainText(text).trim() === "";
50
+ });
51
+ }
52
+ export function richTextBlocksToPlainText(blocks) {
53
+ return sanitizeRichTextBlocks(blocks)
54
+ .map((block) => {
55
+ if (block.type === "divider") {
56
+ return "";
57
+ }
58
+ if (block.type === "checkbox") {
59
+ return markdownToPlainText(block.markdown);
60
+ }
61
+ if (block.type === "image") {
62
+ return block.alt ?? "";
63
+ }
64
+ return block.type === "code"
65
+ ? block.text
66
+ : markdownToPlainText(block.markdown);
67
+ })
68
+ .join(" ")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ }
72
+ export function editorHtmlToMarkdown(html) {
73
+ const sanitized = normalizeTypography(decodeHtmlText(sanitizeRichTextHtml(html)
74
+ .replace(/<br>/g, " \n")
75
+ .replace(/<strong>([\s\S]*?)<\/strong>/g, (_, value) => {
76
+ return `**${decodeHtmlText(stripTags(value))}**`;
77
+ })
78
+ .replace(/<em>([\s\S]*?)<\/em>/g, (_, value) => {
79
+ return `_${decodeHtmlText(stripTags(value))}_`;
80
+ })
81
+ .replace(/<code>([\s\S]*?)<\/code>/g, (_, value) => {
82
+ return `\`${decodeHtmlText(stripTags(value)).replace(/`/g, "\\`")}\``;
83
+ })
84
+ .replace(/<a href="([^"]*)">([\s\S]*?)<\/a>/g, (_, href, value) => {
85
+ return `[${decodeHtmlText(stripTags(value))}](${decodeHtmlText(href)})`;
86
+ })
87
+ .replace(/<a>([\s\S]*?)<\/a>/g, (_, value) => {
88
+ return decodeHtmlText(stripTags(value));
89
+ })
90
+ .replace(/<[^>]+>/g, "")))
91
+ .replace(/\u00a0/g, " ")
92
+ .trim();
93
+ return sanitized;
94
+ }
95
+ export function markdownToEditorHtml(markdown) {
96
+ const tokens = [];
97
+ let html = escapeHtmlText(normalizeTypography(markdown));
98
+ html = html.replace(/`([^`\n]+)`/g, (_, value) => {
99
+ const token = pushToken(tokens, `<code>${value}</code>`);
100
+ return token;
101
+ });
102
+ html = html.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_, label, href) => {
103
+ const safeHref = getSafeUrl(decodeHtmlText(href));
104
+ return safeHref ? `<a href="${safeHref}">${label}</a>` : label;
105
+ });
106
+ html = html
107
+ .replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
108
+ .replace(/_([^_\n]+)_/g, "<em>$1</em>")
109
+ .replace(/ {2}\n/g, "<br>")
110
+ .replace(/\n/g, "<br>");
111
+ tokens.forEach((value, index) => {
112
+ html = html.replace(tokenPlaceholder(index), value);
113
+ });
114
+ return html;
115
+ }
116
+ export function sanitizeRichTextHtml(html) {
117
+ return html
118
+ .replace(/<!--[\s\S]*?-->/g, "")
119
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
120
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
121
+ .replace(/<b(\s[^>]*)?>/gi, "<strong>")
122
+ .replace(/<\/b>/gi, "</strong>")
123
+ .replace(/<i(\s[^>]*)?>/gi, "<em>")
124
+ .replace(/<\/i>/gi, "</em>")
125
+ .replace(/<\/?([a-z0-9]+)([^>]*)>/gi, (match, rawTag, rawAttrs) => {
126
+ const tag = String(rawTag).toLowerCase();
127
+ const closing = match.startsWith("</");
128
+ if (!allowedInlineTags.has(tag)) {
129
+ return "";
130
+ }
131
+ if (closing) {
132
+ return tag === "br" ? "" : `</${tag}>`;
133
+ }
134
+ if (tag === "a") {
135
+ const href = getSafeAttribute(rawAttrs, "href");
136
+ return href ? `<a href="${href}">` : "<a>";
137
+ }
138
+ return tag === "br" ? "<br>" : `<${tag}>`;
139
+ });
140
+ }
141
+ function sanitizeRichTextBlock(block) {
142
+ if (!isRecord(block)) {
143
+ return [];
144
+ }
145
+ const id = readBlockId(block);
146
+ const type = block.type;
147
+ if (type === "code") {
148
+ return [{ id, type, text: String(block.text ?? "") }];
149
+ }
150
+ if (type === "divider") {
151
+ return [{ id, type }];
152
+ }
153
+ if (type === "image") {
154
+ return [
155
+ {
156
+ id,
157
+ type,
158
+ ...(typeof block.assetId === "string" && block.assetId.trim()
159
+ ? { assetId: block.assetId.trim() }
160
+ : {}),
161
+ ...(typeof block.alt === "string" && block.alt.trim()
162
+ ? { alt: markdownToPlainText(editorHtmlToMarkdown(block.alt)) }
163
+ : {}),
164
+ },
165
+ ];
166
+ }
167
+ if (typeof type === "string" && textBlockTypes.has(type)) {
168
+ const markdown = typeof block.markdown === "string"
169
+ ? block.markdown
170
+ : typeof block.text === "string"
171
+ ? block.text
172
+ : typeof block.html === "string"
173
+ ? editorHtmlToMarkdown(block.html)
174
+ : "";
175
+ if (type === "checkbox") {
176
+ return [
177
+ {
178
+ id,
179
+ type,
180
+ checked: Boolean(block.checked),
181
+ markdown: sanitizeMarkdown(markdown),
182
+ },
183
+ ];
184
+ }
185
+ return [
186
+ {
187
+ id,
188
+ type: type,
189
+ markdown: sanitizeMarkdown(markdown),
190
+ },
191
+ ];
192
+ }
193
+ return [];
194
+ }
195
+ function readBlockId(block) {
196
+ return typeof block.id === "string" && block.id.trim()
197
+ ? block.id.trim()
198
+ : createRichTextBlockId();
199
+ }
200
+ function createUniqueRichTextBlockId(existingIds) {
201
+ let id = createRichTextBlockId();
202
+ while (existingIds.has(id)) {
203
+ id = createRichTextBlockId();
204
+ }
205
+ return id;
206
+ }
207
+ function markdownToPlainText(value) {
208
+ return value
209
+ .replace(/`([^`]*)`/g, "$1")
210
+ .replace(/\*\*([^*]*)\*\*/g, "$1")
211
+ .replace(/_([^_]*)_/g, "$1")
212
+ .replace(/\[([^\]]*)\]\([^)]+\)/g, "$1")
213
+ .replace(/[#>*`_[\]()]/g, " ")
214
+ .replace(/\s+/g, " ")
215
+ .trim();
216
+ }
217
+ function sanitizeMarkdown(value) {
218
+ return normalizeTypography(value)
219
+ .replace(/<!--[\s\S]*?-->/g, "")
220
+ .trim();
221
+ }
222
+ function normalizeTypography(value) {
223
+ const tokens = [];
224
+ let normalized = value.replace(/`([^`\n]+)`/g, (match) => {
225
+ return pushToken(tokens, match);
226
+ });
227
+ normalized = normalized.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_match, label, href) => {
228
+ return `[${label}](${pushToken(tokens, href)})`;
229
+ });
230
+ normalized = normalized
231
+ .replace(/––/g, "—")
232
+ .replace(/–-/g, "—")
233
+ .replace(/---/g, "—")
234
+ .replace(/--/g, "–");
235
+ tokens.forEach((token, index) => {
236
+ normalized = normalized.replace(tokenPlaceholder(index), token);
237
+ });
238
+ return normalized;
239
+ }
240
+ function stripTags(value) {
241
+ return value.replace(/<[^>]+>/g, "");
242
+ }
243
+ function getSafeAttribute(attrs, name) {
244
+ const value = getPlainAttribute(attrs, name);
245
+ const safeValue = getSafeUrl(value);
246
+ if (!safeValue) {
247
+ return "";
248
+ }
249
+ return escapeAttribute(safeValue);
250
+ }
251
+ function getSafeUrl(value) {
252
+ const trimmed = value.trim();
253
+ if (!trimmed || /^javascript:/i.test(trimmed)) {
254
+ return "";
255
+ }
256
+ return trimmed;
257
+ }
258
+ function getPlainAttribute(attrs, name) {
259
+ const pattern = new RegExp(`${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
260
+ const match = attrs.match(pattern);
261
+ return match?.[2] ?? match?.[3] ?? match?.[4] ?? "";
262
+ }
263
+ function escapeAttribute(value) {
264
+ return value
265
+ .replace(/&/g, "&amp;")
266
+ .replace(/</g, "&lt;")
267
+ .replace(/>/g, "&gt;")
268
+ .replace(/"/g, "&quot;");
269
+ }
270
+ function escapeHtmlText(value) {
271
+ return value
272
+ .replace(/&/g, "&amp;")
273
+ .replace(/</g, "&lt;")
274
+ .replace(/>/g, "&gt;");
275
+ }
276
+ function decodeHtmlText(value) {
277
+ return value
278
+ .replace(/&nbsp;/g, " ")
279
+ .replace(/&#160;/g, " ")
280
+ .replace(/&#xA0;/gi, " ")
281
+ .replace(/&amp;/g, "&")
282
+ .replace(/&lt;/g, "<")
283
+ .replace(/&gt;/g, ">")
284
+ .replace(/&quot;/g, '"')
285
+ .replace(/&apos;/g, "'")
286
+ .replace(/&#39;/g, "'");
287
+ }
288
+ function pushToken(tokens, value) {
289
+ const index = tokens.push(value) - 1;
290
+ return tokenPlaceholder(index);
291
+ }
292
+ function tokenPlaceholder(index) {
293
+ return `@@RICHTEXTTOKEN${index}@@`;
294
+ }
295
+ function isRecord(value) {
296
+ return typeof value === "object" && value !== null;
297
+ }
@@ -0,0 +1,8 @@
1
+ export declare function getEditorSaveControlState({ isDirty, isSaving, saveLabel, }: {
2
+ isDirty: boolean;
3
+ isSaving: boolean;
4
+ saveLabel: string;
5
+ }): {
6
+ disabled: boolean;
7
+ label: string;
8
+ };
@@ -0,0 +1,9 @@
1
+ export function getEditorSaveControlState({ isDirty, isSaving, saveLabel, }) {
2
+ if (isSaving) {
3
+ return { disabled: true, label: "Saving..." };
4
+ }
5
+ if (!isDirty) {
6
+ return { disabled: true, label: "No changes to save" };
7
+ }
8
+ return { disabled: false, label: saveLabel };
9
+ }
@@ -0,0 +1,27 @@
1
+ import type { RichTextDocument } from "./types";
2
+ export type EditorDirtyScope = "all" | "content";
3
+ export type EditorSessionValue<Metadata = unknown> = {
4
+ document: RichTextDocument;
5
+ metadata?: Metadata;
6
+ };
7
+ export type EditorSessionOptions<Metadata> = {
8
+ dirtyScope?: EditorDirtyScope;
9
+ equals?: (left: EditorSessionValue<Metadata>, right: EditorSessionValue<Metadata>) => boolean;
10
+ initialBaselineValue?: EditorSessionValue<Metadata>;
11
+ initialValue: EditorSessionValue<Metadata>;
12
+ onSave: (value: EditorSessionValue<Metadata>) => Promise<unknown>;
13
+ };
14
+ export type EditorSession<Metadata> = {
15
+ getValue: () => EditorSessionValue<Metadata>;
16
+ isDirty: () => boolean;
17
+ isSaving: () => boolean;
18
+ markClean: (value?: EditorSessionValue<Metadata>) => void;
19
+ reset: () => EditorSessionValue<Metadata>;
20
+ save: () => Promise<unknown>;
21
+ setOnSave: (onSave: (value: EditorSessionValue<Metadata>) => Promise<unknown>) => void;
22
+ subscribe: (listener: () => void) => () => void;
23
+ update: (value: EditorSessionValue<Metadata>) => void;
24
+ };
25
+ export declare function createEditorSession<Metadata = unknown>({ dirtyScope, equals, initialBaselineValue, initialValue, onSave, }: EditorSessionOptions<Metadata>): EditorSession<Metadata>;
26
+ export declare function defaultEditorSessionEquals<Metadata>(left: EditorSessionValue<Metadata>, right: EditorSessionValue<Metadata>, dirtyScope?: EditorDirtyScope): boolean;
27
+ export declare function canonicalSerialize(value: unknown): string;
@@ -0,0 +1,78 @@
1
+ export function createEditorSession({ dirtyScope = "all", equals, initialBaselineValue, initialValue, onSave, }) {
2
+ let baseline = cloneValue(initialBaselineValue ?? initialValue);
3
+ let currentValue = cloneValue(initialValue);
4
+ let saving = false;
5
+ let saveHandler = onSave;
6
+ const listeners = new Set();
7
+ const valuesEqual = equals ??
8
+ ((left, right) => defaultEditorSessionEquals(left, right, dirtyScope));
9
+ function emitChange() {
10
+ listeners.forEach((listener) => listener());
11
+ }
12
+ return {
13
+ getValue: () => currentValue,
14
+ isDirty: () => !valuesEqual(currentValue, baseline),
15
+ isSaving: () => saving,
16
+ markClean: (value) => {
17
+ baseline = cloneValue(value ?? currentValue);
18
+ emitChange();
19
+ },
20
+ reset: () => {
21
+ currentValue = cloneValue(baseline);
22
+ emitChange();
23
+ return currentValue;
24
+ },
25
+ save: async () => {
26
+ if (saving) {
27
+ return;
28
+ }
29
+ saving = true;
30
+ emitChange();
31
+ try {
32
+ const valueToSave = cloneValue(currentValue);
33
+ const result = await saveHandler(valueToSave);
34
+ baseline = cloneValue(valueToSave);
35
+ return result;
36
+ }
37
+ finally {
38
+ saving = false;
39
+ emitChange();
40
+ }
41
+ },
42
+ setOnSave: (nextOnSave) => {
43
+ saveHandler = nextOnSave;
44
+ },
45
+ subscribe: (listener) => {
46
+ listeners.add(listener);
47
+ return () => listeners.delete(listener);
48
+ },
49
+ update: (value) => {
50
+ currentValue = cloneValue(value);
51
+ emitChange();
52
+ },
53
+ };
54
+ }
55
+ export function defaultEditorSessionEquals(left, right, dirtyScope = "all") {
56
+ if (dirtyScope === "content") {
57
+ return (canonicalSerialize(left.document) === canonicalSerialize(right.document));
58
+ }
59
+ return canonicalSerialize(left) === canonicalSerialize(right);
60
+ }
61
+ export function canonicalSerialize(value) {
62
+ return JSON.stringify(sortValue(value));
63
+ }
64
+ function sortValue(value) {
65
+ if (Array.isArray(value)) {
66
+ return value.map(sortValue);
67
+ }
68
+ if (value && typeof value === "object") {
69
+ return Object.fromEntries(Object.entries(value)
70
+ .filter(([, entryValue]) => entryValue !== undefined)
71
+ .sort(([left], [right]) => left.localeCompare(right))
72
+ .map(([key, entryValue]) => [key, sortValue(entryValue)]));
73
+ }
74
+ return value;
75
+ }
76
+ function cloneValue(value) {
77
+ return JSON.parse(JSON.stringify(value));
78
+ }
@@ -0,0 +1,24 @@
1
+ export type EditorExitAction = () => void | Promise<void>;
2
+ export type EditorSessionRegistrySnapshot = {
3
+ dirty: boolean;
4
+ error: string | null;
5
+ pendingExit: boolean;
6
+ saving: boolean;
7
+ };
8
+ export type EditorSessionRegistry = {
9
+ discardAndExit: () => void;
10
+ getSnapshot: () => EditorSessionRegistrySnapshot;
11
+ register: (session: RegisteredEditorSession) => () => void;
12
+ requestExit: (action: EditorExitAction) => Promise<boolean>;
13
+ saveAndExit: () => Promise<void>;
14
+ stay: () => void;
15
+ subscribe: (listener: () => void) => () => void;
16
+ };
17
+ type RegisteredEditorSession = {
18
+ isDirty: () => boolean;
19
+ reset: () => unknown;
20
+ save: () => Promise<unknown>;
21
+ subscribe: (listener: () => void) => () => void;
22
+ };
23
+ export declare function createEditorSessionRegistry(): EditorSessionRegistry;
24
+ export {};
@@ -0,0 +1,87 @@
1
+ const cleanSnapshot = {
2
+ dirty: false,
3
+ error: null,
4
+ pendingExit: false,
5
+ saving: false,
6
+ };
7
+ export function createEditorSessionRegistry() {
8
+ const sessions = new Set();
9
+ const sessionUnsubscribers = new Map();
10
+ const listeners = new Set();
11
+ let pendingAction = null;
12
+ let snapshot = cleanSnapshot;
13
+ function emitChange(changes = {}) {
14
+ snapshot = {
15
+ ...snapshot,
16
+ ...changes,
17
+ dirty: Array.from(sessions).some((session) => session.isDirty()),
18
+ };
19
+ listeners.forEach((listener) => listener());
20
+ }
21
+ function clearPendingExit() {
22
+ pendingAction = null;
23
+ emitChange({ error: null, pendingExit: false, saving: false });
24
+ }
25
+ return {
26
+ discardAndExit: () => {
27
+ const action = pendingAction;
28
+ sessions.forEach((session) => {
29
+ if (session.isDirty()) {
30
+ session.reset();
31
+ }
32
+ });
33
+ clearPendingExit();
34
+ void action?.();
35
+ },
36
+ getSnapshot: () => snapshot,
37
+ register: (session) => {
38
+ sessions.add(session);
39
+ sessionUnsubscribers.set(session, session.subscribe(() => emitChange()));
40
+ emitChange();
41
+ return () => {
42
+ sessionUnsubscribers.get(session)?.();
43
+ sessionUnsubscribers.delete(session);
44
+ sessions.delete(session);
45
+ emitChange();
46
+ };
47
+ },
48
+ requestExit: async (action) => {
49
+ if (!Array.from(sessions).some((session) => session.isDirty())) {
50
+ await action();
51
+ return true;
52
+ }
53
+ pendingAction = action;
54
+ emitChange({ error: null, pendingExit: true });
55
+ return false;
56
+ },
57
+ saveAndExit: async () => {
58
+ const action = pendingAction;
59
+ if (!action || snapshot.saving) {
60
+ return;
61
+ }
62
+ emitChange({ error: null, saving: true });
63
+ try {
64
+ for (const session of sessions) {
65
+ if (session.isDirty()) {
66
+ await session.save();
67
+ }
68
+ }
69
+ clearPendingExit();
70
+ await action();
71
+ }
72
+ catch (error) {
73
+ emitChange({
74
+ error: error instanceof Error ? error.message : "Unable to save changes.",
75
+ pendingExit: true,
76
+ saving: false,
77
+ });
78
+ throw error;
79
+ }
80
+ },
81
+ stay: clearPendingExit,
82
+ subscribe: (listener) => {
83
+ listeners.add(listener);
84
+ return () => listeners.delete(listener);
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,34 @@
1
+ export type RichTextBlock = {
2
+ id: string;
3
+ type: "paragraph";
4
+ markdown: string;
5
+ } | {
6
+ id: string;
7
+ type: "heading";
8
+ markdown: string;
9
+ } | {
10
+ id: string;
11
+ type: "quote";
12
+ markdown: string;
13
+ } | {
14
+ id: string;
15
+ type: "checkbox";
16
+ checked: boolean;
17
+ markdown: string;
18
+ } | {
19
+ id: string;
20
+ type: "code";
21
+ text: string;
22
+ } | {
23
+ id: string;
24
+ type: "divider";
25
+ } | {
26
+ id: string;
27
+ type: "image";
28
+ assetId?: string;
29
+ alt?: string;
30
+ };
31
+ export type RichTextDocument = {
32
+ contentBlocks: RichTextBlock[];
33
+ title: string;
34
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};