@37signals/lexxy 0.1.9-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 (3) hide show
  1. package/README.md +78 -3
  2. package/dist/lexxy.esm.js +405 -116
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -341,9 +341,84 @@ The sandbox app is available at http://localhost:3000. There is also a CRUD exam
341
341
 
342
342
  ## Events
343
343
 
344
- * `lexxy:initialize`: Fired whenever the `<lexxy-editor>` element is attached to the DOM and is ready for use.
345
- * `lexxy:change`: Fired whenever the editor content changes.
346
- * `lexxy:file-accept`: Fired whenever a file is dropped or inserted into the editor. You can access the `File` object through the `event.detail.file` property. Call `preventDefault` on the event to cancel upload and prevent attaching the file.
344
+ Lexxy fires a handful of custom events that you can hook into.
345
+ Each event is dispatched on the `<lexxy-editor>` element.
346
+
347
+ ### `lexxy:initialize`
348
+
349
+ Fired when the `<lexxy-editor>` element is attached to the DOM and ready for use.
350
+ This is useful for one-time setup.
351
+
352
+ ### `lexxy:change`
353
+
354
+ Fired whenever the editor content changes.
355
+ You can use this to sync the editor state with your application.
356
+
357
+ ### `lexxy:file-accept`
358
+
359
+ Fired when a file is dropped or inserted into the editor.
360
+
361
+ - Access the file via `event.detail.file`.
362
+ - Call `event.preventDefault()` to cancel the upload and prevent attaching the file.
363
+
364
+ ### `lexxy:insert-link`
365
+
366
+ Fired when a plain text link is pasted into the editor.
367
+ Access the link’s URL via `event.detail.url`.
368
+
369
+ You also get a handful of callback helpers on `event.detail`:
370
+
371
+ - **`replaceLinkWith(html, options)`** – replace the pasted link with your own HTML.
372
+ - **`insertBelowLink(html, options)`** – insert custom HTML below the link.
373
+ - **Attachment rendering** – pass `{ attachment: true }` in `options` to render as non-editable content,
374
+ or `{ attachment: { sgid: "your-sgid-here" } }` to provide a custom SGID.
375
+
376
+ #### Example: Link Unfurling with Stimulus
377
+
378
+ When a user pastes a link, you may want to turn it into a preview or embed.
379
+ Here’s a Stimulus controller that sends the URL to your app, retrieves metadata,
380
+ and replaces the plain text link with a richer version:
381
+
382
+ ```javascript
383
+ // app/javascript/controllers/link_unfurl_controller.js
384
+ import { Controller } from "@hotwired/stimulus"
385
+ import { post } from "@rails/request.js"
386
+
387
+ export default class extends Controller {
388
+ static values = {
389
+ url: String, // endpoint that handles unfurling
390
+ }
391
+
392
+ unfurl(event) {
393
+ this.#unfurlLink(event.detail.url, event.detail)
394
+ }
395
+
396
+ async #unfurlLink(url, callbacks) {
397
+ const { response } = await post(this.urlValue, {
398
+ body: JSON.stringify({ url }),
399
+ headers: {
400
+ "Content-Type": "application/json",
401
+ "Accept": "application/json"
402
+ }
403
+ })
404
+
405
+ const metadata = await response.json()
406
+ this.#insertUnfurledLink(metadata, callbacks)
407
+ }
408
+
409
+ #insertUnfurledLink(metadata, callbacks) {
410
+ // Replace the pasted link with your custom HTML
411
+ callbacks.replaceLinkWith(this.#renderUnfurledLinkHTML(metadata))
412
+
413
+ // Or, insert below the link as an attachment:
414
+ // callbacks.insertBelowLink(this.#renderUnfurledLinkHTML(metadata), { attachment: true })
415
+ }
416
+
417
+ #renderUnfurledLinkHTML(link) {
418
+ return `<a href="${link.canonical_url}">${link.title}</a>`
419
+ }
420
+ }
421
+ ```
347
422
 
348
423
  ## Contributing
349
424
 
