@37signals/lexxy 0.1.8-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 +212 -105
  3. package/package.json +2 -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,13 +1,14 @@
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
- import { $convertFromMarkdownString, TRANSFORMERS, registerMarkdownShortcuts } from '@lexical/markdown';
8
+ import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
9
9
  import { registerHistory, createEmptyHistoryState } from '@lexical/history';
10
10
  import { DirectUpload } from '@rails/activestorage';
11
+ import { marked } from 'marked';
11
12
  import 'prismjs/components/prism-ruby';
12
13
 
13
14
  DOMPurify.addHook("uponSanitizeElement", (node, data) => {
@@ -16,6 +17,11 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => {
16
17
  }
17
18
  });
18
19
 
20
+ const getNonce = () => {
21
+ const element = document.head.querySelector("meta[name=csp-nonce]");
22
+ return element?.content
23
+ };
24
+
19
25
  function getNearestListItemNode(node) {
20
26
  let current = node;
21
27
  while (current !== null) {
@@ -209,6 +215,7 @@ class LexicalToolbarElement extends HTMLElement {
209
215
  this.#compactMenu();
210
216
 
211
217
  this.#overflow.style.display = this.#overflowMenu.children.length ? "block" : "none";
218
+ this.#overflow.setAttribute("nonce", getNonce());
212
219
  }
213
220
 
214
221
  get #overflow() {
@@ -1399,6 +1406,7 @@ class Selection {
1399
1406
  marker.style.width = "1px";
1400
1407
  marker.style.height = "1em";
1401
1408
  marker.style.lineHeight = "normal";
1409
+ marker.setAttribute("nonce", getNonce());
1402
1410
  return marker
1403
1411
  }
1404
1412
 
@@ -1512,6 +1520,105 @@ class Selection {
1512
1520
  }
1513
1521
  }
1514
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
+
1515
1622
  class Contents {
1516
1623
  constructor(editorElement) {
1517
1624
  this.editorElement = editorElement;
@@ -1630,6 +1737,24 @@ class Contents {
1630
1737
  });
1631
1738
  }
1632
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
+
1633
1758
  createLinkWithSelectedText(url) {
1634
1759
  if (!this.hasSelectedText()) return
1635
1760
 
@@ -1758,6 +1883,47 @@ class Contents {
1758
1883
  });
1759
1884
  }
1760
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
+
1761
1927
  get #selection() {
1762
1928
  return this.editorElement.selection
1763
1929
  }
@@ -2000,6 +2166,21 @@ class Contents {
2000
2166
  }
2001
2167
  }
2002
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
+
2003
2184
  #shouldUploadFile(file) {
2004
2185
  return dispatch(this.editorElement, 'lexxy:file-accept', { file }, true)
2005
2186
  }
@@ -2055,18 +2236,32 @@ class Clipboard {
2055
2236
  item.getAsString((text) => {
2056
2237
  if (isUrl(text) && this.contents.hasSelectedText()) {
2057
2238
  this.contents.createLinkWithSelectedText(text);
2239
+ } else if (isUrl(text)) {
2240
+ const nodeKey = this.contents.createLink(text);
2241
+ this.#dispatchLinkInsertEvent(nodeKey, { url: text });
2058
2242
  } else {
2059
2243
  this.#pasteMarkdown(text);
2060
2244
  }
2061
2245
  });
2062
2246
  }
2063
2247
 
2064
- #pasteMarkdown(text) {
2065
- this.editor.update(() => {
2066
- $convertFromMarkdownString(text, TRANSFORMERS);
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
2067
2257
  });
2068
2258
  }
2069
2259
 
2260
+ #pasteMarkdown(text) {
2261
+ const html = marked(text);
2262
+ this.contents.insertHtml(html);
2263
+ }
2264
+
2070
2265
  #handlePastedFiles(clipboardData) {
2071
2266
  if (!this.editorElement.supportsAttachments) return
2072
2267
 
@@ -2094,105 +2289,6 @@ class Clipboard {
2094
2289
  }
2095
2290
  }
2096
2291
 
