@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.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +213 -22
  2. 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, KEY_ARROW_UP_COMMAND, KEY_ARROW_RIGHT_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';
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(KEY_ARROW_DOWN_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW);
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
- let nodesWereRemoved = false;
1864
- this.#selection.current.getNodes().forEach((node) => {
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
- node.remove();
2048
+ const children = parent.getChildren();
2049
+ const index = children.indexOf(node);
1868
2050
 
1869
- if (parent.getType() === "root" && parent.getChildrenSize() === 0) {
1870
- parent.append($createParagraphNode());
2051
+ if (index >= 0) {
2052
+ parent.splice(index, 1, []);
1871
2053
  }
1872
-
1873
- nodesWereRemoved = true;
1874
2054
  });
1875
2055
 
1876
- if (nodesWereRemoved) {
1877
- this.#selection.clear();
1878
- this.editor.focus();
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.#validateRequired();
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
- #validateRequired() {
2634
- if (this.hasAttribute("required") && this.#isEmpty) {
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.10-beta",
3
+ "version": "0.1.11-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",