package/dist/lexxy.esm.js CHANGED
@@ -1,9 +1,9 @@
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, $isParagraphNode, $insertNodes, $createTextNode, $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';
6
- import { $isLinkNode, $toggleLink, LinkNode, AutoLinkNode } from '@lexical/link';
6
+ import { $isLinkNode, $toggleLink, $createLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
7
7
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
8
8
  import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
9
9
  import { registerHistory, createEmptyHistoryState } from '@lexical/history';
@@ -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) {
@@ -1520,6 +1671,105 @@ class Selection {
1520
1671
  }
1521
1672
  }
1522
1673
 
1674
+ class CustomActionTextAttachmentNode extends DecoratorNode {
1675
+ static getType() {
1676
+ return "custom_action_text_attachment"
1677
+ }
1678
+
1679
+ static clone(node) {
1680
+ return new CustomActionTextAttachmentNode({ ...node }, node.__key)
1681
+ }
1682
+
1683
+ static importJSON(serializedNode) {
1684
+ return new CustomActionTextAttachmentNode({ ...serializedNode })
1685
+ }
1686
+
1687
+ static importDOM() {
1688
+ return {
1689
+ "action-text-attachment": (attachment) => {
1690
+ const content = attachment.getAttribute("content");
1691
+ if (!attachment.getAttribute("content")) {
1692
+ return null
1693
+ }
1694
+
1695
+ return {
1696
+ conversion: () => {
1697
+ // Preserve initial space if present since Lexical removes it
1698
+ const nodes = [];
1699
+ const previousSibling = attachment.previousSibling;
1700
+ if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
1701
+ nodes.push($createTextNode(" "));
1702
+ }
1703
+
1704
+ nodes.push(new CustomActionTextAttachmentNode({
1705
+ sgid: attachment.getAttribute("sgid"),
1706
+ innerHtml: JSON.parse(content),
1707
+ contentType: attachment.getAttribute("content-type")
1708
+ }));
1709
+
1710
+ nodes.push($createTextNode(" "));
1711
+
1712
+ return { node: nodes }
1713
+ },
1714
+ priority: 2
1715
+ }
1716
+ }
1717
+ }
1718
+ }
1719
+
1720
+ constructor({ sgid, contentType, innerHtml }, key) {
1721
+ super(key);
1722
+
1723
+ this.sgid = sgid;
1724
+ this.contentType = contentType || "application/vnd.actiontext.unknown";
1725
+ this.innerHtml = innerHtml;
1726
+ }
1727
+
1728
+ createDOM() {
1729
+ const figure = createElement("action-text-attachment", { "content-type": this.contentType, "data-lexxy-decorator": true });
1730
+
1731
+ figure.addEventListener("click", (event) => {
1732
+ dispatchCustomEvent(figure, "lexxy:internal:select-node", { key: this.getKey() });
1733
+ });
1734
+
1735
+ figure.insertAdjacentHTML("beforeend", this.innerHtml);
1736
+
1737
+ return figure
1738
+ }
1739
+
1740
+ updateDOM() {
1741
+ return true
1742
+ }
1743
+
1744
+ isInline() {
1745
+ return true
1746
+ }
1747
+
1748
+ exportDOM() {
1749
+ const attachment = createElement("action-text-attachment", {
1750
+ sgid: this.sgid,
1751
+ content: JSON.stringify(this.innerHtml),
1752
+ "content-type": this.contentType
1753
+ });
1754
+
1755
+ return { element: attachment }
1756
+ }
1757
+
1758
+ exportJSON() {
1759
+ return {
1760
+ type: "custom_action_text_attachment",
1761
+ version: 1,
1762
+ sgid: this.sgid,
1763
+ contentType: this.contentType,
1764
+ innerHtml: this.innerHtml
1765
+ }
1766
+ }
1767
+
1768
+ decorate() {
1769
+ return null
1770
+ }
1771
+ }
1772
+
1523
1773
  class Contents {
1524
1774
  constructor(editorElement) {
1525
1775
  this.editorElement = editorElement;
@@ -1638,6 +1888,24 @@ class Contents {
1638
1888
  });
1639
1889
  }
1640
1890
 
