@37signals/lexxy 0.1.9-beta → 0.1.10-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 +199 -101
  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_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';
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';
@@ -1520,6 +1520,105 @@ class Selection {
1520
1520
  }
1521
1521
  }
1522
1522
 
1523
+ class CustomActionTextAttachmentNode extends DecoratorNode {
1524
+ static getType() {
1525
+ return "custom_action_text_attachment"
1526
+ }
1527
+
1528
+ static clone(node) {
1529
+ return new CustomActionTextAttachmentNode({ ...node }, node.__key)
1530
+ }
1531
+
1532
+ static importJSON(serializedNode) {
1533
+ return new CustomActionTextAttachmentNode({ ...serializedNode })
1534
+ }
1535
+
1536
+ static importDOM() {
1537
+ return {
1538
+ "action-text-attachment": (attachment) => {
1539
+ const content = attachment.getAttribute("content");
1540
+ if (!attachment.getAttribute("content")) {
1541
+ return null
1542
+ }
1543
+
1544
+ return {
1545
+ conversion: () => {
1546
+ // Preserve initial space if present since Lexical removes it
1547
+ const nodes = [];
1548
+ const previousSibling = attachment.previousSibling;
1549
+ if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
1550
+ nodes.push($createTextNode(" "));
1551
+ }
1552
+
1553
+ nodes.push(new CustomActionTextAttachmentNode({
1554
+ sgid: attachment.getAttribute("sgid"),
1555
+ innerHtml: JSON.parse(content),
1556
+ contentType: attachment.getAttribute("content-type")
1557
+ }));
1558
+
1559
+ nodes.push($createTextNode(" "));
1560
+
1561
+ return { node: nodes }
1562
+ },
1563
+ priority: 2
1564
+ }
1565
+ }
1566
+ }
1567
+ }
1568
+
1569
+ constructor({ sgid, contentType, innerHtml }, key) {
1570
+ super(key);
1571
+
1572
+ this.sgid = sgid;
1573
+ this.contentType = contentType || "application/vnd.actiontext.unknown";
1574
+ this.innerHtml = innerHtml;
1575
+ }
1576
+
1577
+ createDOM() {
1578
+ const figure = createElement("action-text-attachment", { "content-type": this.contentType, "data-lexxy-decorator": true });
1579
+
1580
+ figure.addEventListener("click", (event) => {
1581
+ dispatchCustomEvent(figure, "lexxy:internal:select-node", { key: this.getKey() });
1582
+ });
1583
+
1584
+ figure.insertAdjacentHTML("beforeend", this.innerHtml);
1585
+
1586
+ return figure
1587
+ }
1588
+
1589
+ updateDOM() {
1590
+ return true
1591
+ }
1592
+
1593
+ isInline() {
1594
+ return true
1595
+ }
1596
+
1597
+ exportDOM() {
1598
+ const attachment = createElement("action-text-attachment", {
1599
+ sgid: this.sgid,
1600
+ content: JSON.stringify(this.innerHtml),
1601
+ "content-type": this.contentType
1602
+ });
1603
+
1604
+ return { element: attachment }
1605
+ }
1606
+
1607
+ exportJSON() {
1608
+ return {
1609
+ type: "custom_action_text_attachment",
1610
+ version: 1,
1611
+ sgid: this.sgid,
1612
+ contentType: this.contentType,
1613
+ innerHtml: this.innerHtml
1614
+ }
1615
+ }
1616
+
1617
+ decorate() {
1618
+ return null
1619
+ }
1620
+ }
1621
+
1523
1622
  class Contents {
1524
1623
  constructor(editorElement) {
1525
1624
  this.editorElement = editorElement;
@@ -1638,6 +1737,24 @@ class Contents {
1638
1737
  });
1639
1738
  }
1640
1739
 
1740
+ createLink(url) {
1741
+ let linkNodeKey = null;
1742
+
1743
+ this.editor.update(() => {
1744
+ const textNode = $createTextNode(url);
1745
+ const linkNode = $createLinkNode(url);
1746
+ linkNode.append(textNode);
1747
+
1748
+ const selection = $getSelection();
1749
+ if ($isRangeSelection(selection)) {
1750
+ selection.insertNodes([linkNode]);
1751
+ linkNodeKey = linkNode.getKey();
1752
+ }
1753
+ });
1754
+
1755
+ return linkNodeKey
1756
+ }
1757
+
1641
1758
  createLinkWithSelectedText(url) {
1642
1759
  if (!this.hasSelectedText()) return
1643
1760
 
@@ -1766,6 +1883,47 @@ class Contents {
1766
1883
  });
1767
1884
  }
1768
1885
 
