@37signals/lexxy 0.1.10-beta → 0.1.11-beta
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/dist/lexxy.esm.js +213 -22
- package/package.json +1 -1
package/dist/lexxy.esm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import DOMPurify from 'dompurify';
|
|
2
|
-
import { $getSelection, $isRangeSelection, $isTextNode, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND,
|
|
2
|
+
import { $getSelection, $isRangeSelection, $isTextNode, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_DELETE_COMMAND, KEY_BACKSPACE_COMMAND, SELECTION_CHANGE_COMMAND, $createNodeSelection, $setSelection, $createParagraphNode, $createTextNode, $isParagraphNode, $insertNodes, $createLineBreakNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, SKIP_DOM_SELECTION_TAG, createEditor, KEY_ENTER_COMMAND, COMMAND_PRIORITY_NORMAL, COMMAND_PRIORITY_HIGH, KEY_TAB_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
|
|
3
3
|
import { $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListNode, ListItemNode, registerList } from '@lexical/list';
|
|
4
4
|
import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
|
|
5
5
|
import { $isCodeNode, $isCodeHighlightNode, CodeNode, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP, normalizeCodeLang } from '@lexical/code';
|
|
@@ -42,6 +42,17 @@ function getListType(node) {
|
|
|
42
42
|
return null
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function isPrintableCharacter(event) {
|
|
46
|
+
// Ignore if modifier keys are pressed (except Shift for uppercase)
|
|
47
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return false
|
|
48
|
+
|
|
49
|
+
// Ignore special keys
|
|
50
|
+
if (event.key.length > 1 && event.key !== 'Enter' && event.key !== 'Space') return false
|
|
51
|
+
|
|
52
|
+
// Accept single character keys (letters, numbers, punctuation)
|
|
53
|
+
return event.key.length === 1
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
class LexicalToolbarElement extends HTMLElement {
|
|
46
57
|
constructor() {
|
|
47
58
|
super();
|
|
@@ -881,6 +892,8 @@ class CommandDispatcher {
|
|
|
881
892
|
|
|
882
893
|
dispatchInsertUnorderedList() {
|
|
883
894
|
const selection = $getSelection();
|
|
895
|
+
if (!selection) return;
|
|
896
|
+
|
|
884
897
|
const anchorNode = selection.anchor.getNode();
|
|
885
898
|
|
|
886
899
|
if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
|
|
@@ -892,6 +905,8 @@ class CommandDispatcher {
|
|
|
892
905
|
|
|
893
906
|
dispatchInsertOrderedList() {
|
|
894
907
|
const selection = $getSelection();
|
|
908
|
+
if (!selection) return;
|
|
909
|
+
|
|
895
910
|
const anchorNode = selection.anchor.getNode();
|
|
896
911
|
|
|
897
912
|
if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
|
|
@@ -1064,11 +1079,14 @@ function nextFrame() {
|
|
|
1064
1079
|
class Selection {
|
|
1065
1080
|
constructor(editorElement) {
|
|
1066
1081
|
this.editorElement = editorElement;
|
|
1082
|
+
this.editorContentElement = editorElement.editorContentElement;
|
|
1067
1083
|
this.editor = this.editorElement.editor;
|
|
1068
1084
|
this.previouslySelectedKeys = new Set();
|
|
1069
1085
|
|
|
1070
1086
|
this.#listenForNodeSelections();
|
|
1071
1087
|
this.#processSelectionChangeCommands();
|
|
1088
|
+
this.#handleInputWhenDecoratorNodesSelected();
|
|
1089
|
+
this.#containEditorFocus();
|
|
1072
1090
|
}
|
|
1073
1091
|
|
|
1074
1092
|
clear() {
|
|
@@ -1162,6 +1180,21 @@ class Selection {
|
|
|
1162
1180
|
return this.#findNextSiblingUp(anchorNode)
|
|
1163
1181
|
}
|
|
1164
1182
|
|
|
1183
|
+
get topLevelNodeAfterCursor() {
|
|
1184
|
+
const { anchorNode, offset } = this.#getCollapsedSelectionData();
|
|
1185
|
+
if (!anchorNode) return null
|
|
1186
|
+
|
|
1187
|
+
if ($isTextNode(anchorNode)) {
|
|
1188
|
+
return this.#getNextNodeFromTextEnd(anchorNode)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if ($isElementNode(anchorNode)) {
|
|
1192
|
+
return this.#getNodeAfterElementNode(anchorNode, offset)
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
return this.#findNextSiblingUp(anchorNode)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1165
1198
|
get nodeBeforeCursor() {
|
|
1166
1199
|
const { anchorNode, offset } = this.#getCollapsedSelectionData();
|
|
1167
1200
|
if (!anchorNode) return null
|
|
@@ -1177,6 +1210,21 @@ class Selection {
|
|
|
1177
1210
|
return this.#findPreviousSiblingUp(anchorNode)
|
|
1178
1211
|
}
|
|
1179
1212
|
|
|
1213
|
+
get topLevelNodeBeforeCursor() {
|
|
1214
|
+
const { anchorNode, offset } = this.#getCollapsedSelectionData();
|
|
1215
|
+
if (!anchorNode) return null
|
|
1216
|
+
|
|
1217
|
+
if ($isTextNode(anchorNode)) {
|
|
1218
|
+
return this.#getPreviousNodeFromTextStart(anchorNode)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if ($isElementNode(anchorNode)) {
|
|
1222
|
+
return this.#getNodeBeforeElementNode(anchorNode, offset)
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
return this.#findPreviousSiblingUp(anchorNode)
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1180
1228
|
get #contents() {
|
|
1181
1229
|
return this.editorElement.contents
|
|
1182
1230
|
}
|
|
@@ -1197,9 +1245,9 @@ class Selection {
|
|
|
1197
1245
|
|
|
1198
1246
|
#processSelectionChangeCommands() {
|
|
1199
1247
|
this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1200
|
-
this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1201
1248
|
this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1202
|
-
this.editor.registerCommand(
|
|
1249
|
+
this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1250
|
+
this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1203
1251
|
|
|
1204
1252
|
this.editor.registerCommand(KEY_DELETE_COMMAND, this.#deleteSelectedOrNext.bind(this), COMMAND_PRIORITY_LOW);
|
|
1205
1253
|
this.editor.registerCommand(KEY_BACKSPACE_COMMAND, this.#deletePreviousOrNext.bind(this), COMMAND_PRIORITY_LOW);
|
|
@@ -1230,6 +1278,93 @@ class Selection {
|
|
|
1230
1278
|
});
|
|
1231
1279
|
}
|
|
1232
1280
|
|
|
1281
|
+
// In Safari, when the only node in the document is an attachment, it won't let you enter text
|
|
1282
|
+
// before/below it. There is probably a better fix here, but this workaround solves the problem until
|
|
1283
|
+
// we find it.
|
|
1284
|
+
#handleInputWhenDecoratorNodesSelected() {
|
|
1285
|
+
this.editor.getRootElement().addEventListener("keydown", (event) => {
|
|
1286
|
+
if (isPrintableCharacter(event)) {
|
|
1287
|
+
this.editor.update(() => {
|
|
1288
|
+
const selection = $getSelection();
|
|
1289
|
+
|
|
1290
|
+
if ($isRangeSelection(selection) && selection.isCollapsed()) {
|
|
1291
|
+
const anchorNode = selection.anchor.getNode();
|
|
1292
|
+
const offset = selection.anchor.offset;
|
|
1293
|
+
|
|
1294
|
+
const nodeBefore = this.#getNodeBeforePosition(anchorNode, offset);
|
|
1295
|
+
const nodeAfter = this.#getNodeAfterPosition(anchorNode, offset);
|
|
1296
|
+
|
|
1297
|
+
if (nodeBefore instanceof DecoratorNode && !nodeBefore.isInline()) {
|
|
1298
|
+
event.preventDefault();
|
|
1299
|
+
this.#contents.createParagraphAfterNode(nodeBefore, event.key);
|
|
1300
|
+
return
|
|
1301
|
+
} else if (nodeAfter instanceof DecoratorNode && !nodeAfter.isInline()) {
|
|
1302
|
+
event.preventDefault();
|
|
1303
|
+
this.#contents.createParagraphBeforeNode(nodeAfter, event.key);
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
}, true);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
#getNodeBeforePosition(node, offset) {
|
|
1313
|
+
if ($isTextNode(node) && offset === 0) {
|
|
1314
|
+
return node.getPreviousSibling()
|
|
1315
|
+
}
|
|
1316
|
+
if ($isElementNode(node) && offset > 0) {
|
|
1317
|
+
return node.getChildAtIndex(offset - 1)
|
|
1318
|
+
}
|
|
1319
|
+
return null
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
#getNodeAfterPosition(node, offset) {
|
|
1323
|
+
if ($isTextNode(node) && offset === node.getTextContentSize()) {
|
|
1324
|
+
return node.getNextSibling()
|
|
1325
|
+
}
|
|
1326
|
+
if ($isElementNode(node)) {
|
|
1327
|
+
return node.getChildAtIndex(offset)
|
|
1328
|
+
}
|
|
1329
|
+
return null
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
#containEditorFocus() {
|
|
1333
|
+
// Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements
|
|
1334
|
+
// above when navigating UP/DOWN when Lexical shows its fake cursor on custom decorator nodes.
|
|
1335
|
+
this.editorContentElement.addEventListener("keydown", (event) => {
|
|
1336
|
+
if (event.key === "ArrowUp") {
|
|
1337
|
+
const lexicalCursor = this.editor.getRootElement().querySelector('[data-lexical-cursor]');
|
|
1338
|
+
|
|
1339
|
+
if (lexicalCursor) {
|
|
1340
|
+
let currentElement = lexicalCursor.previousElementSibling;
|
|
1341
|
+
while (currentElement && currentElement.hasAttribute('data-lexical-cursor')) {
|
|
1342
|
+
currentElement = currentElement.previousElementSibling;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (!currentElement) {
|
|
1346
|
+
event.preventDefault();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (event.key === "ArrowDown") {
|
|
1352
|
+
const lexicalCursor = this.editor.getRootElement().querySelector('[data-lexical-cursor]');
|
|
1353
|
+
|
|
1354
|
+
if (lexicalCursor) {
|
|
1355
|
+
let currentElement = lexicalCursor.nextElementSibling;
|
|
1356
|
+
while (currentElement && currentElement.hasAttribute('data-lexical-cursor')) {
|
|
1357
|
+
currentElement = currentElement.nextElementSibling;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
if (!currentElement) {
|
|
1361
|
+
event.preventDefault();
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}, true);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1233
1368
|
#syncSelectedClasses() {
|
|
1234
1369
|
this.#clearPreviouslyHighlightedItems();
|
|
1235
1370
|
this.#highlightNewItems();
|
|
@@ -1272,6 +1407,22 @@ class Selection {
|
|
|
1272
1407
|
}
|
|
1273
1408
|
}
|
|
1274
1409
|
|
|
1410
|
+
async #selectPreviousTopLevelNode() {
|
|
1411
|
+
if (this.current) {
|
|
1412
|
+
await this.#withCurrentNode((currentNode) => currentNode.selectPrevious());
|
|
1413
|
+
} else {
|
|
1414
|
+
this.#selectInLexical(this.topLevelNodeBeforeCursor);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async #selectNextTopLevelNode() {
|
|
1419
|
+
if (this.current) {
|
|
1420
|
+
await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0));
|
|
1421
|
+
} else {
|
|
1422
|
+
this.#selectInLexical(this.topLevelNodeAfterCursor);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1275
1426
|
async #withCurrentNode(fn) {
|
|
1276
1427
|
await nextFrame();
|
|
1277
1428
|
if (this.current) {
|
|
@@ -1826,6 +1977,30 @@ class Contents {
|
|
|
1826
1977
|
});
|
|
1827
1978
|
}
|
|
1828
1979
|
|
|
1980
|
+
createParagraphAfterNode(node, text) {
|
|
1981
|
+
const newParagraph = $createParagraphNode();
|
|
1982
|
+
node.insertAfter(newParagraph);
|
|
1983
|
+
newParagraph.selectStart();
|
|
1984
|
+
|
|
1985
|
+
// Insert the typed text
|
|
1986
|
+
if (text) {
|
|
1987
|
+
newParagraph.append($createTextNode(text));
|
|
1988
|
+
newParagraph.select(1, 1); // Place cursor after the text
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
createParagraphBeforeNode(node, text) {
|
|
1993
|
+
const newParagraph = $createParagraphNode();
|
|
1994
|
+
node.insertBefore(newParagraph);
|
|
1995
|
+
newParagraph.selectStart();
|
|
1996
|
+
|
|
1997
|
+
// Insert the typed text
|
|
1998
|
+
if (text) {
|
|
1999
|
+
newParagraph.append($createTextNode(text));
|
|
2000
|
+
newParagraph.select(1, 1); // Place cursor after the text
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1829
2004
|
uploadFile(file) {
|
|
1830
2005
|
if (!this.editorElement.supportsAttachments) {
|
|
1831
2006
|
console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
|
|
@@ -1860,25 +2035,34 @@ class Contents {
|
|
|
1860
2035
|
deleteSelectedNodes() {
|
|
1861
2036
|
this.editor.update(() => {
|
|
1862
2037
|
if ($isNodeSelection(this.#selection.current)) {
|
|
1863
|
-
|
|
1864
|
-
|
|
2038
|
+
const nodesToRemove = this.#selection.current.getNodes();
|
|
2039
|
+
if (nodesToRemove.length === 0) return
|
|
2040
|
+
|
|
2041
|
+
// Use splice() instead of node.remove() for proper removal and
|
|
2042
|
+
// reconciliation. Would have issues with removing unintended decorator nodes
|
|
2043
|
+
// with node.remove()
|
|
2044
|
+
nodesToRemove.forEach((node) => {
|
|
1865
2045
|
const parent = node.getParent();
|
|
2046
|
+
if (!$isElementNode(parent)) return
|
|
1866
2047
|
|
|
1867
|
-
|
|
2048
|
+
const children = parent.getChildren();
|
|
2049
|
+
const index = children.indexOf(node);
|
|
1868
2050
|
|
|
1869
|
-
if (
|
|
1870
|
-
parent.
|
|
2051
|
+
if (index >= 0) {
|
|
2052
|
+
parent.splice(index, 1, []);
|
|
1871
2053
|
}
|
|
1872
|
-
|
|
1873
|
-
nodesWereRemoved = true;
|
|
1874
2054
|
});
|
|
1875
2055
|
|
|
1876
|
-
if
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
return true
|
|
2056
|
+
// Check if root is empty after all removals
|
|
2057
|
+
const root = $getRoot();
|
|
2058
|
+
if (root.getChildrenSize() === 0) {
|
|
2059
|
+
root.append($createParagraphNode());
|
|
1881
2060
|
}
|
|
2061
|
+
|
|
2062
|
+
this.#selection.clear();
|
|
2063
|
+
this.editor.focus();
|
|
2064
|
+
|
|
2065
|
+
return true
|
|
1882
2066
|
}
|
|
1883
2067
|
});
|
|
1884
2068
|
}
|
|
@@ -2294,9 +2478,10 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2294
2478
|
static debug = true
|
|
2295
2479
|
static commands = [ "bold", "italic", "" ]
|
|
2296
2480
|
|
|
2297
|
-
static observedAttributes = [ "connected" ]
|
|
2481
|
+
static observedAttributes = [ "connected", "required" ]
|
|
2298
2482
|
|
|
2299
2483
|
#initialValue = ""
|
|
2484
|
+
#validationTextArea = document.createElement("textarea")
|
|
2300
2485
|
|
|
2301
2486
|
constructor() {
|
|
2302
2487
|
super();
|
|
@@ -2329,6 +2514,11 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2329
2514
|
if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) {
|
|
2330
2515
|
requestAnimationFrame(() => this.#reconnect());
|
|
2331
2516
|
}
|
|
2517
|
+
|
|
2518
|
+
if (name === "required" && this.isConnected) {
|
|
2519
|
+
this.#validationTextArea.required = this.hasAttribute("required");
|
|
2520
|
+
this.#setValidity();
|
|
2521
|
+
}
|
|
2332
2522
|
}
|
|
2333
2523
|
|
|
2334
2524
|
formResetCallback() {
|
|
@@ -2382,7 +2572,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2382
2572
|
$addUpdateTag(SKIP_DOM_SELECTION_TAG);
|
|
2383
2573
|
const root = $getRoot();
|
|
2384
2574
|
root.clear();
|
|
2385
|
-
root.append(...this.#parseHtmlIntoLexicalNodes(html));
|
|
2575
|
+
if (html !== "") root.append(...this.#parseHtmlIntoLexicalNodes(html));
|
|
2386
2576
|
root.select();
|
|
2387
2577
|
|
|
2388
2578
|
this.#toggleEmptyStatus();
|
|
@@ -2495,6 +2685,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2495
2685
|
|
|
2496
2686
|
this.internals.setFormValue(html);
|
|
2497
2687
|
this._internalFormValue = html;
|
|
2688
|
+
this.#validationTextArea.value = this.#isEmpty ? "" : html;
|
|
2498
2689
|
|
|
2499
2690
|
if (changed) {
|
|
2500
2691
|
dispatch(this, "lexxy:change");
|
|
@@ -2523,7 +2714,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2523
2714
|
this.cachedValue = null;
|
|
2524
2715
|
this.#internalFormValue = this.value;
|
|
2525
2716
|
this.#toggleEmptyStatus();
|
|
2526
|
-
this.#
|
|
2717
|
+
this.#setValidity();
|
|
2527
2718
|
}));
|
|
2528
2719
|
}
|
|
2529
2720
|
|
|
@@ -2630,11 +2821,11 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2630
2821
|
return !this.editorContentElement.textContent.trim() && !containsVisuallyRelevantChildren(this.editorContentElement)
|
|
2631
2822
|
}
|
|
2632
2823
|
|
|
2633
|
-
#
|
|
2634
|
-
if (this.
|
|
2635
|
-
this.internals.setValidity({ valueMissing: true }, "Please fill out this field.", this.editorContentElement);
|
|
2636
|
-
} else {
|
|
2824
|
+
#setValidity() {
|
|
2825
|
+
if (this.#validationTextArea.validity.valid) {
|
|
2637
2826
|
this.internals.setValidity({});
|
|
2827
|
+
} else {
|
|
2828
|
+
this.internals.setValidity(this.#validationTextArea.validity, this.#validationTextArea.validationMessage, this.editorContentElement);
|
|
2638
2829
|
}
|
|
2639
2830
|
}
|
|
2640
2831
|
|