1891
+ createLink(url) {
1892
+ let linkNodeKey = null;
1893
+
1894
+ this.editor.update(() => {
1895
+ const textNode = $createTextNode(url);
1896
+ const linkNode = $createLinkNode(url);
1897
+ linkNode.append(textNode);
1898
+
1899
+ const selection = $getSelection();
1900
+ if ($isRangeSelection(selection)) {
1901
+ selection.insertNodes([linkNode]);
1902
+ linkNodeKey = linkNode.getKey();
1903
+ }
1904
+ });
1905
+
1906
+ return linkNodeKey
1907
+ }
1908
+
1641
1909
  createLinkWithSelectedText(url) {
1642
1910
  if (!this.hasSelectedText()) return
1643
1911
 
@@ -1709,6 +1977,30 @@ class Contents {
1709
1977
  });
1710
1978
  }
1711
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
+
1712
2004
  uploadFile(file) {
1713
2005
  if (!this.editorElement.supportsAttachments) {
1714
2006
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
@@ -1743,26 +2035,76 @@ class Contents {
1743
2035
  deleteSelectedNodes() {
1744
2036
  this.editor.update(() => {
1745
2037
  if ($isNodeSelection(this.#selection.current)) {
1746
- let nodesWereRemoved = false;
1747
- 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) => {
1748
2045
  const parent = node.getParent();
2046
+ if (!$isElementNode(parent)) return
1749
2047
 
1750
- node.remove();
2048
+ const children = parent.getChildren();
2049
+ const index = children.indexOf(node);
1751
2050
 
1752
- if (parent.getType() === "root" && parent.getChildrenSize() === 0) {
1753
- parent.append($createParagraphNode());
2051
+ if (index >= 0) {
2052
+ parent.splice(index, 1, []);
1754
2053
  }
1755
-
1756
- nodesWereRemoved = true;
1757
2054
  });
1758
2055
 
1759
- if (nodesWereRemoved) {
1760
- this.#selection.clear();
1761
- this.editor.focus();
2056
+ // Check if root is empty after all removals
2057
+ const root = $getRoot();
2058
+ if (root.getChildrenSize() === 0) {
2059
+ root.append($createParagraphNode());
2060
+ }
1762
2061
 
1763
- return true
2062
+ this.#selection.clear();
2063
+ this.editor.focus();
2064
+
2065
+ return true
2066
+ }
2067
+ });
2068
+ }
2069
+
2070
+ replaceNodeWithHTML(nodeKey, html, options = {}) {
2071
+ this.editor.update(() => {
2072
+ const node = $getNodeByKey(nodeKey);
2073
+ if (!node) return
2074
+
2075
+ const selection = $getSelection();
2076
+ let wasSelected = false;
2077
+
2078
+ if ($isRangeSelection(selection)) {
2079
+ const selectedNodes = selection.getNodes();
2080
+ wasSelected = selectedNodes.includes(node) || selectedNodes.some(n => n.getParent() === node);
2081
+
2082
+ if (wasSelected) {
2083
+ $setSelection(null);
1764
2084
  }
1765
2085
  }
2086
+
2087
+ const replacementNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
2088
+ node.replace(replacementNode);
2089
+
2090
+ if (wasSelected) {
2091
+ replacementNode.selectEnd();
2092
+ }
2093
+ });
2094
+ }
2095
+
2096
+ insertHTMLBelowNode(nodeKey, html, options = {}) {
2097
+ this.editor.update(() => {
2098
+ const node = $getNodeByKey(nodeKey);
2099
+ if (!node) return
2100
+
2101
+ let previousNode = node;
2102
+ try {
2103
+ previousNode = node.getTopLevelElementOrThrow();
2104
+ } catch {}
2105
+
2106
+ const newNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
2107
+ previousNode.insertAfter(newNode);
1766
2108
  });
1767
2109
  }
1768
2110
 
@@ -2008,6 +2350,21 @@ class Contents {
2008
2350
  }
2009
2351
  }
2010
2352
 
2353
+ #createCustomAttachmentNodeWithHtml(html, options = {}) {
2354
+ const attachmentConfig = typeof options === 'object' ? options : {};
2355
+
2356
+ return new CustomActionTextAttachmentNode({
2357
+ sgid: attachmentConfig.sgid || null,
2358
+ contentType: "text/html",
2359
+ innerHtml: html
2360
+ })
2361
+ }
2362
+
2363
+ #createHtmlNodeWith(html) {
2364
+ const htmlNodes = $generateNodesFromDOM(this.editor, parseHtml(html));
2365
+ return htmlNodes[0] || $createParagraphNode()
2366
+ }
2367
+
2011
2368
  #shouldUploadFile(file) {
2012
2369
  return dispatch(this.editorElement, 'lexxy:file-accept', { file }, true)
2013
2370
  }
