@azlib/editor 0.2.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/LICENSE +201 -0
- package/README.md +28 -0
- package/dist/index.cjs +1310 -0
- package/dist/index.d.cts +128 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +128 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1278 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +62 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1278 @@
|
|
|
1
|
+
import DOMPurify from "isomorphic-dompurify";
|
|
2
|
+
import { Schema } from "prosemirror-model";
|
|
3
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
//#region src/core/errors.ts
|
|
6
|
+
const createDiagnostic = (code, severity, message, location) => ({
|
|
7
|
+
code,
|
|
8
|
+
severity,
|
|
9
|
+
message,
|
|
10
|
+
location
|
|
11
|
+
});
|
|
12
|
+
const createRecoverableError = (code, message, details) => ({
|
|
13
|
+
code,
|
|
14
|
+
message,
|
|
15
|
+
severity: "warning",
|
|
16
|
+
recoverable: true,
|
|
17
|
+
details
|
|
18
|
+
});
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/core/commandDiagnostics.ts
|
|
21
|
+
const createUnknownCommandDiagnostic = (commandName) => createDiagnostic("UNKNOWN_COMMAND", "warning", `Command '${commandName}' is not available in current editor capabilities.`);
|
|
22
|
+
const createNoHistoryDiagnostic = (type) => createDiagnostic("NO_HISTORY", "info", `No ${type} history entry is available for this editor session.`);
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/core/formattingCommands.ts
|
|
25
|
+
const withCommandMetadata = (commandName) => ({ document }) => ({ document: {
|
|
26
|
+
...document,
|
|
27
|
+
revision: document.revision + 1,
|
|
28
|
+
metadata: {
|
|
29
|
+
...document.metadata,
|
|
30
|
+
lastCommand: commandName
|
|
31
|
+
}
|
|
32
|
+
} });
|
|
33
|
+
const createFormattingCommandHandlers = () => ({
|
|
34
|
+
bold: withCommandMetadata("bold"),
|
|
35
|
+
italic: withCommandMetadata("italic"),
|
|
36
|
+
underline: withCommandMetadata("underline"),
|
|
37
|
+
heading: withCommandMetadata("heading"),
|
|
38
|
+
list: withCommandMetadata("list"),
|
|
39
|
+
align: withCommandMetadata("align"),
|
|
40
|
+
link: withCommandMetadata("link")
|
|
41
|
+
});
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/core/commands.ts
|
|
44
|
+
const createCommandRegistry = (capabilities) => {
|
|
45
|
+
const allowed = new Set(capabilities && capabilities.length > 0 ? capabilities : [
|
|
46
|
+
"bold",
|
|
47
|
+
"italic",
|
|
48
|
+
"underline",
|
|
49
|
+
"heading",
|
|
50
|
+
"list",
|
|
51
|
+
"align",
|
|
52
|
+
"link",
|
|
53
|
+
"undo",
|
|
54
|
+
"redo"
|
|
55
|
+
]);
|
|
56
|
+
const registry = /* @__PURE__ */ new Map();
|
|
57
|
+
const formattingHandlers = createFormattingCommandHandlers();
|
|
58
|
+
const registerIfAllowed = (name, handler) => {
|
|
59
|
+
if (allowed.has(name)) registry.set(name, handler);
|
|
60
|
+
};
|
|
61
|
+
Object.entries(formattingHandlers).forEach(([commandName, handler]) => {
|
|
62
|
+
registerIfAllowed(commandName, handler);
|
|
63
|
+
});
|
|
64
|
+
return registry;
|
|
65
|
+
};
|
|
66
|
+
const executeCommand = (registry, commandName, context, params) => {
|
|
67
|
+
const handler = registry.get(commandName);
|
|
68
|
+
if (!handler) return {
|
|
69
|
+
document: context.document,
|
|
70
|
+
diagnostics: [createUnknownCommandDiagnostic(commandName)]
|
|
71
|
+
};
|
|
72
|
+
return handler(context, params);
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/core/history.ts
|
|
76
|
+
var EditorHistory = class {
|
|
77
|
+
undoStack = [];
|
|
78
|
+
redoStack = [];
|
|
79
|
+
record(snapshot) {
|
|
80
|
+
this.undoStack.push(snapshot);
|
|
81
|
+
this.redoStack.length = 0;
|
|
82
|
+
}
|
|
83
|
+
undo(current) {
|
|
84
|
+
const previous = this.undoStack.pop();
|
|
85
|
+
if (!previous) return current;
|
|
86
|
+
this.redoStack.push(current);
|
|
87
|
+
return {
|
|
88
|
+
...previous,
|
|
89
|
+
revision: current.revision + 1,
|
|
90
|
+
metadata: {
|
|
91
|
+
...previous.metadata,
|
|
92
|
+
lastCommand: "undo"
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
redo(current) {
|
|
97
|
+
const next = this.redoStack.pop();
|
|
98
|
+
if (!next) return current;
|
|
99
|
+
this.undoStack.push(current);
|
|
100
|
+
return {
|
|
101
|
+
...next,
|
|
102
|
+
revision: current.revision + 1,
|
|
103
|
+
metadata: {
|
|
104
|
+
...next.metadata,
|
|
105
|
+
lastCommand: "redo"
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/transforms/html.ts
|
|
112
|
+
const stripHtml = (value) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
113
|
+
const allowedStyleProperties = new Set([
|
|
114
|
+
"font-family",
|
|
115
|
+
"font-size",
|
|
116
|
+
"text-align",
|
|
117
|
+
"margin-left"
|
|
118
|
+
]);
|
|
119
|
+
const normalizeStyleValue = (property, value) => {
|
|
120
|
+
const next = value.trim().toLowerCase();
|
|
121
|
+
if (property === "font-family") return next === "serif" || next === "monospace" ? next : null;
|
|
122
|
+
if (property === "font-size") return [
|
|
123
|
+
"12px",
|
|
124
|
+
"16px",
|
|
125
|
+
"20px",
|
|
126
|
+
"28px"
|
|
127
|
+
].includes(next) ? next : null;
|
|
128
|
+
if (property === "text-align") return [
|
|
129
|
+
"left",
|
|
130
|
+
"center",
|
|
131
|
+
"right",
|
|
132
|
+
"justify"
|
|
133
|
+
].includes(next) ? next : null;
|
|
134
|
+
if (property === "margin-left") {
|
|
135
|
+
const match = /^([0-9]{1,3})px$/.exec(next);
|
|
136
|
+
if (!match) return null;
|
|
137
|
+
const amount = Number.parseInt(match[1], 10);
|
|
138
|
+
return amount >= 0 && amount % 24 === 0 ? `${amount}px` : null;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
};
|
|
142
|
+
const normalizeStyleAttribute = (styleValue) => {
|
|
143
|
+
const entries = styleValue.split(";").map((entry) => entry.trim()).filter((entry) => entry.length > 0).map((entry) => {
|
|
144
|
+
const separator = entry.indexOf(":");
|
|
145
|
+
if (separator < 0) return null;
|
|
146
|
+
const property = entry.slice(0, separator).trim().toLowerCase();
|
|
147
|
+
const value = entry.slice(separator + 1).trim();
|
|
148
|
+
if (!allowedStyleProperties.has(property)) return null;
|
|
149
|
+
const normalized = normalizeStyleValue(property, value);
|
|
150
|
+
if (!normalized) return null;
|
|
151
|
+
return `${property}:${normalized}`;
|
|
152
|
+
}).filter((entry) => Boolean(entry));
|
|
153
|
+
if (entries.length === 0) return null;
|
|
154
|
+
return entries.join(";");
|
|
155
|
+
};
|
|
156
|
+
const normalizeHtml = (value) => {
|
|
157
|
+
if (typeof DOMParser === "undefined") return value.trim().length > 0 ? value : "<p></p>";
|
|
158
|
+
const doc = new DOMParser().parseFromString(`<div>${value}</div>`, "text/html");
|
|
159
|
+
const root = doc.body.firstElementChild;
|
|
160
|
+
if (!root) return "<p></p>";
|
|
161
|
+
root.querySelectorAll("b").forEach((element) => {
|
|
162
|
+
const replacement = doc.createElement("strong");
|
|
163
|
+
replacement.innerHTML = element.innerHTML;
|
|
164
|
+
element.replaceWith(replacement);
|
|
165
|
+
});
|
|
166
|
+
root.querySelectorAll("i").forEach((element) => {
|
|
167
|
+
const replacement = doc.createElement("em");
|
|
168
|
+
replacement.innerHTML = element.innerHTML;
|
|
169
|
+
element.replaceWith(replacement);
|
|
170
|
+
});
|
|
171
|
+
root.querySelectorAll("strike").forEach((element) => {
|
|
172
|
+
const replacement = doc.createElement("s");
|
|
173
|
+
replacement.innerHTML = element.innerHTML;
|
|
174
|
+
element.replaceWith(replacement);
|
|
175
|
+
});
|
|
176
|
+
root.querySelectorAll("*").forEach((element) => {
|
|
177
|
+
const tag = element.tagName.toLowerCase();
|
|
178
|
+
Array.from(element.attributes).forEach((attribute) => {
|
|
179
|
+
const name = attribute.name.toLowerCase();
|
|
180
|
+
const valueAttr = attribute.value;
|
|
181
|
+
if (name === "style") {
|
|
182
|
+
const normalized = normalizeStyleAttribute(valueAttr);
|
|
183
|
+
if (normalized) element.setAttribute("style", normalized);
|
|
184
|
+
else element.removeAttribute("style");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (name === "href" || name === "src") {
|
|
188
|
+
if (!/^(https?:|mailto:|tel:)/i.test(valueAttr.trim())) element.removeAttribute(attribute.name);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (name === "target" || name === "rel" || name === "alt" || name === "data-formula" || name === "controls" || name === "dir") return;
|
|
192
|
+
element.removeAttribute(attribute.name);
|
|
193
|
+
});
|
|
194
|
+
if (tag === "a") {
|
|
195
|
+
element.setAttribute("rel", "noopener noreferrer");
|
|
196
|
+
element.setAttribute("target", "_blank");
|
|
197
|
+
}
|
|
198
|
+
if (tag === "video") element.setAttribute("controls", "true");
|
|
199
|
+
});
|
|
200
|
+
Array.from(root.childNodes).forEach((node) => {
|
|
201
|
+
if (node.nodeType === Node.TEXT_NODE && (node.textContent?.trim().length ?? 0) > 0) {
|
|
202
|
+
const paragraph = doc.createElement("p");
|
|
203
|
+
paragraph.textContent = node.textContent ?? "";
|
|
204
|
+
root.replaceChild(paragraph, node);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
const normalized = root.innerHTML.trim();
|
|
208
|
+
return normalized.length > 0 ? normalized : "<p></p>";
|
|
209
|
+
};
|
|
210
|
+
const sanitizeHtml = (payload) => DOMPurify.sanitize(payload, {
|
|
211
|
+
ALLOWED_TAGS: [
|
|
212
|
+
"p",
|
|
213
|
+
"br",
|
|
214
|
+
"b",
|
|
215
|
+
"i",
|
|
216
|
+
"strike",
|
|
217
|
+
"strong",
|
|
218
|
+
"em",
|
|
219
|
+
"u",
|
|
220
|
+
"s",
|
|
221
|
+
"code",
|
|
222
|
+
"pre",
|
|
223
|
+
"blockquote",
|
|
224
|
+
"h1",
|
|
225
|
+
"h2",
|
|
226
|
+
"ul",
|
|
227
|
+
"ol",
|
|
228
|
+
"li",
|
|
229
|
+
"a",
|
|
230
|
+
"img",
|
|
231
|
+
"video",
|
|
232
|
+
"span",
|
|
233
|
+
"sub",
|
|
234
|
+
"sup",
|
|
235
|
+
"div"
|
|
236
|
+
],
|
|
237
|
+
ALLOWED_ATTR: [
|
|
238
|
+
"href",
|
|
239
|
+
"src",
|
|
240
|
+
"alt",
|
|
241
|
+
"target",
|
|
242
|
+
"rel",
|
|
243
|
+
"style",
|
|
244
|
+
"data-formula",
|
|
245
|
+
"controls",
|
|
246
|
+
"dir"
|
|
247
|
+
],
|
|
248
|
+
FORBID_TAGS: [
|
|
249
|
+
"script",
|
|
250
|
+
"style",
|
|
251
|
+
"iframe",
|
|
252
|
+
"object",
|
|
253
|
+
"embed",
|
|
254
|
+
"form",
|
|
255
|
+
"input",
|
|
256
|
+
"button",
|
|
257
|
+
"textarea",
|
|
258
|
+
"select"
|
|
259
|
+
]
|
|
260
|
+
});
|
|
261
|
+
const importHtml = (payload) => {
|
|
262
|
+
const diagnostics = [];
|
|
263
|
+
const normalized = normalizeHtml(sanitizeHtml(payload));
|
|
264
|
+
if (normalized !== payload) diagnostics.push(createDiagnostic("SANITIZED_CONTENT", "warning", "Input HTML was sanitized to remove unsupported or unsafe markup."));
|
|
265
|
+
if (normalized.trim().length === 0) diagnostics.push(createDiagnostic("EMPTY_HTML", "info", "The provided HTML content is empty after normalization."));
|
|
266
|
+
return {
|
|
267
|
+
richText: stripHtml(normalized),
|
|
268
|
+
sanitizedHtml: normalized,
|
|
269
|
+
diagnostics
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
const exportHtml = (richText) => {
|
|
273
|
+
return normalizeHtml(`<p>${richText.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\n/g, "<br>")}</p>`);
|
|
274
|
+
};
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/transforms/diagnostics.ts
|
|
277
|
+
const createUnsupportedMarkupDiagnostic = (details) => createDiagnostic("UNSUPPORTED_MARKUP", "warning", details ?? "Some markup could not be fully represented and was normalized.");
|
|
278
|
+
const createRecoverableParseDiagnostic = (format, details) => createDiagnostic("RECOVERABLE_PARSE", "warning", details ?? `Some ${format.toUpperCase()} content was partially recovered during import.`);
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/transforms/importFallback.ts
|
|
281
|
+
const applyImportFallback = (input, base) => {
|
|
282
|
+
const payload = input.payload.trim();
|
|
283
|
+
const diagnostics = [createRecoverableParseDiagnostic(input.format === "html" ? "html" : "markdown")];
|
|
284
|
+
const stripped = input.format === "html" ? payload.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() : payload;
|
|
285
|
+
if (stripped.length === 0) diagnostics.push(createUnsupportedMarkupDiagnostic("Input produced no recoverable text content after normalization."));
|
|
286
|
+
return {
|
|
287
|
+
document: {
|
|
288
|
+
...base,
|
|
289
|
+
content: {
|
|
290
|
+
richText: stripped,
|
|
291
|
+
html: input.format === "html" ? payload : base.content.html
|
|
292
|
+
},
|
|
293
|
+
metadata: {
|
|
294
|
+
...base.metadata,
|
|
295
|
+
importFallback: true
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
diagnostics
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/transforms/markdown.ts
|
|
303
|
+
const normalizeMarkdown = (value) => value.replace(/\r\n?/g, "\n");
|
|
304
|
+
const importMarkdown = (payload) => {
|
|
305
|
+
const diagnostics = [];
|
|
306
|
+
const normalized = normalizeMarkdown(payload);
|
|
307
|
+
if (normalized.trim().length === 0) diagnostics.push(createDiagnostic("EMPTY_MARKDOWN", "info", "The provided markdown content is empty."));
|
|
308
|
+
return {
|
|
309
|
+
richText: normalized,
|
|
310
|
+
diagnostics
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
const exportMarkdown = (richText) => normalizeMarkdown(richText);
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/transforms/representationSwitch.ts
|
|
316
|
+
const importRepresentation = (input, base) => {
|
|
317
|
+
if (input.format === "rich") {
|
|
318
|
+
const normalizedHtml = normalizeHtml(exportHtml(input.payload));
|
|
319
|
+
return {
|
|
320
|
+
document: {
|
|
321
|
+
...base,
|
|
322
|
+
content: {
|
|
323
|
+
richText: input.payload,
|
|
324
|
+
html: normalizedHtml
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
diagnostics: []
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (input.format === "markdown") try {
|
|
331
|
+
const result = importMarkdown(input.payload);
|
|
332
|
+
return {
|
|
333
|
+
document: {
|
|
334
|
+
...base,
|
|
335
|
+
content: { richText: result.richText },
|
|
336
|
+
metadata: {
|
|
337
|
+
...base.metadata,
|
|
338
|
+
importedFrom: "markdown"
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
diagnostics: result.diagnostics
|
|
342
|
+
};
|
|
343
|
+
} catch {
|
|
344
|
+
return applyImportFallback(input, base);
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const result = importHtml(input.payload);
|
|
348
|
+
return {
|
|
349
|
+
document: {
|
|
350
|
+
...base,
|
|
351
|
+
content: {
|
|
352
|
+
richText: result.richText,
|
|
353
|
+
html: result.sanitizedHtml
|
|
354
|
+
},
|
|
355
|
+
metadata: {
|
|
356
|
+
...base.metadata,
|
|
357
|
+
importedFrom: "html"
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
diagnostics: result.diagnostics
|
|
361
|
+
};
|
|
362
|
+
} catch {
|
|
363
|
+
return applyImportFallback(input, base);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const exportRepresentation = (document, format) => {
|
|
367
|
+
if (format === "rich") return {
|
|
368
|
+
format,
|
|
369
|
+
payload: document.content.richText,
|
|
370
|
+
diagnostics: []
|
|
371
|
+
};
|
|
372
|
+
if (format === "markdown") return {
|
|
373
|
+
format,
|
|
374
|
+
payload: exportMarkdown(document.content.richText),
|
|
375
|
+
diagnostics: []
|
|
376
|
+
};
|
|
377
|
+
return {
|
|
378
|
+
format,
|
|
379
|
+
payload: document.content.html ? normalizeHtml(document.content.html) : normalizeHtml(exportHtml(document.content.richText)),
|
|
380
|
+
diagnostics: []
|
|
381
|
+
};
|
|
382
|
+
};
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/core/createEditor.ts
|
|
385
|
+
const createDefaultDocument = () => ({
|
|
386
|
+
id: crypto.randomUUID(),
|
|
387
|
+
content: { richText: "" },
|
|
388
|
+
metadata: {},
|
|
389
|
+
revision: 0
|
|
390
|
+
});
|
|
391
|
+
const fromInput = (input, base) => importRepresentation(input, base);
|
|
392
|
+
const toExport = (document, format) => exportRepresentation(document, format);
|
|
393
|
+
const createEditor = (config = {}) => {
|
|
394
|
+
const registry = createCommandRegistry(config.commandCapabilities);
|
|
395
|
+
const history = new EditorHistory();
|
|
396
|
+
let isDestroyed = false;
|
|
397
|
+
let currentDocument = createDefaultDocument();
|
|
398
|
+
if (config.initialContent) {
|
|
399
|
+
const initialized = fromInput(config.initialContent, currentDocument);
|
|
400
|
+
currentDocument = initialized.document;
|
|
401
|
+
if (initialized.diagnostics.length > 0) config.onError?.(createRecoverableError("INITIAL_CONTENT_DIAGNOSTIC", "Initial content was normalized during editor initialization.", { diagnostics: initialized.diagnostics }));
|
|
402
|
+
}
|
|
403
|
+
const ensureActive = () => {
|
|
404
|
+
if (!isDestroyed) return true;
|
|
405
|
+
config.onError?.(createRecoverableError("EDITOR_DESTROYED", "This editor instance has already been destroyed."));
|
|
406
|
+
return false;
|
|
407
|
+
};
|
|
408
|
+
const emitChange = () => {
|
|
409
|
+
config.onChange?.(currentDocument);
|
|
410
|
+
};
|
|
411
|
+
return {
|
|
412
|
+
getDocument: () => currentDocument,
|
|
413
|
+
execute: (commandName, params) => {
|
|
414
|
+
if (!ensureActive()) return {
|
|
415
|
+
ok: false,
|
|
416
|
+
document: currentDocument,
|
|
417
|
+
diagnostics: []
|
|
418
|
+
};
|
|
419
|
+
const result = executeCommand(registry, commandName, { document: currentDocument }, params);
|
|
420
|
+
if (commandName === "undo") {
|
|
421
|
+
const next = history.undo(currentDocument);
|
|
422
|
+
if (next === currentDocument) return {
|
|
423
|
+
ok: false,
|
|
424
|
+
document: currentDocument,
|
|
425
|
+
diagnostics: [createNoHistoryDiagnostic("undo")]
|
|
426
|
+
};
|
|
427
|
+
currentDocument = next;
|
|
428
|
+
emitChange();
|
|
429
|
+
return {
|
|
430
|
+
ok: true,
|
|
431
|
+
document: currentDocument,
|
|
432
|
+
diagnostics: []
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (commandName === "redo") {
|
|
436
|
+
const next = history.redo(currentDocument);
|
|
437
|
+
if (next === currentDocument) return {
|
|
438
|
+
ok: false,
|
|
439
|
+
document: currentDocument,
|
|
440
|
+
diagnostics: [createNoHistoryDiagnostic("redo")]
|
|
441
|
+
};
|
|
442
|
+
currentDocument = next;
|
|
443
|
+
emitChange();
|
|
444
|
+
return {
|
|
445
|
+
ok: true,
|
|
446
|
+
document: currentDocument,
|
|
447
|
+
diagnostics: []
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
history.record(currentDocument);
|
|
451
|
+
currentDocument = result.document;
|
|
452
|
+
emitChange();
|
|
453
|
+
return {
|
|
454
|
+
ok: (result.diagnostics?.length ?? 0) === 0,
|
|
455
|
+
document: currentDocument,
|
|
456
|
+
diagnostics: result.diagnostics ?? []
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
export: (format) => toExport(currentDocument, format),
|
|
460
|
+
import: (input) => {
|
|
461
|
+
if (!ensureActive()) return {
|
|
462
|
+
ok: false,
|
|
463
|
+
document: currentDocument,
|
|
464
|
+
diagnostics: []
|
|
465
|
+
};
|
|
466
|
+
const result = fromInput(input, {
|
|
467
|
+
...currentDocument,
|
|
468
|
+
revision: currentDocument.revision + 1
|
|
469
|
+
});
|
|
470
|
+
currentDocument = result.document;
|
|
471
|
+
emitChange();
|
|
472
|
+
return {
|
|
473
|
+
ok: result.diagnostics.length === 0,
|
|
474
|
+
document: currentDocument,
|
|
475
|
+
diagnostics: result.diagnostics
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
mount: (target) => {
|
|
479
|
+
if (!ensureActive()) return;
|
|
480
|
+
},
|
|
481
|
+
unmount: () => {},
|
|
482
|
+
destroy: () => {
|
|
483
|
+
isDestroyed = true;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
};
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/core/schema.ts
|
|
489
|
+
const editorNodeNames = {
|
|
490
|
+
doc: "doc",
|
|
491
|
+
paragraph: "paragraph",
|
|
492
|
+
heading: "heading",
|
|
493
|
+
text: "text",
|
|
494
|
+
bulletList: "bullet_list",
|
|
495
|
+
orderedList: "ordered_list",
|
|
496
|
+
listItem: "list_item",
|
|
497
|
+
hardBreak: "hard_break"
|
|
498
|
+
};
|
|
499
|
+
const editorMarkNames = {
|
|
500
|
+
strong: "strong",
|
|
501
|
+
em: "em",
|
|
502
|
+
underline: "underline",
|
|
503
|
+
link: "link"
|
|
504
|
+
};
|
|
505
|
+
const createEditorSchema = () => new Schema({
|
|
506
|
+
nodes: {
|
|
507
|
+
doc: { content: "block+" },
|
|
508
|
+
paragraph: {
|
|
509
|
+
content: "inline*",
|
|
510
|
+
group: "block",
|
|
511
|
+
parseDOM: [{ tag: "p" }],
|
|
512
|
+
toDOM: () => ["p", 0]
|
|
513
|
+
},
|
|
514
|
+
heading: {
|
|
515
|
+
attrs: { level: { default: 1 } },
|
|
516
|
+
content: "inline*",
|
|
517
|
+
group: "block",
|
|
518
|
+
defining: true,
|
|
519
|
+
parseDOM: [
|
|
520
|
+
{
|
|
521
|
+
tag: "h1",
|
|
522
|
+
attrs: { level: 1 }
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
tag: "h2",
|
|
526
|
+
attrs: { level: 2 }
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
tag: "h3",
|
|
530
|
+
attrs: { level: 3 }
|
|
531
|
+
}
|
|
532
|
+
],
|
|
533
|
+
toDOM: (node) => [`h${Math.max(1, Math.min(3, Number(node.attrs.level) || 1))}`, 0]
|
|
534
|
+
},
|
|
535
|
+
bullet_list: {
|
|
536
|
+
content: "list_item+",
|
|
537
|
+
group: "block",
|
|
538
|
+
parseDOM: [{ tag: "ul" }],
|
|
539
|
+
toDOM: () => ["ul", 0]
|
|
540
|
+
},
|
|
541
|
+
ordered_list: {
|
|
542
|
+
attrs: { order: { default: 1 } },
|
|
543
|
+
content: "list_item+",
|
|
544
|
+
group: "block",
|
|
545
|
+
parseDOM: [{
|
|
546
|
+
tag: "ol",
|
|
547
|
+
getAttrs: (dom) => {
|
|
548
|
+
if (!(dom instanceof HTMLOListElement)) return { order: 1 };
|
|
549
|
+
return { order: dom.start || 1 };
|
|
550
|
+
}
|
|
551
|
+
}],
|
|
552
|
+
toDOM: (node) => [
|
|
553
|
+
"ol",
|
|
554
|
+
{ start: node.attrs.order || 1 },
|
|
555
|
+
0
|
|
556
|
+
]
|
|
557
|
+
},
|
|
558
|
+
list_item: {
|
|
559
|
+
content: "paragraph block*",
|
|
560
|
+
parseDOM: [{ tag: "li" }],
|
|
561
|
+
toDOM: () => ["li", 0]
|
|
562
|
+
},
|
|
563
|
+
text: { group: "inline" },
|
|
564
|
+
hard_break: {
|
|
565
|
+
inline: true,
|
|
566
|
+
group: "inline",
|
|
567
|
+
selectable: false,
|
|
568
|
+
parseDOM: [{ tag: "br" }],
|
|
569
|
+
toDOM: () => ["br"]
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
marks: {
|
|
573
|
+
strong: {
|
|
574
|
+
parseDOM: [{ tag: "strong" }, {
|
|
575
|
+
tag: "b",
|
|
576
|
+
getAttrs: () => null
|
|
577
|
+
}],
|
|
578
|
+
toDOM: () => ["strong", 0]
|
|
579
|
+
},
|
|
580
|
+
em: {
|
|
581
|
+
parseDOM: [{ tag: "em" }, {
|
|
582
|
+
tag: "i",
|
|
583
|
+
getAttrs: () => null
|
|
584
|
+
}],
|
|
585
|
+
toDOM: () => ["em", 0]
|
|
586
|
+
},
|
|
587
|
+
underline: {
|
|
588
|
+
parseDOM: [{ tag: "u" }],
|
|
589
|
+
toDOM: () => ["u", 0]
|
|
590
|
+
},
|
|
591
|
+
link: {
|
|
592
|
+
attrs: {
|
|
593
|
+
href: {},
|
|
594
|
+
title: { default: null }
|
|
595
|
+
},
|
|
596
|
+
inclusive: false,
|
|
597
|
+
parseDOM: [{
|
|
598
|
+
tag: "a[href]",
|
|
599
|
+
getAttrs: (dom) => {
|
|
600
|
+
if (!(dom instanceof HTMLAnchorElement)) return false;
|
|
601
|
+
return {
|
|
602
|
+
href: dom.getAttribute("href"),
|
|
603
|
+
title: dom.getAttribute("title")
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
}],
|
|
607
|
+
toDOM: (node) => [
|
|
608
|
+
"a",
|
|
609
|
+
{
|
|
610
|
+
href: node.attrs.href,
|
|
611
|
+
title: node.attrs.title
|
|
612
|
+
},
|
|
613
|
+
0
|
|
614
|
+
]
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
const editorSchema = createEditorSchema();
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region src/core/toolbarModel.ts
|
|
621
|
+
const defaultToolbarActions = [
|
|
622
|
+
{
|
|
623
|
+
command: "bold",
|
|
624
|
+
label: "Bold",
|
|
625
|
+
group: "inline"
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
command: "italic",
|
|
629
|
+
label: "Italic",
|
|
630
|
+
group: "inline"
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
command: "underline",
|
|
634
|
+
label: "Underline",
|
|
635
|
+
group: "inline"
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
command: "heading",
|
|
639
|
+
label: "Heading",
|
|
640
|
+
group: "block"
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
command: "list",
|
|
644
|
+
label: "List",
|
|
645
|
+
group: "block"
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
command: "align",
|
|
649
|
+
label: "Align",
|
|
650
|
+
group: "block"
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
command: "link",
|
|
654
|
+
label: "Link",
|
|
655
|
+
group: "inline"
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
command: "undo",
|
|
659
|
+
label: "Undo",
|
|
660
|
+
group: "history"
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
command: "redo",
|
|
664
|
+
label: "Redo",
|
|
665
|
+
group: "history"
|
|
666
|
+
}
|
|
667
|
+
];
|
|
668
|
+
//#endregion
|
|
669
|
+
//#region src/rich-text/mountEditor.ts
|
|
670
|
+
const commandLabels = {
|
|
671
|
+
bold: "Bold",
|
|
672
|
+
italic: "Italic",
|
|
673
|
+
underline: "Underline",
|
|
674
|
+
strike: "Strike",
|
|
675
|
+
code: "Inline code",
|
|
676
|
+
blockquote: "Blockquote",
|
|
677
|
+
"code-block": "Code block",
|
|
678
|
+
"indent-": "Outdent",
|
|
679
|
+
"indent+": "Indent",
|
|
680
|
+
link: "Link",
|
|
681
|
+
image: "Image",
|
|
682
|
+
video: "Video",
|
|
683
|
+
formula: "Formula",
|
|
684
|
+
clean: "Remove formatting"
|
|
685
|
+
};
|
|
686
|
+
const commandIconSvg = {
|
|
687
|
+
bold: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 2.5h4.5a2.5 2.5 0 010 5H4z\"/><path d=\"M4 7.5h5a3 3 0 010 6H4z\"/></svg>",
|
|
688
|
+
italic: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"><path d=\"M9.8 2.5H5.5\"/><path d=\"M10.5 13.5H6.2\"/><path d=\"M9.3 2.5 6.7 13.5\"/></svg>",
|
|
689
|
+
underline: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M4 2.5v4.2a4 4 0 008 0V2.5\"/><path d=\"M3 13.5h10\"/></svg>",
|
|
690
|
+
strike: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M4 4.5c.8-1.2 2-1.9 3.7-1.9 2 0 3.3 1 3.3 2.5 0 3-6.5 1.8-6.5 4.6 0 1.5 1.3 2.3 3.4 2.3 1.6 0 2.9-.6 3.8-1.7\"/><path d=\"M2.5 8h11\"/></svg>",
|
|
691
|
+
code: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 4 2.5 8 6 12\"/><path d=\"M10 4 13.5 8 10 12\"/><path d=\"M8.7 2.8 7.3 13.2\"/></svg>",
|
|
692
|
+
blockquote: "<svg viewBox=\"0 0 16 16\" fill=\"currentColor\"><path d=\"M3 3h4v4H4.8c.1 1.8-.7 3.2-1.8 4L2 10c1-.7 1.5-1.7 1.4-3H3zM9 3h4v4h-2.2c.1 1.8-.7 3.2-1.8 4L8 10c1-.7 1.5-1.7 1.4-3H9z\"/></svg>",
|
|
693
|
+
"code-block": "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2.5\" y=\"2.5\" width=\"11\" height=\"11\" rx=\"1.8\"/><path d=\"M6.2 6 4.5 8l1.7 2\"/><path d=\"M9.8 6 11.5 8l-1.7 2\"/><path d=\"M8.6 5.5 7.4 10.5\"/></svg>",
|
|
694
|
+
"indent-": "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 4h4\"/><path d=\"M9 8h4\"/><path d=\"M9 12h4\"/><path d=\"M7 5.5 4 8l3 2.5\"/></svg>",
|
|
695
|
+
"indent+": "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 4h4\"/><path d=\"M3 8h4\"/><path d=\"M3 12h4\"/><path d=\"M9 5.5 12 8l-3 2.5\"/></svg>",
|
|
696
|
+
link: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M6.3 9.7 4.9 11a2.4 2.4 0 103.4 3.4l1.5-1.4\"/><path d=\"M9.7 6.3 11 4.9a2.4 2.4 0 10-3.4-3.4L6.1 2.9\"/><path d=\"M5.9 10.1 10.1 5.9\"/></svg>",
|
|
697
|
+
image: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2.3\" y=\"2.5\" width=\"11.4\" height=\"11\" rx=\"1.8\"/><circle cx=\"6\" cy=\"6\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/><path d=\"m3.8 11 2.8-2.8 2.2 2.2 1.6-1.6 1.8 2.2\"/></svg>",
|
|
698
|
+
video: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2.2\" y=\"3\" width=\"8.8\" height=\"10\" rx=\"1.6\"/><path d=\"M11 6.2 13.8 4.8v6.4L11 9.8z\"/><path d=\"M6.2 6.4v3.2l2.7-1.6z\" fill=\"currentColor\" stroke=\"none\"/></svg>",
|
|
699
|
+
formula: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 4h5\"/><path d=\"M3 12h5\"/><path d=\"M8 4 5 8l3 4\"/><path d=\"M10.3 5.8v4.4\"/><path d=\"M12.5 8h-4.4\"/></svg>",
|
|
700
|
+
clean: "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2.8 12.5h6.8\"/><path d=\"m4.2 3.3 6 6\"/><path d=\"m10.2 2.7 3 3-2 2-3-3z\"/><path d=\"M6.2 11.2 4.1 13.3\"/></svg>"
|
|
701
|
+
};
|
|
702
|
+
const createToolbarIcon = (command, fallback) => {
|
|
703
|
+
const icon = document.createElement("span");
|
|
704
|
+
icon.className = "az-rich-editor-toolbar-icon";
|
|
705
|
+
const svgMarkup = commandIconSvg[command];
|
|
706
|
+
if (svgMarkup) icon.innerHTML = svgMarkup;
|
|
707
|
+
else icon.textContent = fallback;
|
|
708
|
+
return icon;
|
|
709
|
+
};
|
|
710
|
+
const selectOptionLabels = {
|
|
711
|
+
font: {
|
|
712
|
+
default: "A",
|
|
713
|
+
serif: "As",
|
|
714
|
+
monospace: "Am"
|
|
715
|
+
},
|
|
716
|
+
size: {
|
|
717
|
+
small: "S",
|
|
718
|
+
normal: "N",
|
|
719
|
+
large: "L",
|
|
720
|
+
huge: "XL"
|
|
721
|
+
},
|
|
722
|
+
header: {
|
|
723
|
+
normal: "P",
|
|
724
|
+
h1: "H1",
|
|
725
|
+
h2: "H2"
|
|
726
|
+
},
|
|
727
|
+
script: {
|
|
728
|
+
normal: "x",
|
|
729
|
+
sub: "x_",
|
|
730
|
+
super: "x^"
|
|
731
|
+
},
|
|
732
|
+
align: {
|
|
733
|
+
left: "L",
|
|
734
|
+
center: "C",
|
|
735
|
+
right: "R",
|
|
736
|
+
justify: "J"
|
|
737
|
+
},
|
|
738
|
+
list: {
|
|
739
|
+
none: "-",
|
|
740
|
+
ordered: "1.",
|
|
741
|
+
bullet: "o"
|
|
742
|
+
},
|
|
743
|
+
direction: {
|
|
744
|
+
ltr: "->",
|
|
745
|
+
rtl: "<-"
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
const safeUrl = (value) => {
|
|
749
|
+
const trimmed = value.trim();
|
|
750
|
+
if (!trimmed) return null;
|
|
751
|
+
if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return trimmed;
|
|
752
|
+
return null;
|
|
753
|
+
};
|
|
754
|
+
const closestElement = (node, root) => {
|
|
755
|
+
let current = node;
|
|
756
|
+
while (current && current !== root) {
|
|
757
|
+
if (current instanceof HTMLElement) return current;
|
|
758
|
+
current = current.parentNode;
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
};
|
|
762
|
+
const closestByTag = (node, tagName, root) => {
|
|
763
|
+
const desired = tagName.toLowerCase();
|
|
764
|
+
let current = node;
|
|
765
|
+
while (current && current !== root) {
|
|
766
|
+
if (current instanceof HTMLElement && current.tagName.toLowerCase() === desired) return current;
|
|
767
|
+
current = current.parentNode;
|
|
768
|
+
}
|
|
769
|
+
return null;
|
|
770
|
+
};
|
|
771
|
+
const unwrapElement = (element) => {
|
|
772
|
+
const parent = element.parentNode;
|
|
773
|
+
if (!parent) return;
|
|
774
|
+
while (element.firstChild) parent.insertBefore(element.firstChild, element);
|
|
775
|
+
parent.removeChild(element);
|
|
776
|
+
};
|
|
777
|
+
const placeCaretInside = (element) => {
|
|
778
|
+
const selection = window.getSelection();
|
|
779
|
+
if (!selection) return;
|
|
780
|
+
const range = document.createRange();
|
|
781
|
+
range.selectNodeContents(element);
|
|
782
|
+
range.collapse(false);
|
|
783
|
+
selection.removeAllRanges();
|
|
784
|
+
selection.addRange(range);
|
|
785
|
+
};
|
|
786
|
+
const getSelectionRange = (root) => {
|
|
787
|
+
const selection = window.getSelection();
|
|
788
|
+
if (!selection || selection.rangeCount === 0) return null;
|
|
789
|
+
const range = selection.getRangeAt(0);
|
|
790
|
+
if (!root.contains(range.commonAncestorContainer)) return null;
|
|
791
|
+
return range;
|
|
792
|
+
};
|
|
793
|
+
const wrapRange = (range, tagName, attrs) => {
|
|
794
|
+
const wrapper = document.createElement(tagName);
|
|
795
|
+
if (attrs) Object.entries(attrs).forEach(([key, value]) => wrapper.setAttribute(key, value));
|
|
796
|
+
if (range.collapsed) {
|
|
797
|
+
wrapper.append(document.createTextNode(""));
|
|
798
|
+
range.insertNode(wrapper);
|
|
799
|
+
placeCaretInside(wrapper);
|
|
800
|
+
return wrapper;
|
|
801
|
+
}
|
|
802
|
+
const fragment = range.extractContents();
|
|
803
|
+
wrapper.append(fragment);
|
|
804
|
+
range.insertNode(wrapper);
|
|
805
|
+
const selection = window.getSelection();
|
|
806
|
+
if (selection) {
|
|
807
|
+
const nextRange = document.createRange();
|
|
808
|
+
nextRange.selectNodeContents(wrapper);
|
|
809
|
+
selection.removeAllRanges();
|
|
810
|
+
selection.addRange(nextRange);
|
|
811
|
+
}
|
|
812
|
+
return wrapper;
|
|
813
|
+
};
|
|
814
|
+
const blockTags = new Set([
|
|
815
|
+
"p",
|
|
816
|
+
"div",
|
|
817
|
+
"h1",
|
|
818
|
+
"h2",
|
|
819
|
+
"blockquote",
|
|
820
|
+
"pre",
|
|
821
|
+
"li"
|
|
822
|
+
]);
|
|
823
|
+
const getBlockElement = (range, root) => {
|
|
824
|
+
const start = closestElement(range.startContainer, root);
|
|
825
|
+
if (!start) return root;
|
|
826
|
+
let current = start;
|
|
827
|
+
while (current && current !== root) {
|
|
828
|
+
if (blockTags.has(current.tagName.toLowerCase())) return current;
|
|
829
|
+
current = current.parentElement;
|
|
830
|
+
}
|
|
831
|
+
return root;
|
|
832
|
+
};
|
|
833
|
+
const replaceBlockTag = (block, tagName) => {
|
|
834
|
+
if (block.tagName.toLowerCase() === tagName.toLowerCase()) return block;
|
|
835
|
+
const replacement = document.createElement(tagName);
|
|
836
|
+
while (block.firstChild) replacement.append(block.firstChild);
|
|
837
|
+
Array.from(block.attributes).forEach((attribute) => {
|
|
838
|
+
if (attribute.name === "style" || attribute.name === "dir") replacement.setAttribute(attribute.name, attribute.value);
|
|
839
|
+
});
|
|
840
|
+
block.parentNode?.replaceChild(replacement, block);
|
|
841
|
+
return replacement;
|
|
842
|
+
};
|
|
843
|
+
const applyInlineTag = (tagName, root, range) => {
|
|
844
|
+
const existing = closestByTag(range.commonAncestorContainer, tagName, root);
|
|
845
|
+
if (existing) {
|
|
846
|
+
unwrapElement(existing);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
wrapRange(range, tagName);
|
|
850
|
+
};
|
|
851
|
+
const applyScript = (value, root, range) => {
|
|
852
|
+
const sub = closestByTag(range.commonAncestorContainer, "sub", root);
|
|
853
|
+
const sup = closestByTag(range.commonAncestorContainer, "sup", root);
|
|
854
|
+
if (sub) unwrapElement(sub);
|
|
855
|
+
if (sup) unwrapElement(sup);
|
|
856
|
+
if (value === "sub") wrapRange(range, "sub");
|
|
857
|
+
if (value === "super") wrapRange(range, "sup");
|
|
858
|
+
};
|
|
859
|
+
const applyHeader = (value, root, range) => {
|
|
860
|
+
const block = getBlockElement(range, root);
|
|
861
|
+
if (value === "h1" || value === "h2") {
|
|
862
|
+
replaceBlockTag(block, value);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
replaceBlockTag(block, "p");
|
|
866
|
+
};
|
|
867
|
+
const toggleBlockTag = (tagName, root, range) => {
|
|
868
|
+
const block = getBlockElement(range, root);
|
|
869
|
+
if (block.tagName.toLowerCase() === tagName) {
|
|
870
|
+
replaceBlockTag(block, "p");
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
replaceBlockTag(block, tagName);
|
|
874
|
+
};
|
|
875
|
+
const ensureList = (tagName, root, range, remove) => {
|
|
876
|
+
const block = getBlockElement(range, root);
|
|
877
|
+
const li = closestByTag(block, "li", root);
|
|
878
|
+
if (li) {
|
|
879
|
+
const list = li.parentElement;
|
|
880
|
+
if (!list) return;
|
|
881
|
+
if (remove || list.tagName.toLowerCase() === tagName) {
|
|
882
|
+
const paragraph = document.createElement("p");
|
|
883
|
+
paragraph.textContent = li.textContent ?? "";
|
|
884
|
+
list.parentNode?.insertBefore(paragraph, list);
|
|
885
|
+
li.remove();
|
|
886
|
+
if (!list.querySelector("li")) list.remove();
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const nextList = document.createElement(tagName);
|
|
890
|
+
const nextLi = document.createElement("li");
|
|
891
|
+
nextLi.textContent = li.textContent ?? "";
|
|
892
|
+
nextList.append(nextLi);
|
|
893
|
+
list.parentNode?.insertBefore(nextList, list.nextSibling);
|
|
894
|
+
li.remove();
|
|
895
|
+
if (!list.querySelector("li")) list.remove();
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (remove) return;
|
|
899
|
+
const list = document.createElement(tagName);
|
|
900
|
+
const listItem = document.createElement("li");
|
|
901
|
+
listItem.innerHTML = block.innerHTML;
|
|
902
|
+
list.append(listItem);
|
|
903
|
+
block.parentNode?.replaceChild(list, block);
|
|
904
|
+
};
|
|
905
|
+
const applyIndent = (root, range, direction) => {
|
|
906
|
+
const block = getBlockElement(range, root);
|
|
907
|
+
const current = Number.parseInt(block.style.marginLeft || "0", 10) || 0;
|
|
908
|
+
const next = direction === "+" ? current + 24 : Math.max(0, current - 24);
|
|
909
|
+
block.style.marginLeft = next === 0 ? "" : `${next}px`;
|
|
910
|
+
};
|
|
911
|
+
const applyAlign = (root, range, align) => {
|
|
912
|
+
const block = getBlockElement(range, root);
|
|
913
|
+
block.style.textAlign = align;
|
|
914
|
+
};
|
|
915
|
+
const applyDirection = (root, range, value) => {
|
|
916
|
+
getBlockElement(range, root).setAttribute("dir", value);
|
|
917
|
+
};
|
|
918
|
+
const clearFormatting = (root, range) => {
|
|
919
|
+
if (range.collapsed) {
|
|
920
|
+
const block = getBlockElement(range, root);
|
|
921
|
+
const text = block.textContent ?? "";
|
|
922
|
+
block.replaceChildren(document.createTextNode(text));
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const selectedText = range.toString();
|
|
926
|
+
range.deleteContents();
|
|
927
|
+
range.insertNode(document.createTextNode(selectedText));
|
|
928
|
+
};
|
|
929
|
+
const insertNodeAtRange = (range, node) => {
|
|
930
|
+
range.deleteContents();
|
|
931
|
+
range.insertNode(node);
|
|
932
|
+
const selection = window.getSelection();
|
|
933
|
+
if (selection) {
|
|
934
|
+
const after = document.createRange();
|
|
935
|
+
after.setStartAfter(node);
|
|
936
|
+
after.collapse(true);
|
|
937
|
+
selection.removeAllRanges();
|
|
938
|
+
selection.addRange(after);
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
const applyEmbed = async (type, range, requestEmbedValue) => {
|
|
942
|
+
const value = await Promise.resolve(requestEmbedValue?.(type) ?? null);
|
|
943
|
+
if (!value || value.trim().length === 0) return;
|
|
944
|
+
if (type === "formula") {
|
|
945
|
+
const formula = document.createElement("span");
|
|
946
|
+
formula.setAttribute("data-formula", "true");
|
|
947
|
+
formula.textContent = value.trim();
|
|
948
|
+
insertNodeAtRange(range, formula);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const url = safeUrl(value);
|
|
952
|
+
if (!url) return;
|
|
953
|
+
if (type === "link") {
|
|
954
|
+
const selected = range.toString().trim();
|
|
955
|
+
const anchor = document.createElement("a");
|
|
956
|
+
anchor.href = url;
|
|
957
|
+
anchor.rel = "noopener noreferrer";
|
|
958
|
+
anchor.target = "_blank";
|
|
959
|
+
anchor.textContent = selected.length > 0 ? selected : url;
|
|
960
|
+
insertNodeAtRange(range, anchor);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (type === "image") {
|
|
964
|
+
const image = document.createElement("img");
|
|
965
|
+
image.src = url;
|
|
966
|
+
image.alt = "embedded image";
|
|
967
|
+
insertNodeAtRange(range, image);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const video = document.createElement("video");
|
|
971
|
+
video.setAttribute("controls", "true");
|
|
972
|
+
video.src = url;
|
|
973
|
+
insertNodeAtRange(range, video);
|
|
974
|
+
};
|
|
975
|
+
const applyButtonCommand = async (command, editable, requestEmbedValue) => {
|
|
976
|
+
editable.focus();
|
|
977
|
+
const range = getSelectionRange(editable);
|
|
978
|
+
if (!range) return;
|
|
979
|
+
switch (command) {
|
|
980
|
+
case "bold":
|
|
981
|
+
applyInlineTag("strong", editable, range);
|
|
982
|
+
break;
|
|
983
|
+
case "italic":
|
|
984
|
+
applyInlineTag("em", editable, range);
|
|
985
|
+
break;
|
|
986
|
+
case "underline":
|
|
987
|
+
applyInlineTag("u", editable, range);
|
|
988
|
+
break;
|
|
989
|
+
case "strike":
|
|
990
|
+
applyInlineTag("s", editable, range);
|
|
991
|
+
break;
|
|
992
|
+
case "code":
|
|
993
|
+
applyInlineTag("code", editable, range);
|
|
994
|
+
break;
|
|
995
|
+
case "blockquote":
|
|
996
|
+
toggleBlockTag("blockquote", editable, range);
|
|
997
|
+
break;
|
|
998
|
+
case "code-block":
|
|
999
|
+
toggleBlockTag("pre", editable, range);
|
|
1000
|
+
break;
|
|
1001
|
+
case "indent-":
|
|
1002
|
+
applyIndent(editable, range, "-");
|
|
1003
|
+
break;
|
|
1004
|
+
case "indent+":
|
|
1005
|
+
applyIndent(editable, range, "+");
|
|
1006
|
+
break;
|
|
1007
|
+
case "link":
|
|
1008
|
+
await applyEmbed("link", range, requestEmbedValue);
|
|
1009
|
+
break;
|
|
1010
|
+
case "image":
|
|
1011
|
+
await applyEmbed("image", range, requestEmbedValue);
|
|
1012
|
+
break;
|
|
1013
|
+
case "video":
|
|
1014
|
+
await applyEmbed("video", range, requestEmbedValue);
|
|
1015
|
+
break;
|
|
1016
|
+
case "formula":
|
|
1017
|
+
await applyEmbed("formula", range, requestEmbedValue);
|
|
1018
|
+
break;
|
|
1019
|
+
case "clean":
|
|
1020
|
+
clearFormatting(editable, range);
|
|
1021
|
+
break;
|
|
1022
|
+
default: break;
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
const applySelectCommand = (command, value, editable) => {
|
|
1026
|
+
editable.focus();
|
|
1027
|
+
const range = getSelectionRange(editable);
|
|
1028
|
+
if (!range) return;
|
|
1029
|
+
switch (command) {
|
|
1030
|
+
case "font":
|
|
1031
|
+
if (value === "default") clearFormatting(editable, range);
|
|
1032
|
+
else wrapRange(range, "span", { style: `font-family:${value === "serif" ? "serif" : "monospace"}` });
|
|
1033
|
+
break;
|
|
1034
|
+
case "size":
|
|
1035
|
+
wrapRange(range, "span", { style: value === "small" ? "font-size:12px" : value === "large" ? "font-size:20px" : value === "huge" ? "font-size:28px" : "font-size:16px" });
|
|
1036
|
+
break;
|
|
1037
|
+
case "header":
|
|
1038
|
+
applyHeader(value, editable, range);
|
|
1039
|
+
break;
|
|
1040
|
+
case "script":
|
|
1041
|
+
applyScript(value, editable, range);
|
|
1042
|
+
break;
|
|
1043
|
+
case "align":
|
|
1044
|
+
applyAlign(editable, range, value);
|
|
1045
|
+
break;
|
|
1046
|
+
case "list":
|
|
1047
|
+
ensureList(value === "ordered" ? "ol" : "ul", editable, range, value === "none");
|
|
1048
|
+
break;
|
|
1049
|
+
case "direction":
|
|
1050
|
+
applyDirection(editable, range, value === "rtl" ? "rtl" : "ltr");
|
|
1051
|
+
break;
|
|
1052
|
+
default: break;
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
const renderButton = (command, editable, requestEmbedValue, onMutate) => {
|
|
1056
|
+
const button = document.createElement("button");
|
|
1057
|
+
button.type = "button";
|
|
1058
|
+
button.className = "az-rich-editor-toolbar-button";
|
|
1059
|
+
button.setAttribute("aria-label", commandLabels[command] ?? command);
|
|
1060
|
+
button.title = commandLabels[command] ?? command;
|
|
1061
|
+
button.append(createToolbarIcon(command, command));
|
|
1062
|
+
button.addEventListener("click", async () => {
|
|
1063
|
+
await applyButtonCommand(command, editable, requestEmbedValue);
|
|
1064
|
+
onMutate();
|
|
1065
|
+
});
|
|
1066
|
+
return button;
|
|
1067
|
+
};
|
|
1068
|
+
const createSelect = (format, options, editable, onMutate) => {
|
|
1069
|
+
const select = document.createElement("select");
|
|
1070
|
+
select.className = "az-rich-editor-toolbar-select";
|
|
1071
|
+
select.setAttribute("aria-label", format);
|
|
1072
|
+
options.forEach((optionValue) => {
|
|
1073
|
+
const option = document.createElement("option");
|
|
1074
|
+
option.value = optionValue;
|
|
1075
|
+
option.textContent = selectOptionLabels[format]?.[optionValue] ?? optionValue;
|
|
1076
|
+
option.title = optionValue;
|
|
1077
|
+
select.append(option);
|
|
1078
|
+
});
|
|
1079
|
+
select.addEventListener("change", () => {
|
|
1080
|
+
applySelectCommand(format, select.value, editable);
|
|
1081
|
+
onMutate();
|
|
1082
|
+
});
|
|
1083
|
+
return select;
|
|
1084
|
+
};
|
|
1085
|
+
const buildToolbar = (editable, requestEmbedValue, onMutate) => {
|
|
1086
|
+
const toolbar = document.createElement("div");
|
|
1087
|
+
toolbar.className = "az-rich-editor-toolbar";
|
|
1088
|
+
toolbar.setAttribute("role", "toolbar");
|
|
1089
|
+
toolbar.setAttribute("aria-label", "Rich editor toolbar");
|
|
1090
|
+
[
|
|
1091
|
+
{
|
|
1092
|
+
format: "font",
|
|
1093
|
+
options: [
|
|
1094
|
+
"default",
|
|
1095
|
+
"serif",
|
|
1096
|
+
"monospace"
|
|
1097
|
+
]
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
format: "size",
|
|
1101
|
+
options: [
|
|
1102
|
+
"small",
|
|
1103
|
+
"normal",
|
|
1104
|
+
"large",
|
|
1105
|
+
"huge"
|
|
1106
|
+
]
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
format: "header",
|
|
1110
|
+
options: [
|
|
1111
|
+
"normal",
|
|
1112
|
+
"h1",
|
|
1113
|
+
"h2"
|
|
1114
|
+
]
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
format: "script",
|
|
1118
|
+
options: [
|
|
1119
|
+
"normal",
|
|
1120
|
+
"sub",
|
|
1121
|
+
"super"
|
|
1122
|
+
]
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
format: "align",
|
|
1126
|
+
options: [
|
|
1127
|
+
"left",
|
|
1128
|
+
"center",
|
|
1129
|
+
"right",
|
|
1130
|
+
"justify"
|
|
1131
|
+
]
|
|
1132
|
+
},
|
|
1133
|
+
{
|
|
1134
|
+
format: "list",
|
|
1135
|
+
options: [
|
|
1136
|
+
"none",
|
|
1137
|
+
"ordered",
|
|
1138
|
+
"bullet"
|
|
1139
|
+
]
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
format: "direction",
|
|
1143
|
+
options: ["ltr", "rtl"]
|
|
1144
|
+
}
|
|
1145
|
+
].forEach((config) => toolbar.append(createSelect(config.format, config.options, editable, onMutate)));
|
|
1146
|
+
[
|
|
1147
|
+
"bold",
|
|
1148
|
+
"italic",
|
|
1149
|
+
"underline",
|
|
1150
|
+
"strike",
|
|
1151
|
+
"code",
|
|
1152
|
+
"blockquote",
|
|
1153
|
+
"code-block",
|
|
1154
|
+
"indent-",
|
|
1155
|
+
"indent+",
|
|
1156
|
+
"link",
|
|
1157
|
+
"image",
|
|
1158
|
+
"video",
|
|
1159
|
+
"formula",
|
|
1160
|
+
"clean"
|
|
1161
|
+
].forEach((command) => toolbar.append(renderButton(command, editable, requestEmbedValue, onMutate)));
|
|
1162
|
+
return toolbar;
|
|
1163
|
+
};
|
|
1164
|
+
const mountRichTextEditor = ({ host, initialHtml, disabled = false, onChange, requestEmbedValue }) => {
|
|
1165
|
+
host.innerHTML = "";
|
|
1166
|
+
const wrapper = document.createElement("div");
|
|
1167
|
+
wrapper.className = "az-rich-editor";
|
|
1168
|
+
const editable = document.createElement("div");
|
|
1169
|
+
editable.className = "az-rich-editor-input";
|
|
1170
|
+
editable.setAttribute("aria-label", "Rich editor input");
|
|
1171
|
+
editable.setAttribute("role", "textbox");
|
|
1172
|
+
editable.setAttribute("aria-multiline", "true");
|
|
1173
|
+
editable.setAttribute("contenteditable", disabled ? "false" : "true");
|
|
1174
|
+
editable.dataset.placeholder = "Compose an epic...";
|
|
1175
|
+
editable.innerHTML = initialHtml;
|
|
1176
|
+
const emitChange = () => onChange(editable.innerHTML);
|
|
1177
|
+
const toolbar = buildToolbar(editable, requestEmbedValue, emitChange);
|
|
1178
|
+
const inputHandler = () => emitChange();
|
|
1179
|
+
editable.addEventListener("input", inputHandler);
|
|
1180
|
+
wrapper.append(toolbar, editable);
|
|
1181
|
+
host.append(wrapper);
|
|
1182
|
+
return {
|
|
1183
|
+
destroy: () => {
|
|
1184
|
+
editable.removeEventListener("input", inputHandler);
|
|
1185
|
+
host.innerHTML = "";
|
|
1186
|
+
},
|
|
1187
|
+
getHtml: () => editable.innerHTML,
|
|
1188
|
+
setHtml: (html) => {
|
|
1189
|
+
editable.innerHTML = html;
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
};
|
|
1193
|
+
//#endregion
|
|
1194
|
+
//#region src/adapters/dom/createDomAdapter.ts
|
|
1195
|
+
const createDomAdapter = (editor) => {
|
|
1196
|
+
let target = null;
|
|
1197
|
+
let mountedEditor = null;
|
|
1198
|
+
return {
|
|
1199
|
+
mount: (nextTarget) => {
|
|
1200
|
+
target = nextTarget;
|
|
1201
|
+
editor.mount(nextTarget);
|
|
1202
|
+
if (!(nextTarget instanceof HTMLElement)) return;
|
|
1203
|
+
mountedEditor = mountRichTextEditor({
|
|
1204
|
+
host: nextTarget,
|
|
1205
|
+
initialHtml: editor.export("html").payload,
|
|
1206
|
+
requestEmbedValue: () => null,
|
|
1207
|
+
onChange: (html) => {
|
|
1208
|
+
editor.import({
|
|
1209
|
+
format: "html",
|
|
1210
|
+
payload: html
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
},
|
|
1215
|
+
unmount: () => {
|
|
1216
|
+
if (!target) return;
|
|
1217
|
+
mountedEditor?.destroy();
|
|
1218
|
+
mountedEditor = null;
|
|
1219
|
+
editor.unmount();
|
|
1220
|
+
target = null;
|
|
1221
|
+
},
|
|
1222
|
+
destroy: () => {
|
|
1223
|
+
mountedEditor?.destroy();
|
|
1224
|
+
mountedEditor = null;
|
|
1225
|
+
editor.destroy();
|
|
1226
|
+
target = null;
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
};
|
|
1230
|
+
//#endregion
|
|
1231
|
+
//#region src/adapters/react/useEditorAdapter.tsx
|
|
1232
|
+
const useEditorAdapter = (config) => {
|
|
1233
|
+
const editor = useMemo(() => createEditor(config), [config]);
|
|
1234
|
+
useEffect(() => () => {
|
|
1235
|
+
editor.destroy();
|
|
1236
|
+
}, [editor]);
|
|
1237
|
+
return editor;
|
|
1238
|
+
};
|
|
1239
|
+
function RichEditorAdapter({ className, disabled = false, initialContent, onChange, onError, onRequestEmbedValue }) {
|
|
1240
|
+
const editor = useEditorAdapter({
|
|
1241
|
+
initialContent,
|
|
1242
|
+
onChange,
|
|
1243
|
+
onError
|
|
1244
|
+
});
|
|
1245
|
+
const hostRef = useRef(null);
|
|
1246
|
+
useEffect(() => {
|
|
1247
|
+
const host = hostRef.current;
|
|
1248
|
+
if (!host) return;
|
|
1249
|
+
const mounted = mountRichTextEditor({
|
|
1250
|
+
host,
|
|
1251
|
+
initialHtml: editor.export("html").payload,
|
|
1252
|
+
disabled,
|
|
1253
|
+
requestEmbedValue: onRequestEmbedValue,
|
|
1254
|
+
onChange: (html) => {
|
|
1255
|
+
editor.import({
|
|
1256
|
+
format: "html",
|
|
1257
|
+
payload: html
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
return () => {
|
|
1262
|
+
mounted.destroy();
|
|
1263
|
+
};
|
|
1264
|
+
}, [
|
|
1265
|
+
disabled,
|
|
1266
|
+
editor,
|
|
1267
|
+
onRequestEmbedValue
|
|
1268
|
+
]);
|
|
1269
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1270
|
+
"aria-label": "Rich editor",
|
|
1271
|
+
className,
|
|
1272
|
+
ref: hostRef
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
//#endregion
|
|
1276
|
+
export { RichEditorAdapter, createDomAdapter, createEditor, createEditorSchema, defaultToolbarActions, editorMarkNames, editorNodeNames, editorSchema, exportRepresentation, importRepresentation, useEditorAdapter };
|
|
1277
|
+
|
|
1278
|
+
//# sourceMappingURL=index.mjs.map
|