@37signals/lexxy 0.1.10-beta → 0.1.12-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 +217 -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();
@@ -482,6 +493,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
482
493
  conversion: () => ({
483
494
  node: new ActionTextAttachmentNode({
484
495
  src: img.getAttribute("src"),
496
+ caption: img.getAttribute("alt") || "",
485
497
  contentType: "image/*",
486
498
  width: img.getAttribute("width"),
487
499
  height: img.getAttribute("height")
@@ -881,6 +893,8 @@ class CommandDispatcher {
881
893
 
882
894
  dispatchInsertUnorderedList() {
883
895
  const selection = $getSelection();
896
+ if (!selection) return;
897
+
884
898
  const anchorNode = selection.anchor.getNode();
885
899
 
886
900
  if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
@@ -892,6 +906,8 @@ class CommandDispatcher {
892
906
 
893
907
  dispatchInsertOrderedList() {
894
908
  const selection = $getSelection();
909
+ if (!selection) return;
910
+
895
911
  const anchorNode = selection.anchor.getNode();
896
912
 
897
913
  if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
@@ -1064,11 +1080,14 @@ function nextFrame() {
1064
1080
  class Selection {
1065
1081
  constructor(editorElement) {
1066
1082
  this.editorElement = editorElement;
1083
+ this.editorContentElement = editorElement.editorContentElement;
1067
1084
  this.editor = this.editorElement.editor;
1068
1085
  this.previouslySelectedKeys = new Set();
1069
1086
 
1070
1087
  this.#listenForNodeSelections();
1071
1088
  this.#processSelectionChangeCommands();
1089
+ this.#handleInputWhenDecoratorNodesSelected();
1090
+ this.#containEditorFocus();
1072
1091
  }
1073
1092
 
1074
1093
  clear() {
@@ -1162,6 +1181,21 @@ class Selection {
1162
1181
  return this.#findNextSiblingUp(anchorNode)
1163
1182
  }
1164
1183
 
1184
+ get topLevelNodeAfterCursor() {
1185
+ const { anchorNode, offset } = this.#getCollapsedSelectionData();
1186
+ if (!anchorNode) return null
1187
+
1188
+ if ($isTextNode(anchorNode)) {
1189
+ return this.#getNextNodeFromTextEnd(anchorNode)
1190
+ }
1191
+
1192
+ if ($isElementNode(anchorNode)) {
1193
+ return this.#getNodeAfterElementNode(anchorNode, offset)
1194
+ }
1195
+
1196
+ return this.#findNextSiblingUp(anchorNode)
1197
+ }
1198
+
1165
1199
  get nodeBeforeCursor() {
1166
1200
  const { anchorNode, offset } = this.#getCollapsedSelectionData();
1167
1201
  if (!anchorNode) return null
@@ -1177,6 +1211,21 @@ class Selection {
1177
1211
  return this.#findPreviousSiblingUp(anchorNode)
1178
1212
  }
1179
1213
 
1214
+ get topLevelNodeBeforeCursor() {
1215
+ const { anchorNode, offset } = this.#getCollapsedSelectionData();
1216
+ if (!anchorNode) return null
1217
+
1218
+ if ($isTextNode(anchorNode)) {
1219
+ return this.#getPreviousNodeFromTextStart(anchorNode)
1220
+ }
1221
+
1222
+ if ($isElementNode(anchorNode)) {
1223
+ return this.#getNodeBeforeElementNode(anchorNode, offset)
1224
+ }
1225
+
1226
+ return this.#findPreviousSiblingUp(anchorNode)
1227
+ }
1228
+
1180
1229
  get #contents() {
1181
1230
  return this.editorElement.contents
1182
1231
  }
@@ -1197,9 +1246,9 @@ class Selection {
1197
1246
 
1198
1247
  #processSelectionChangeCommands() {
1199
1248
  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
1249
  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);
1250
+ this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
1251
+ this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
1203
1252
 
1204
1253
  this.editor.registerCommand(KEY_DELETE_COMMAND, this.#deleteSelectedOrNext.bind(this), COMMAND_PRIORITY_LOW);
1205
1254
  this.editor.registerCommand(KEY_BACKSPACE_COMMAND, this.#deletePreviousOrNext.bind(this), COMMAND_PRIORITY_LOW);
@@ -1230,6 +1279,93 @@ class Selection {
1230
1279
  });
1231
1280
  }
1232
1281
 
1282
+ // In Safari, when the only node in the document is an attachment, it won't let you enter text
1283
+ // before/below it. There is probably a better fix here, but this workaround solves the problem until
1284
+ // we find it.
1285
+ #handleInputWhenDecoratorNodesSelected() {
1286
+ this.editor.getRootElement().addEventListener("keydown", (event) => {
1287
+ if (isPrintableCharacter(event)) {
1288
+ this.editor.update(() => {
1289
+ const selection = $getSelection();
1290
+
1291
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
1292
+ const anchorNode = selection.anchor.getNode();
1293
+ const offset = selection.anchor.offset;
1294
+
1295
+ const nodeBefore = this.#getNodeBeforePosition(anchorNode, offset);
1296
+ const nodeAfter = this.#getNodeAfterPosition(anchorNode, offset);
1297
+
1298
+ if (nodeBefore instanceof DecoratorNode && !nodeBefore.isInline()) {
1299
+ event.preventDefault();
1300
+ this.#contents.createParagraphAfterNode(nodeBefore, event.key);
1301
+ return
1302
+ } else if (nodeAfter instanceof DecoratorNode && !nodeAfter.isInline()) {
1303
+ event.preventDefault();
1304
+ this.#contents.createParagraphBeforeNode(nodeAfter, event.key);
1305
+ return
1306
+ }
1307
+ }
1308
+ });
1309
+ }
1310
+ }, true);
1311
+ }
1312
+
1313
+ #getNodeBeforePosition(node, offset) {
1314
+ if ($isTextNode(node) && offset === 0) {
1315
+ return node.getPreviousSibling()
1316
+ }
1317
+ if ($isElementNode(node) && offset > 0) {
1318
+ return node.getChildAtIndex(offset - 1)
1319
+ }
1320
+ return null
1321
+ }
1322
+
1323
+ #getNodeAfterPosition(node, offset) {
1324
+ if ($isTextNode(node) && offset === node.getTextContentSize()) {
1325
+ return node.getNextSibling()
1326
+ }
1327
+ if ($isElementNode(node)) {
1328
+ return node.getChildAtIndex(offset)
1329
+ }
1330
+ return null
1331
+ }
1332
+
1333
+ #containEditorFocus() {
1334
+ // Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements
1335
+ // above when navigating UP/DOWN when Lexical shows its fake cursor on custom decorator nodes.
1336
+ this.editorContentElement.addEventListener("keydown", (event) => {
1337
+ if (event.key === "ArrowUp") {
1338
+ const lexicalCursor = this.editor.getRootElement().querySelector('[data-lexical-cursor]');
1339
+
1340
+ if (lexicalCursor) {
1341
+ let currentElement = lexicalCursor.previousElementSibling;
1342
+ while (currentElement && currentElement.hasAttribute('data-lexical-cursor')) {
1343
+ currentElement = currentElement.previousElementSibling;
1344
+ }
1345
+
1346
+ if (!currentElement) {
1347
+ event.preventDefault();
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ if (event.key === "ArrowDown") {
1353
+ const lexicalCursor = this.editor.getRootElement().querySelector('[data-lexical-cursor]');
1354
+
1355
+ if (lexicalCursor) {
1356
+ let currentElement = lexicalCursor.nextElementSibling;
1357
+ while (currentElement && currentElement.hasAttribute('data-lexical-cursor')) {
1358
+ currentElement = currentElement.nextElementSibling;
1359
+ }
1360
+
1361
+ if (!currentElement) {
1362
+ event.preventDefault();
1363
+ }
1364
+ }
1365
+ }
1366
+ }, true);
1367
+ }
1368
+
1233
1369
  #syncSelectedClasses() {
1234
1370
  this.#clearPreviouslyHighlightedItems();
1235
1371
  this.#highlightNewItems();
@@ -1272,6 +1408,22 @@ class Selection {
1272
1408
  }
1273
1409
  }
1274
1410
 
1411
+ async #selectPreviousTopLevelNode() {
1412
+ if (this.current) {
1413
+ await this.#withCurrentNode((currentNode) => currentNode.selectPrevious());
1414
+ } else {
1415
+ this.#selectInLexical(this.topLevelNodeBeforeCursor);
1416
+ }
1417
+ }
1418
+
1419
+ async #selectNextTopLevelNode() {
1420
+ if (this.current) {
1421
+ await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0));
1422
+ } else {
1423
+ this.#selectInLexical(this.topLevelNodeAfterCursor);
1424
+ }
1425
+ }
1426
+
1275
1427
  async #withCurrentNode(fn) {
1276
1428
  await nextFrame();
1277
1429
  if (this.current) {
@@ -1826,6 +1978,30 @@ class Contents {
1826
1978
  });
1827
1979
  }
1828
1980
 
1981
+ createParagraphAfterNode(node, text) {
1982
+ const newParagraph = $createParagraphNode();
1983
+ node.insertAfter(newParagraph);
1984
+ newParagraph.selectStart();
1985
+
1986
+ // Insert the typed text
1987
+ if (text) {
1988
+ newParagraph.append($createTextNode(text));
1989
+ newParagraph.select(1, 1); // Place cursor after the text
1990
+ }
1991
+ }
1992
+
1993
+ createParagraphBeforeNode(node, text) {
1994
+ const newParagraph = $createParagraphNode();
1995
+ node.insertBefore(newParagraph);
1996
+ newParagraph.selectStart();
1997
+
1998
+ // Insert the typed text
1999
+ if (text) {
2000
+ newParagraph.append($createTextNode(text));
2001
+ newParagraph.select(1, 1); // Place cursor after the text
2002
+ }
2003
+ }
2004
+
1829
2005
  uploadFile(file) {
1830
2006
  if (!this.editorElement.supportsAttachments) {
1831
2007
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
@@ -1860,25 +2036,34 @@ class Contents {
1860
2036
  deleteSelectedNodes() {
1861
2037
  this.editor.update(() => {
1862
2038
  if ($isNodeSelection(this.#selection.current)) {
1863
- let nodesWereRemoved = false;
1864
- this.#selection.current.getNodes().forEach((node) => {
2039
+ const nodesToRemove = this.#selection.current.getNodes();
2040
+ if (nodesToRemove.length === 0) return
2041
+
2042
+ // Use splice() instead of node.remove() for proper removal and
2043
+ // reconciliation. Would have issues with removing unintended decorator nodes
2044
+ // with node.remove()
2045
+ nodesToRemove.forEach((node) => {
1865
2046
  const parent = node.getParent();
2047
+ if (!$isElementNode(parent)) return
1866
2048
 
1867
- node.remove();
2049
+ const children = parent.getChildren();
2050
+ const index = children.indexOf(node);
1868
2051
 
1869
- if (parent.getType() === "root" && parent.getChildrenSize() === 0) {
1870
- parent.append($createParagraphNode());
2052
+ if (index >= 0) {
2053
+ parent.splice(index, 1, []);
1871
2054
  }
1872
-
1873
- nodesWereRemoved = true;
1874
2055
  });
1875
2056
 
1876
- if (nodesWereRemoved) {
1877
- this.#selection.clear();
1878
- this.editor.focus();
1879
-
1880
- return true
2057
+ // Check if root is empty after all removals
2058
+ const root = $getRoot();
2059
+ if (root.getChildrenSize() === 0) {
2060
+ root.append($createParagraphNode());
1881
2061
  }
2062
+
2063
+ this.#selection.clear();
2064
+ this.editor.focus();
2065
+
2066
+ return true
1882
2067
  }
1883
2068
  });
1884
2069
  }
@@ -2265,6 +2450,9 @@ class Clipboard {
2265
2450
  #handlePastedFiles(clipboardData) {
2266
2451
  if (!this.editorElement.supportsAttachments) return
2267
2452
 
2453
+ const html = clipboardData.getData('text/html');
2454
+ if (html) return // Ignore if image copied from browser since we will load it as a remote image
2455
+
2268
2456
  this.#preservingScrollPosition(() => {
2269
2457
  for (const item of clipboardData.items) {
2270
2458
  const file = item.getAsFile();
@@ -2294,9 +2482,10 @@ class LexicalEditorElement extends HTMLElement {
2294
2482
  static debug = true
2295
2483
  static commands = [ "bold", "italic", "" ]
2296
2484
 
2297
- static observedAttributes = [ "connected" ]
2485
+ static observedAttributes = [ "connected", "required" ]
2298
2486
 
2299
2487
  #initialValue = ""
2488
+ #validationTextArea = document.createElement("textarea")
2300
2489
 
2301
2490
  constructor() {
2302
2491
  super();
@@ -2329,6 +2518,11 @@ class LexicalEditorElement extends HTMLElement {
2329
2518
  if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) {
2330
2519
  requestAnimationFrame(() => this.#reconnect());
2331
2520
  }
2521
+
2522
+ if (name === "required" && this.isConnected) {
2523
+ this.#validationTextArea.required = this.hasAttribute("required");
2524
+ this.#setValidity();
2525
+ }
2332
2526
  }
2333
2527
 
2334
2528
  formResetCallback() {
@@ -2382,7 +2576,7 @@ class LexicalEditorElement extends HTMLElement {
2382
2576
  $addUpdateTag(SKIP_DOM_SELECTION_TAG);
2383
2577
  const root = $getRoot();
2384
2578
  root.clear();
2385
- root.append(...this.#parseHtmlIntoLexicalNodes(html));
2579
+ if (html !== "") root.append(...this.#parseHtmlIntoLexicalNodes(html));
2386
2580
  root.select();
2387
2581
 
2388
2582
  this.#toggleEmptyStatus();
@@ -2495,6 +2689,7 @@ class LexicalEditorElement extends HTMLElement {
2495
2689
 
2496
2690
  this.internals.setFormValue(html);
2497
2691
  this._internalFormValue = html;
2692
+ this.#validationTextArea.value = this.#isEmpty ? "" : html;
2498
2693
 
2499
2694
  if (changed) {
2500
2695
  dispatch(this, "lexxy:change");
@@ -2523,7 +2718,7 @@ class LexicalEditorElement extends HTMLElement {
2523
2718
  this.cachedValue = null;
2524
2719
  this.#internalFormValue = this.value;
2525
2720
  this.#toggleEmptyStatus();
2526
- this.#validateRequired();
2721
+ this.#setValidity();
2527
2722
  }));
2528
2723
  }
2529
2724
 
@@ -2630,11 +2825,11 @@ class LexicalEditorElement extends HTMLElement {
2630
2825
  return !this.editorContentElement.textContent.trim() && !containsVisuallyRelevantChildren(this.editorContentElement)
2631
2826
  }
2632
2827
 
2633
- #validateRequired() {
2634
- if (this.hasAttribute("required") && this.#isEmpty) {
2635
- this.internals.setValidity({ valueMissing: true }, "Please fill out this field.", this.editorContentElement);
2636
- } else {
2828
+ #setValidity() {
2829
+ if (this.#validationTextArea.validity.valid) {
2637
2830
  this.internals.setValidity({});
2831
+ } else {
2832
+ this.internals.setValidity(this.#validationTextArea.validity, this.#validationTextArea.validationMessage, this.editorContentElement);
2638
2833
  }
2639
2834
  }
2640
2835
 
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.12-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",