2097
- class CustomActionTextAttachmentNode extends DecoratorNode {
2098
- static getType() {
2099
- return "custom_action_text_attachment"
2100
- }
2101
-
2102
- static clone(node) {
2103
- return new CustomActionTextAttachmentNode({ ...node }, node.__key)
2104
- }
2105
-
2106
- static importJSON(serializedNode) {
2107
- return new CustomActionTextAttachmentNode({ ...serializedNode })
2108
- }
2109
-
2110
- static importDOM() {
2111
- return {
2112
- "action-text-attachment": (attachment) => {
2113
- const content = attachment.getAttribute("content");
2114
- if (!attachment.getAttribute("content")) {
2115
- return null
2116
- }
2117
-
2118
- return {
2119
- conversion: () => {
2120
- // Preserve initial space if present since Lexical removes it
2121
- const nodes = [];
2122
- const previousSibling = attachment.previousSibling;
2123
- if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
2124
- nodes.push($createTextNode(" "));
2125
- }
2126
-
2127
- nodes.push(new CustomActionTextAttachmentNode({
2128
- sgid: attachment.getAttribute("sgid"),
2129
- innerHtml: JSON.parse(content),
2130
- contentType: attachment.getAttribute("content-type")
2131
- }));
2132
-
2133
- nodes.push($createTextNode(" "));
2134
-
2135
- return { node: nodes }
2136
- },
2137
- priority: 2
2138
- }
2139
- }
2140
- }
2141
- }
2142
-
2143
- constructor({ sgid, contentType, innerHtml }, key) {
2144
- super(key);
2145
-
2146
- this.sgid = sgid;
2147
- this.contentType = contentType || "application/vnd.actiontext.unknown";
2148
- this.innerHtml = innerHtml;
2149
- }
2150
-
2151
- createDOM() {
2152
- const figure = createElement("action-text-attachment", { "content-type": this.contentType, "data-lexxy-decorator": true });
2153
-
2154
- figure.addEventListener("click", (event) => {
2155
- dispatchCustomEvent(figure, "lexxy:internal:select-node", { key: this.getKey() });
2156
- });
2157
-
2158
- figure.insertAdjacentHTML("beforeend", this.innerHtml);
2159
-
2160
- return figure
2161
- }
2162
-
2163
- updateDOM() {
2164
- return true
2165
- }
2166
-
2167
- isInline() {
2168
- return true
2169
- }
2170
-
2171
- exportDOM() {
2172
- const attachment = createElement("action-text-attachment", {
2173
- sgid: this.sgid,
2174
- content: JSON.stringify(this.innerHtml),
2175
- "content-type": this.contentType
2176
- });
2177
-
2178
- return { element: attachment }
2179
- }
2180
-
2181
- exportJSON() {
2182
- return {
2183
- type: "custom_action_text_attachment",
2184
- version: 1,
2185
- sgid: this.sgid,
2186
- contentType: this.contentType,
2187
- innerHtml: this.innerHtml
2188
- }
2189
- }
2190
-
2191
- decorate() {
2192
- return null
2193
- }
2194
- }
2195
-
2196
2292
  class LexicalEditorElement extends HTMLElement {
2197
2293
  static formAssociated = true
2198
2294
  static debug = true
@@ -2427,6 +2523,7 @@ class LexicalEditorElement extends HTMLElement {
2427
2523
  this.cachedValue = null;
2428
2524
  this.#internalFormValue = this.value;
2429
2525
  this.#toggleEmptyStatus();
2526
+ this.#validateRequired();
2430
2527
  }));
2431
2528
  }
2432
2529
 
@@ -2533,6 +2630,14 @@ class LexicalEditorElement extends HTMLElement {
2533
2630
  return !this.editorContentElement.textContent.trim() && !containsVisuallyRelevantChildren(this.editorContentElement)
2534
2631
  }
2535
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
+
2536
2641
  #reset() {
2537
2642
  this.#unregisterHandlers();
2538
2643
 
@@ -3065,6 +3170,7 @@ class LexicalPromptElement extends HTMLElement {
3065
3170
  const popoverContainer = createElement("ul", { role: "listbox", id: generateDomId("prompt-popover") }); // Avoiding [popover] due to not being able to position at an arbitrary X, Y position.
3066
3171
  popoverContainer.classList.add("lexxy-prompt-menu");
3067
3172
  popoverContainer.style.position = "absolute";
3173
+ popoverContainer.setAttribute("nonce", getNonce());
3068
3174
  popoverContainer.append(...(await this.source.buildListItems()));
3069
3175
  popoverContainer.addEventListener("click", this.#handlePopoverClick);
3070
3176
  this.#editorElement.appendChild(popoverContainer);
@@ -3099,6 +3205,7 @@ class CodeLanguagePicker extends HTMLElement {
3099
3205
  });
3100
3206
 
3101
3207
  this.languagePickerElement.style.position = "absolute";
3208
+ this.languagePickerElement.setAttribute("nonce", getNonce());
3102
3209
  this.editorElement.appendChild(this.languagePickerElement);
3103
3210
  }
3104
3211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.8-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",
@@ -37,6 +37,7 @@
37
37
  "@lexical/table": "^0.32.1",
38
38
  "@lexical/utils": "^0.32.1",
39
39
  "dompurify": "^3.2.6",
40
+ "marked": "^16.3.0",
40
41
  "prismjs": "^1.30.0"
41
42
  },
42
43
  "peerDependencies": {