@37signals/lexxy 0.9.14-beta → 0.9.15-alpha.2

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 +107 -33
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { highlightCode } from './lexxy_helpers.esm.js';
2
2
  import DOMPurify from 'dompurify';
3
3
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $ensureForwardRangeSelection, $isAtNodeEnd, $patchStyleText, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
4
- import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, PASTE_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, $onUpdate, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
4
+ import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, PASTE_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, $onUpdate, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
5
5
  import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
6
6
  import { buildEditorFromExtensions } from '@lexical/extension';
7
7
  import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
@@ -1527,14 +1527,15 @@ function $isShadowRoot(node) {
1527
1527
  return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
1528
1528
  }
1529
1529
 
1530
+ function $isSafeForRoot(node) {
1531
+ return ($isElementNode(node) || $isDecoratorNode(node)) && !node.isParentRequired()
1532
+ }
1533
+
1530
1534
  function $makeSafeForRoot(node) {
1531
- if ($isTextNode(node)) {
1532
- return $wrapNodeInElement(node, $createParagraphNode)
1533
- } else if (node.isParentRequired()) {
1534
- const parent = node.createRequiredParent();
1535
- return $wrapNodeInElement(node, parent)
1536
- } else {
1535
+ if ($isSafeForRoot(node)) {
1537
1536
  return node
1537
+ } else {
1538
+ return $wrapNodeInElement(node, () => node.createParentElementNode())
1538
1539
  }
1539
1540
  }
1540
1541
 
@@ -1649,6 +1650,39 @@ function $isListItemStructurallyEmpty(listItem) {
1649
1650
  return true
1650
1651
  }
1651
1652
 
1653
+ // Returns the document text up to `offset` inside `targetNode`. Non-inline
1654
+ // element siblings are joined with `\n\n`, matching Lexical's own
1655
+ // ElementNode.getTextContent behavior.
1656
+ function $textBeforeOffset(targetNode, offset) {
1657
+ const parts = [];
1658
+ let done = false;
1659
+
1660
+ function visit(node) {
1661
+ if (done) return
1662
+ if (node === targetNode) {
1663
+ parts.push(node.getTextContent().slice(0, offset));
1664
+ done = true;
1665
+ return
1666
+ }
1667
+ if ($isElementNode(node)) {
1668
+ const children = node.getChildren();
1669
+ for (let i = 0; i < children.length; i++) {
1670
+ visit(children[i]);
1671
+ if (done) return
1672
+ const child = children[i];
1673
+ if ($isElementNode(child) && !child.isInline() && i < children.length - 1) {
1674
+ parts.push("\n\n");
1675
+ }
1676
+ }
1677
+ } else {
1678
+ parts.push(node.getTextContent());
1679
+ }
1680
+ }
1681
+
1682
+ visit($getRoot());
1683
+ return parts.join("")
1684
+ }
1685
+
1652
1686
  function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1653
1687
  return $isTextNode(node)
1654
1688
  && node.getTextContent() === " "
@@ -1934,6 +1968,11 @@ function safeCloneEditorState(editorState) {
1934
1968
  return clone
1935
1969
  }
1936
1970
 
1971
+ const INITIAL_PREVIEW_POLL_DELAY_MS = 3000;
1972
+ const MAX_PREVIEW_POLL_DELAY_MS = 120000;
1973
+ const MAX_PREVIEW_POLL_ATTEMPTS = 20;
1974
+
1975
+
1937
1976
  class ActionTextAttachmentNode extends DecoratorNode {
1938
1977
  static getType() {
1939
1978
  return "action_text_attachment"
@@ -2008,7 +2047,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2008
2047
  return Lexxy.global.get("attachmentTagName")
2009
2048
  }
2010
2049
 
2011
- constructor({ tagName, sgid, src, previewSrc, previewable, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
2050
+ constructor({ tagName, sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
2012
2051
  super(key);
2013
2052
 
2014
2053
  this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
@@ -2016,6 +2055,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2016
2055
  this.src = src;
2017
2056
  this.previewSrc = previewSrc;
2018
2057
  this.previewable = parseBoolean(previewable);
2058
+ this.previewStatusUrl = previewStatusUrl;
2019
2059
  this.pendingPreview = pendingPreview;
2020
2060
  this.altText = altText || "";
2021
2061
  this.caption = caption || "";
@@ -2094,6 +2134,8 @@ class ActionTextAttachmentNode extends DecoratorNode {
2094
2134
  sgid: this.sgid,
2095
2135
  src: this.src,
2096
2136
  previewable: this.previewable,
2137
+ previewStatusUrl: this.previewStatusUrl,
2138
+ pendingPreview: this.pendingPreview,
2097
2139
  altText: this.altText,
2098
2140
  caption: this.caption,
2099
2141
  contentType: this.contentType,
@@ -2212,41 +2254,61 @@ class ActionTextAttachmentNode extends DecoratorNode {
2212
2254
  });
2213
2255
  }
2214
2256
 
2257
+ // While the file-icon is shown, watch for the preview to become ready.
2258
+ // With a status URL, poll it (2xx = processing, anything else = ready).
2259
+ // Without one, preload the preview URL once and swap on load.
2215
2260
  #pollForPreview(figure) {
2261
+ if (this.previewStatusUrl) {
2262
+ this.#waitForPreviewByPollingStatus(figure);
2263
+ } else {
2264
+ this.#waitForPreviewByPreloadingImage(figure);
2265
+ }
2266
+ }
2267
+
2268
+ #waitForPreviewByPollingStatus(figure) {
2216
2269
  let attempt = 0;
2217
- const maxAttempts = 10;
2218
2270
 
2219
- const tryLoad = () => {
2271
+ const tryStatus = async () => {
2220
2272
  if (!this.editor.read(() => this.isAttached())) return
2221
2273
 
2222
- const img = new Image();
2223
- const cacheBustedSrc = `${this.src}${this.src.includes("?") ? "&" : "?"}_=${Date.now()}`;
2274
+ try {
2275
+ // redirect: "manual" prevents fetch from transparently following a
2276
+ // 3xx response — without it, a status endpoint that redirected to,
2277
+ // say, the preview URL would resolve to a 200 and look like
2278
+ // "still processing." The contract is "any non-2xx means done."
2279
+ const response = await fetch(this.previewStatusUrl, { credentials: "include", redirect: "manual" });
2224
2280
 
2225
- img.onload = () => {
2226
2281
  if (!this.editor.read(() => this.isAttached())) return
2227
2282
 
2228
- // The placeholder is a file-type icon SVG (86×100). A real thumbnail
2229
- // generated from PDF/video content is significantly larger.
2230
- if (img.naturalWidth > 150 && img.naturalHeight > 150) {
2231
- this.#swapToPreviewDOM(figure, cacheBustedSrc);
2232
- } else {
2283
+ if (response.ok) {
2233
2284
  retry();
2285
+ } else {
2286
+ this.#swapToPreviewDOM(figure, this.src);
2234
2287
  }
2235
- };
2236
- img.onerror = () => retry();
2237
- img.src = cacheBustedSrc;
2288
+ } catch {
2289
+ retry();
2290
+ }
2238
2291
  };
2239
2292
 
2240
2293
  const retry = () => {
2241
2294
  attempt++;
2242
- if (attempt < maxAttempts && this.editor.read(() => this.isAttached())) {
2243
- const delay = Math.min(2000 * Math.pow(1.5, attempt), 15000);
2244
- setTimeout(tryLoad, delay);
2295
+ if (attempt < MAX_PREVIEW_POLL_ATTEMPTS && this.editor.read(() => this.isAttached())) {
2296
+ const delay = Math.min(2000 * Math.pow(1.5, attempt), MAX_PREVIEW_POLL_DELAY_MS);
2297
+ setTimeout(tryStatus, delay);
2245
2298
  }
2246
2299
  };
2247
2300
 
2248
2301
  // Give the server time to start processing before the first attempt
2249
- setTimeout(tryLoad, 3000);
2302
+ setTimeout(tryStatus, INITIAL_PREVIEW_POLL_DELAY_MS);
2303
+ }
2304
+
2305
+ #waitForPreviewByPreloadingImage(figure) {
2306
+ const img = new Image();
2307
+ img.onload = () => {
2308
+ if (!this.editor.read(() => this.isAttached())) return
2309
+ this.#swapToPreviewDOM(figure, this.src);
2310
+ };
2311
+ img.src = this.src;
2250
2312
  }
2251
2313
 
2252
2314
  #swapToPreviewDOM(figure, previewSrc) {
@@ -4825,7 +4887,7 @@ class ImageGalleryNode extends ElementNode {
4825
4887
  replaceWithSingularChild() {
4826
4888
  if (this.#hasSingularChild) {
4827
4889
  const child = this.getFirstChild();
4828
- return this.replace(child)
4890
+ return this.replace($makeSafeForRoot(child))
4829
4891
  }
4830
4892
  }
4831
4893
 
@@ -5253,6 +5315,7 @@ class AttachmentNodeConversion {
5253
5315
  fileName: blob.filename,
5254
5316
  fileSize: blob.byte_size,
5255
5317
  previewable: blob.previewable,
5318
+ previewStatusUrl: blob.preview_status_url
5256
5319
  }
5257
5320
  }
5258
5321
 
@@ -8552,6 +8615,9 @@ class RemoteFilterSource extends BaseSource {
8552
8615
  const NOTHING_FOUND_DEFAULT_MESSAGE = "Nothing found";
8553
8616
  const FILTER_DEBOUNCE_INTERVAL = 50;
8554
8617
 
8618
+ // Start of line, or after a space or newline.
8619
+ const DEFAULT_ONLY_AT_PATTERN = "^|[ \\n]";
8620
+
8555
8621
  class LexicalPromptElement extends HTMLElement {
8556
8622
  #globalListeners = new ListenerBin()
8557
8623
  #popoverListeners = new ListenerBin()
@@ -8597,6 +8663,10 @@ class LexicalPromptElement extends HTMLElement {
8597
8663
  return this.hasAttribute("supports-space-in-searches")
8598
8664
  }
8599
8665
 
8666
+ get onlyAt() {
8667
+ return this.getAttribute("only-at")
8668
+ }
8669
+
8600
8670
  get open() {
8601
8671
  return this.popoverElement?.classList?.contains("lexxy-prompt-menu--visible")
8602
8672
  }
@@ -8640,14 +8710,10 @@ class LexicalPromptElement extends HTMLElement {
8640
8710
  if (offset >= triggerLength) {
8641
8711
  const textBeforeCursor = fullText.slice(offset - triggerLength, offset);
8642
8712
 
8643
- // Check if trigger is at the start of the text node (new line case) or preceded by space or newline
8644
8713
  if (textBeforeCursor === this.trigger) {
8645
- const isAtStart = offset === triggerLength;
8714
+ const textBeforeTrigger = $textBeforeOffset(node, offset - triggerLength);
8646
8715
 
8647
- const charBeforeTrigger = offset > triggerLength ? fullText[offset - triggerLength - 1] : null;
8648
- const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n";
8649
-
8650
- if (isAtStart || isPrecededBySpaceOrNewline) {
8716
+ if (this.#onlyAtRegExp.test(textBeforeTrigger)) {
8651
8717
  this.#popoverListeners.dispose();
8652
8718
  this.#showPopover();
8653
8719
  }
@@ -8658,7 +8724,15 @@ class LexicalPromptElement extends HTMLElement {
8658
8724
  }));
8659
8725
  }
8660
8726
 
8727
+ get #onlyAtRegExp() {
8728
+ return new RegExp(`(?:${this.onlyAt ?? DEFAULT_ONLY_AT_PATTERN})$`)
8729
+ }
8730
+
8661
8731
  get #promptContentTypePermitted() {
8732
+ // `insert-editable-text` prompts never create attachments, so the
8733
+ // editor's attachment support and content-type allowlist don't apply.
8734
+ if (this.hasAttribute("insert-editable-text")) return true
8735
+
8662
8736
  const el = this.#editorElement;
8663
8737
  if (!el.supportsAttachments) {
8664
8738
  return false
@@ -8821,7 +8895,7 @@ class LexicalPromptElement extends HTMLElement {
8821
8895
 
8822
8896
  const popoverRect = this.popoverElement.getBoundingClientRect();
8823
8897
 
8824
- if (popoverRect.right > window.innerWidth) {
8898
+ if (popoverRect.right > editorRect.right) {
8825
8899
  this.popoverElement.toggleAttribute("data-clipped-at-right", true);
8826
8900
  }
8827
8901
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.14-beta",
3
+ "version": "0.9.15-alpha.2",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",