@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.
- package/CHANGELOG.md +38 -0
- package/README.md +37 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/react/BlockActionTool.d.ts +15 -0
- package/dist/react/BlockActionTool.js +37 -0
- package/dist/react/EditorSessionProvider.d.ts +28 -0
- package/dist/react/EditorSessionProvider.js +74 -0
- package/dist/react/RichTextBody.d.ts +18 -0
- package/dist/react/RichTextBody.js +66 -0
- package/dist/react/RichTextDocumentSurface.d.ts +6 -0
- package/dist/react/RichTextDocumentSurface.js +5 -0
- package/dist/react/RichTextEditor.d.ts +45 -0
- package/dist/react/RichTextEditor.js +1096 -0
- package/dist/react/RichTextIcons.d.ts +21 -0
- package/dist/react/RichTextIcons.js +55 -0
- package/dist/react/RichTextRenderedBlock.d.ts +4 -0
- package/dist/react/RichTextRenderedBlock.js +36 -0
- package/dist/react/RichTextRenderer.d.ts +4 -0
- package/dist/react/RichTextRenderer.js +8 -0
- package/dist/react/RichTextStyles.d.ts +1 -0
- package/dist/react/RichTextStyles.js +719 -0
- package/dist/react/RichTextTitleInput.d.ts +20 -0
- package/dist/react/RichTextTitleInput.js +39 -0
- package/dist/react/SelectionFormatToolbar.d.ts +34 -0
- package/dist/react/SelectionFormatToolbar.js +18 -0
- package/dist/react/SpecialBlockOption.d.ts +7 -0
- package/dist/react/SpecialBlockOption.js +7 -0
- package/dist/react/SpecialBlockTool.d.ts +42 -0
- package/dist/react/SpecialBlockTool.js +50 -0
- package/dist/react/TranscriptionControl.d.ts +19 -0
- package/dist/react/TranscriptionControl.js +129 -0
- package/dist/react/UnsavedChangesDialog.d.ts +9 -0
- package/dist/react/UnsavedChangesDialog.js +13 -0
- package/dist/react/blockActionToolState.d.ts +18 -0
- package/dist/react/blockActionToolState.js +53 -0
- package/dist/react/blockActions.d.ts +8 -0
- package/dist/react/blockActions.js +111 -0
- package/dist/react/editorNavigation.d.ts +19 -0
- package/dist/react/editorNavigation.js +39 -0
- package/dist/react/editorShortcuts.d.ts +20 -0
- package/dist/react/editorShortcuts.js +25 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.js +6 -0
- package/dist/react/richTextBlockStyles.d.ts +7 -0
- package/dist/react/richTextBlockStyles.js +7 -0
- package/dist/react/specialBlockStyles.d.ts +15 -0
- package/dist/react/specialBlockStyles.js +9 -0
- package/dist/richText.d.ts +15 -0
- package/dist/richText.js +297 -0
- package/dist/saveControl.d.ts +8 -0
- package/dist/saveControl.js +9 -0
- package/dist/session.d.ts +27 -0
- package/dist/session.js +78 -0
- package/dist/sessionRegistry.d.ts +24 -0
- package/dist/sessionRegistry.js +87 -0
- package/dist/types.d.ts +34 -0
- package/dist/types.js +1 -0
- package/dist/writingStats.d.ts +5 -0
- package/dist/writingStats.js +9 -0
- package/dist-cjs/index.js +22 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/react/BlockActionTool.js +40 -0
- package/dist-cjs/react/EditorSessionProvider.js +79 -0
- package/dist-cjs/react/RichTextBody.js +69 -0
- package/dist-cjs/react/RichTextDocumentSurface.js +8 -0
- package/dist-cjs/react/RichTextEditor.js +1108 -0
- package/dist-cjs/react/RichTextIcons.js +74 -0
- package/dist-cjs/react/RichTextRenderedBlock.js +39 -0
- package/dist-cjs/react/RichTextRenderer.js +11 -0
- package/dist-cjs/react/RichTextStyles.js +722 -0
- package/dist-cjs/react/RichTextTitleInput.js +44 -0
- package/dist-cjs/react/SelectionFormatToolbar.js +22 -0
- package/dist-cjs/react/SpecialBlockOption.js +10 -0
- package/dist-cjs/react/SpecialBlockTool.js +54 -0
- package/dist-cjs/react/TranscriptionControl.js +133 -0
- package/dist-cjs/react/UnsavedChangesDialog.js +16 -0
- package/dist-cjs/react/blockActionToolState.js +58 -0
- package/dist-cjs/react/blockActions.js +119 -0
- package/dist-cjs/react/editorNavigation.js +45 -0
- package/dist-cjs/react/editorShortcuts.js +28 -0
- package/dist-cjs/react/index.js +17 -0
- package/dist-cjs/react/richTextBlockStyles.js +10 -0
- package/dist-cjs/react/specialBlockStyles.js +12 -0
- package/dist-cjs/richText.js +307 -0
- package/dist-cjs/saveControl.js +12 -0
- package/dist-cjs/session.js +83 -0
- package/dist-cjs/sessionRegistry.js +90 -0
- package/dist-cjs/types.js +2 -0
- package/dist-cjs/writingStats.js +12 -0
- 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,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;
|
package/dist/richText.js
ADDED
|
@@ -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, "&")
|
|
266
|
+
.replace(/</g, "<")
|
|
267
|
+
.replace(/>/g, ">")
|
|
268
|
+
.replace(/"/g, """);
|
|
269
|
+
}
|
|
270
|
+
function escapeHtmlText(value) {
|
|
271
|
+
return value
|
|
272
|
+
.replace(/&/g, "&")
|
|
273
|
+
.replace(/</g, "<")
|
|
274
|
+
.replace(/>/g, ">");
|
|
275
|
+
}
|
|
276
|
+
function decodeHtmlText(value) {
|
|
277
|
+
return value
|
|
278
|
+
.replace(/ /g, " ")
|
|
279
|
+
.replace(/ /g, " ")
|
|
280
|
+
.replace(/ /gi, " ")
|
|
281
|
+
.replace(/&/g, "&")
|
|
282
|
+
.replace(/</g, "<")
|
|
283
|
+
.replace(/>/g, ">")
|
|
284
|
+
.replace(/"/g, '"')
|
|
285
|
+
.replace(/'/g, "'")
|
|
286
|
+
.replace(/'/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,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;
|
package/dist/session.js
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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 {};
|