@@ -2063,12 +2420,27 @@ class Clipboard {
2063
2420
  item.getAsString((text) => {
2064
2421
  if (isUrl(text) && this.contents.hasSelectedText()) {
2065
2422
  this.contents.createLinkWithSelectedText(text);
2423
+ } else if (isUrl(text)) {
2424
+ const nodeKey = this.contents.createLink(text);
2425
+ this.#dispatchLinkInsertEvent(nodeKey, { url: text });
2066
2426
  } else {
2067
2427
  this.#pasteMarkdown(text);
2068
2428
  }
2069
2429
  });
2070
2430
  }
2071
2431
 
2432
+ #dispatchLinkInsertEvent(nodeKey, payload) {
2433
+ const linkManipulationMethods = {
2434
+ replaceLinkWith: (html, options) => this.contents.replaceNodeWithHTML(nodeKey, html, options),
2435
+ insertBelowLink: (html, options) => this.contents.insertHTMLBelowNode(nodeKey, html, options)
2436
+ };
2437
+
2438
+ dispatch(this.editorElement, "lexxy:insert-link", {
2439
+ ...payload,
2440
+ ...linkManipulationMethods
2441
+ });
2442
+ }
2443
+
2072
2444
  #pasteMarkdown(text) {
2073
2445
  const html = marked(text);
2074
2446
  this.contents.insertHtml(html);
@@ -2101,113 +2473,15 @@ class Clipboard {
2101
2473
  }
2102
2474
  }
2103
2475
 