1886
+ replaceNodeWithHTML(nodeKey, html, options = {}) {
1887
+ this.editor.update(() => {
1888
+ const node = $getNodeByKey(nodeKey);
1889
+ if (!node) return
1890
+
1891
+ const selection = $getSelection();
1892
+ let wasSelected = false;
1893
+
1894
+ if ($isRangeSelection(selection)) {
1895
+ const selectedNodes = selection.getNodes();
1896
+ wasSelected = selectedNodes.includes(node) || selectedNodes.some(n => n.getParent() === node);
1897
+
1898
+ if (wasSelected) {
1899
+ $setSelection(null);
1900
+ }
1901
+ }
1902
+
1903
+ const replacementNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
1904
+ node.replace(replacementNode);
1905
+
1906
+ if (wasSelected) {
1907
+ replacementNode.selectEnd();
1908
+ }
1909
+ });
1910
+ }
1911
+
1912
+ insertHTMLBelowNode(nodeKey, html, options = {}) {
1913
+ this.editor.update(() => {
1914
+ const node = $getNodeByKey(nodeKey);
1915
+ if (!node) return
1916
+
1917
+ let previousNode = node;
1918
+ try {
1919
+ previousNode = node.getTopLevelElementOrThrow();
1920
+ } catch {}
1921
+
1922
+ const newNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
1923
+ previousNode.insertAfter(newNode);
1924
+ });
1925
+ }
1926
+
1769
1927
  get #selection() {
1770
1928
  return this.editorElement.selection
1771
1929
  }
@@ -2008,6 +2166,21 @@ class Contents {
2008
2166
  }
2009
2167
  }
2010
2168
 
2169
+ #createCustomAttachmentNodeWithHtml(html, options = {}) {
2170
+ const attachmentConfig = typeof options === 'object' ? options : {};
2171
+
2172
+ return new CustomActionTextAttachmentNode({
2173
+ sgid: attachmentConfig.sgid || null,
2174
+ contentType: "text/html",
2175
+ innerHtml: html
2176
+ })
2177
+ }
2178
+
2179
+ #createHtmlNodeWith(html) {
2180
+ const htmlNodes = $generateNodesFromDOM(this.editor, parseHtml(html));
2181
+ return htmlNodes[0] || $createParagraphNode()
2182
+ }
2183
+
2011
2184
  #shouldUploadFile(file) {
2012
2185
  return dispatch(this.editorElement, 'lexxy:file-accept', { file }, true)
2013
2186
  }
@@ -2063,12 +2236,27 @@ class Clipboard {
2063
2236
  item.getAsString((text) => {
2064
2237
  if (isUrl(text) && this.contents.hasSelectedText()) {
2065
2238
  this.contents.createLinkWithSelectedText(text);
2239
+ } else if (isUrl(text)) {
2240
+ const nodeKey = this.contents.createLink(text);
2241
+ this.#dispatchLinkInsertEvent(nodeKey, { url: text });
2066
2242
  } else {
2067
2243
  this.#pasteMarkdown(text);
2068
2244
  }
2069
2245
  });
2070
2246
  }
2071
2247
 
2248
+ #dispatchLinkInsertEvent(nodeKey, payload) {
2249
+ const linkManipulationMethods = {
2250
+ replaceLinkWith: (html, options) => this.contents.replaceNodeWithHTML(nodeKey, html, options),
2251
+ insertBelowLink: (html, options) => this.contents.insertHTMLBelowNode(nodeKey, html, options)
2252
+ };
2253
+
2254
+ dispatch(this.editorElement, "lexxy:insert-link", {
2255
+ ...payload,
2256
+ ...linkManipulationMethods
2257
+ });
2258
+ }
2259
+
2072
2260
  #pasteMarkdown(text) {
2073
2261
  const html = marked(text);
2074
2262
  this.contents.insertHtml(html);
@@ -2101,105 +2289,6 @@ class Clipboard {
2101
2289
  }
2102
2290
  }
2103
2291
 
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
2292
  class LexicalEditorElement extends HTMLElement {
2204
2293
  static formAssociated = true
2205
2294
  static debug = true
@@ -2434,6 +2523,7 @@ class LexicalEditorElement extends HTMLElement {
2434
2523
  this.cachedValue = null;
2435
2524
  this.#internalFormValue = this.value;
2436
2525
  this.#toggleEmptyStatus();
2526
+ this.#validateRequired();
2437
2527
  }));
2438
2528
  }
2439
2529
 
@@ -2540,6 +2630,14 @@ class LexicalEditorElement extends HTMLElement {
2540
2630
  return !this.editorContentElement.textContent.trim() && !containsVisuallyRelevantChildren(this.editorContentElement)
2541
2631
  }
2542
2632
 
2633
+ #validateRequired() {
2634
+ if (this.hasAttribute("required") && this.#isEmpty) {
2635
+ this.internals.setValidity({ valueMissing: true }, "Please fill out this field.", this.editorContentElement);
2636
+ } else {
2637
+ this.internals.setValidity({});
2638
+ }
2639
+ }
2640
+
2543
2641
  #reset() {
2544
2642
  this.#unregisterHandlers();
2545
2643
 
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.10-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",