2104
- class CustomActionTextAttachmentNode extends DecoratorNode {
2105
- static getType() {
2106
- return "custom_action_text_attachment"
2107
- }
2108
-
2109
- static clone(node) {
2110
- return new CustomActionTextAttachmentNode({ ...node }, node.__key)
2111
- }
2112
-
2113
- static importJSON(serializedNode) {
2114
- return new CustomActionTextAttachmentNode({ ...serializedNode })
2115
- }
2116
-
2117
- static importDOM() {
2118
- return {
2119
- "action-text-attachment": (attachment) => {
2120
- const content = attachment.getAttribute("content");
2121
- if (!attachment.getAttribute("content")) {
2122
- return null
2123
- }
2124
-
2125
- return {
2126
- conversion: () => {
2127
- // Preserve initial space if present since Lexical removes it
2128
- const nodes = [];
2129
- const previousSibling = attachment.previousSibling;
2130
- if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
2131
- nodes.push($createTextNode(" "));
2132
- }
2133
-
2134
- nodes.push(new CustomActionTextAttachmentNode({
2135
- sgid: attachment.getAttribute("sgid"),
2136
- innerHtml: JSON.parse(content),
2137
- contentType: attachment.getAttribute("content-type")
2138
- }));
2139
-
2140
- nodes.push($createTextNode(" "));
2141
-
2142
- return { node: nodes }
2143
- },
2144
- priority: 2
2145
- }
2146
- }
2147
- }
2148
- }
2149
-
2150
- constructor({ sgid, contentType, innerHtml }, key) {
2151
- super(key);
2152
-
2153
- this.sgid = sgid;
2154
- this.contentType = contentType || "application/vnd.actiontext.unknown";
2155
- this.innerHtml = innerHtml;
2156
- }
2157
-
2158
- createDOM() {
2159
- const figure = createElement("action-text-attachment", { "content-type": this.contentType, "data-lexxy-decorator": true });
2160
-
2161
- figure.addEventListener("click", (event) => {
2162
- dispatchCustomEvent(figure, "lexxy:internal:select-node", { key: this.getKey() });
2163
- });
2164
-
2165
- figure.insertAdjacentHTML("beforeend", this.innerHtml);
2166
-
2167
- return figure
2168
- }
2169
-
2170
- updateDOM() {
2171
- return true
2172
- }
2173
-
2174
- isInline() {
2175
- return true
2176
- }
2177
-
2178
- exportDOM() {
2179
- const attachment = createElement("action-text-attachment", {
2180
- sgid: this.sgid,
2181
- content: JSON.stringify(this.innerHtml),
2182
- "content-type": this.contentType
2183
- });
2184
-
2185
- return { element: attachment }
2186
- }
2187
-
2188
- exportJSON() {
2189
- return {
2190
- type: "custom_action_text_attachment",
2191
- version: 1,
2192
- sgid: this.sgid,
2193
- contentType: this.contentType,
2194
- innerHtml: this.innerHtml
2195
- }
2196
- }
2197
-
2198
- decorate() {
2199
- return null
2200
- }
2201
- }
2202
-
2203
2476
  class LexicalEditorElement extends HTMLElement {
2204
2477
  static formAssociated = true
2205
2478
  static debug = true
2206
2479
  static commands = [ "bold", "italic", "" ]
2207
2480
 
2208
- static observedAttributes = [ "connected" ]
2481
+ static observedAttributes = [ "connected", "required" ]
2209
2482
 
2210
2483
  #initialValue = ""
2484
+ #validationTextArea = document.createElement("textarea")
2211
2485
 
2212
2486
  constructor() {
2213
2487
  super();
@@ -2240,6 +2514,11 @@ class LexicalEditorElement extends HTMLElement {
2240
2514
  if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) {
2241
2515
  requestAnimationFrame(() => this.#reconnect());
2242
2516
  }
2517
+
2518
+ if (name === "required" && this.isConnected) {
2519
+ this.#validationTextArea.required = this.hasAttribute("required");
2520
+ this.#setValidity();
2521
+ }
2243
2522
  }
2244
2523
 
2245
2524
  formResetCallback() {
@@ -2293,7 +2572,7 @@ class LexicalEditorElement extends HTMLElement {
2293
2572
  $addUpdateTag(SKIP_DOM_SELECTION_TAG);
2294
2573
  const root = $getRoot();
2295
2574
  root.clear();
2296
- root.append(...this.#parseHtmlIntoLexicalNodes(html));
2575
+ if (html !== "") root.append(...this.#parseHtmlIntoLexicalNodes(html));
2297
2576
  root.select();
2298
2577
 
2299
2578
  this.#toggleEmptyStatus();
@@ -2406,6 +2685,7 @@ class LexicalEditorElement extends HTMLElement {
2406
2685
 
2407
2686
  this.internals.setFormValue(html);
2408
2687
  this._internalFormValue = html;
2688
+ this.#validationTextArea.value = this.#isEmpty ? "" : html;
2409
2689
 
2410
2690
  if (changed) {
2411
2691
  dispatch(this, "lexxy:change");
@@ -2434,6 +2714,7 @@ class LexicalEditorElement extends HTMLElement {
2434
2714
  this.cachedValue = null;
2435
2715
  this.#internalFormValue = this.value;
2436
2716
  this.#toggleEmptyStatus();
2717
+ this.#setValidity();
2437
2718
  }));
2438
2719
  }
2439
2720
 
@@ -2540,6 +2821,14 @@ class LexicalEditorElement extends HTMLElement {
2540
2821
  return !this.editorContentElement.textContent.trim() && !containsVisuallyRelevantChildren(this.editorContentElement)
2541
2822
  }
2542
2823
 
2824
+ #setValidity() {
2825
+ if (this.#validationTextArea.validity.valid) {
2826
+ this.internals.setValidity({});
2827
+ } else {
2828
+ this.internals.setValidity(this.#validationTextArea.validity, this.#validationTextArea.validationMessage, this.editorContentElement);
2829
+ }
2830
+ }
2831
+
2543
2832
  #reset() {
2544
2833
  this.#unregisterHandlers();
2545
2834
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.9-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",