@37signals/lexxy 0.1.15-beta → 0.1.16-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 +390 -14
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import DOMPurify from 'dompurify';
2
- import { $getSelection, $isRangeSelection, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, $isNodeSelection, $getRoot, $isLineBreakNode, $isTextNode, $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, $insertNodes, $isParagraphNode, $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
- import { $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListNode, ListItemNode, registerList } from '@lexical/list';
2
+ import { $getSelection, $isRangeSelection, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, $isNodeSelection, $getRoot, $isLineBreakNode, $isTextNode, $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, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, $insertNodes, $createLineBreakNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, SKIP_DOM_SELECTION_TAG, createEditor, COMMAND_PRIORITY_NORMAL, KEY_TAB_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
3
+ import { $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, ListNode, ListItemNode, registerList } from '@lexical/list';
4
4
  import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
5
5
  import { $isCodeNode, CodeNode, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP, normalizeCodeLang } from '@lexical/code';
6
6
  import { $isLinkNode, $toggleLink, $createLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
@@ -1053,7 +1053,7 @@ class CommandDispatcher {
1053
1053
  }
1054
1054
 
1055
1055
  dispatchInsertQuoteBlock() {
1056
- this.contents.toggleNodeWrappingAllSelectedLines((node) => $isQuoteNode(node), () => $createQuoteNode());
1056
+ this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode());
1057
1057
  }
1058
1058
 
1059
1059
  dispatchInsertCodeBlock() {
@@ -1920,10 +1920,277 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1920
1920
  }
1921
1921
  }
1922
1922
 
1923
+ class FormatEscaper {
1924
+ constructor(editorElement) {
1925
+ this.editorElement = editorElement;
1926
+ this.editor = editorElement.editor;
1927
+ }
1928
+
1929
+ monitor() {
1930
+ this.editor.registerCommand(
1931
+ KEY_ENTER_COMMAND,
1932
+ (event) => this.#handleEnterKey(event),
1933
+ COMMAND_PRIORITY_HIGH
1934
+ );
1935
+ }
1936
+
1937
+ #handleEnterKey(event) {
1938
+ const selection = $getSelection();
1939
+ if (!$isRangeSelection(selection)) return false
1940
+
1941
+ const anchorNode = selection.anchor.getNode();
1942
+
1943
+ return this.#handleLists(event, anchorNode)
1944
+ || this.#handleBlockquotes(event, anchorNode)
1945
+ }
1946
+
1947
+ #handleLists(event, anchorNode) {
1948
+ if (this.#shouldEscapeFromEmptyListItem(anchorNode) || this.#shouldEscapeFromEmptyParagraphInListItem(anchorNode)) {
1949
+ event.preventDefault();
1950
+ this.#escapeFromList(anchorNode);
1951
+ return true
1952
+ }
1953
+
1954
+ return false
1955
+ }
1956
+
1957
+ #handleBlockquotes(event, anchorNode) {
1958
+ if (this.#shouldEscapeFromEmptyParagraphInBlockquote(anchorNode)) {
1959
+ event.preventDefault();
1960
+ this.#escapeFromBlockquote(anchorNode);
1961
+ return true
1962
+ }
1963
+
1964
+ return false
1965
+ }
1966
+
1967
+ #shouldEscapeFromEmptyListItem(node) {
1968
+ const listItem = this.#getListItemNode(node);
1969
+ if (!listItem) return false
1970
+
1971
+ return this.#isNodeEmpty(listItem)
1972
+ }
1973
+
1974
+ #shouldEscapeFromEmptyParagraphInListItem(node) {
1975
+ const paragraph = this.#getParagraphNode(node);
1976
+ if (!paragraph) return false
1977
+
1978
+ if (!this.#isNodeEmpty(paragraph)) return false
1979
+
1980
+ const parent = paragraph.getParent();
1981
+ return parent && $isListItemNode(parent)
1982
+ }
1983
+
1984
+ #isNodeEmpty(node) {
1985
+ if (node.getTextContent().trim() !== "") return false
1986
+
1987
+ const children = node.getChildren();
1988
+ if (children.length === 0) return true
1989
+
1990
+ return children.every(child => {
1991
+ if ($isLineBreakNode(child)) return true
1992
+ return this.#isNodeEmpty(child)
1993
+ })
1994
+ }
1995
+
1996
+ #getListItemNode(node) {
1997
+ let currentNode = node;
1998
+
1999
+ while (currentNode) {
2000
+ if ($isListItemNode(currentNode)) {
2001
+ return currentNode
2002
+ }
2003
+ currentNode = currentNode.getParent();
2004
+ }
2005
+
2006
+ return null
2007
+ }
2008
+
2009
+ #escapeFromList(anchorNode) {
2010
+ const listItem = this.#getListItemNode(anchorNode);
2011
+ if (!listItem) return
2012
+
2013
+ const parentList = listItem.getParent();
2014
+ if (!parentList || !$isListNode(parentList)) return
2015
+
2016
+ const blockquote = parentList.getParent();
2017
+ const isInBlockquote = blockquote && $isQuoteNode(blockquote);
2018
+
2019
+ if (isInBlockquote) {
2020
+ const listItemsAfter = this.#getListItemSiblingsAfter(listItem);
2021
+ const nonEmptyListItems = listItemsAfter.filter(item => !this.#isNodeEmpty(item));
2022
+
2023
+ if (nonEmptyListItems.length > 0) {
2024
+ this.#splitBlockquoteWithList(blockquote, parentList, listItem, nonEmptyListItems);
2025
+ return
2026
+ }
2027
+ }
2028
+
2029
+ const paragraph = $createParagraphNode();
2030
+ parentList.insertAfter(paragraph);
2031
+
2032
+ listItem.remove();
2033
+ paragraph.selectStart();
2034
+ }
2035
+
2036
+ #shouldEscapeFromEmptyParagraphInBlockquote(node) {
2037
+ const paragraph = this.#getParagraphNode(node);
2038
+ if (!paragraph) return false
2039
+
2040
+ if (!this.#isNodeEmpty(paragraph)) return false
2041
+
2042
+ const parent = paragraph.getParent();
2043
+ return parent && $isQuoteNode(parent)
2044
+ }
2045
+
2046
+ #getParagraphNode(node) {
2047
+ let currentNode = node;
2048
+
2049
+ while (currentNode) {
2050
+ if ($isParagraphNode(currentNode)) {
2051
+ return currentNode
2052
+ }
2053
+ currentNode = currentNode.getParent();
2054
+ }
2055
+
2056
+ return null
2057
+ }
2058
+
2059
+ #escapeFromBlockquote(anchorNode) {
2060
+ const paragraph = this.#getParagraphNode(anchorNode);
2061
+ if (!paragraph) return
2062
+
2063
+ const blockquote = paragraph.getParent();
2064
+ if (!blockquote || !$isQuoteNode(blockquote)) return
2065
+
2066
+ const siblingsAfter = this.#getSiblingsAfter(paragraph);
2067
+ const nonEmptySiblings = siblingsAfter.filter(sibling => !this.#isNodeEmpty(sibling));
2068
+
2069
+ if (nonEmptySiblings.length > 0) {
2070
+ this.#splitBlockquote(blockquote, paragraph, nonEmptySiblings);
2071
+ } else {
2072
+ const newParagraph = $createParagraphNode();
2073
+ blockquote.insertAfter(newParagraph);
2074
+ paragraph.remove();
2075
+ newParagraph.selectStart();
2076
+ }
2077
+ }
2078
+
2079
+ #getSiblingsAfter(node) {
2080
+ const siblings = [];
2081
+ let sibling = node.getNextSibling();
2082
+
2083
+ while (sibling) {
2084
+ siblings.push(sibling);
2085
+ sibling = sibling.getNextSibling();
2086
+ }
2087
+
2088
+ return siblings
2089
+ }
2090
+
2091
+ #getListItemSiblingsAfter(listItem) {
2092
+ const siblings = [];
2093
+ let sibling = listItem.getNextSibling();
2094
+
2095
+ while (sibling) {
2096
+ if ($isListItemNode(sibling)) {
2097
+ siblings.push(sibling);
2098
+ }
2099
+ sibling = sibling.getNextSibling();
2100
+ }
2101
+
2102
+ return siblings
2103
+ }
2104
+
2105
+ #splitBlockquoteWithList(blockquote, parentList, emptyListItem, listItemsAfter) {
2106
+ const blockquoteSiblingsAfterList = this.#getSiblingsAfter(parentList);
2107
+ const nonEmptyBlockquoteSiblings = blockquoteSiblingsAfterList.filter(sibling => !this.#isNodeEmpty(sibling));
2108
+
2109
+ const middleParagraph = $createParagraphNode();
2110
+ blockquote.insertAfter(middleParagraph);
2111
+
2112
+ const newList = $createListNode(parentList.getListType());
2113
+
2114
+ const newBlockquote = $createQuoteNode();
2115
+ middleParagraph.insertAfter(newBlockquote);
2116
+ newBlockquote.append(newList);
2117
+
2118
+ listItemsAfter.forEach(item => {
2119
+ newList.append(item);
2120
+ });
2121
+
2122
+ nonEmptyBlockquoteSiblings.forEach(sibling => {
2123
+ newBlockquote.append(sibling);
2124
+ });
2125
+
2126
+ emptyListItem.remove();
2127
+
2128
+ this.#removeTrailingEmptyListItems(parentList);
2129
+ this.#removeTrailingEmptyNodes(newBlockquote);
2130
+
2131
+ if (parentList.getChildrenSize() === 0) {
2132
+ parentList.remove();
2133
+
2134
+ if (blockquote.getChildrenSize() === 0) {
2135
+ blockquote.remove();
2136
+ }
2137
+ } else {
2138
+ this.#removeTrailingEmptyNodes(blockquote);
2139
+ }
2140
+
2141
+ middleParagraph.selectStart();
2142
+ }
2143
+
2144
+ #removeTrailingEmptyListItems(list) {
2145
+ const items = list.getChildren();
2146
+ for (let i = items.length - 1; i >= 0; i--) {
2147
+ const item = items[i];
2148
+ if ($isListItemNode(item) && this.#isNodeEmpty(item)) {
2149
+ item.remove();
2150
+ } else {
2151
+ break
2152
+ }
2153
+ }
2154
+ }
2155
+
2156
+ #removeTrailingEmptyNodes(blockquote) {
2157
+ const children = blockquote.getChildren();
2158
+ for (let i = children.length - 1; i >= 0; i--) {
2159
+ const child = children[i];
2160
+ if (this.#isNodeEmpty(child)) {
2161
+ child.remove();
2162
+ } else {
2163
+ break
2164
+ }
2165
+ }
2166
+ }
2167
+
2168
+ #splitBlockquote(blockquote, emptyParagraph, siblingsAfter) {
2169
+ const newParagraph = $createParagraphNode();
2170
+ blockquote.insertAfter(newParagraph);
2171
+
2172
+ const newBlockquote = $createQuoteNode();
2173
+ newParagraph.insertAfter(newBlockquote);
2174
+
2175
+ siblingsAfter.forEach(sibling => {
2176
+ newBlockquote.append(sibling);
2177
+ });
2178
+
2179
+ emptyParagraph.remove();
2180
+
2181
+ this.#removeTrailingEmptyNodes(blockquote);
2182
+ this.#removeTrailingEmptyNodes(newBlockquote);
2183
+
2184
+ newParagraph.selectStart();
2185
+ }
2186
+ }
2187
+
1923
2188
  class Contents {
1924
2189
  constructor(editorElement) {
1925
2190
  this.editorElement = editorElement;
1926
2191
  this.editor = editorElement.editor;
2192
+
2193
+ new FormatEscaper(editorElement).monitor();
1927
2194
  }
1928
2195
 
1929
2196
  insertHtml(html) {
@@ -1984,20 +2251,23 @@ class Contents {
1984
2251
  if (isFormatAppliedFn(topLevelElement)) {
1985
2252
  this.removeFormattingFromSelectedLines();
1986
2253
  } else {
1987
- this.insertNodeWrappingAllSelectedLines(newNodeFn);
2254
+ this.#insertNodeWrappingAllSelectedLines(newNodeFn);
1988
2255
  }
1989
2256
  });
1990
2257
  }
1991
2258
 
1992
- insertNodeWrappingAllSelectedLines(newNodeFn) {
2259
+ toggleNodeWrappingAllSelectedNodes(isFormatAppliedFn, newNodeFn) {
1993
2260
  this.editor.update(() => {
1994
2261
  const selection = $getSelection();
1995
2262
  if (!$isRangeSelection(selection)) return
1996
2263
 
1997
- if (selection.isCollapsed()) {
1998
- this.#wrapCurrentLine(selection, newNodeFn);
2264
+ const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
2265
+
2266
+ // Check if format is already applied
2267
+ if (isFormatAppliedFn(topLevelElement)) {
2268
+ this.#unwrap(topLevelElement);
1999
2269
  } else {
2000
- this.#wrapMultipleSelectedLines(selection, newNodeFn);
2270
+ this.#insertNodeWrappingAllSelectedNodes(newNodeFn);
2001
2271
  }
2002
2272
  });
2003
2273
  }
@@ -2238,6 +2508,80 @@ class Contents {
2238
2508
  return this.editorElement.selection
2239
2509
  }
2240
2510
 
2511
+ #unwrap(node) {
2512
+ const children = node.getChildren();
2513
+
2514
+ children.forEach((child) => {
2515
+ node.insertBefore(child);
2516
+ });
2517
+
2518
+ node.remove();
2519
+ }
2520
+
2521
+ #insertNodeWrappingAllSelectedNodes(newNodeFn) {
2522
+ this.editor.update(() => {
2523
+ const selection = $getSelection();
2524
+ if (!$isRangeSelection(selection)) return
2525
+
2526
+ const selectedNodes = selection.extract();
2527
+ if (selectedNodes.length === 0) return
2528
+
2529
+ const topLevelElements = new Set();
2530
+ selectedNodes.forEach((node) => {
2531
+ const topLevel = node.getTopLevelElementOrThrow();
2532
+ topLevelElements.add(topLevel);
2533
+ });
2534
+
2535
+ const elements = this.#removeTrailingEmptyParagraphs(Array.from(topLevelElements));
2536
+ if (elements.length === 0) return
2537
+
2538
+ const wrappingNode = newNodeFn();
2539
+ elements[0].insertBefore(wrappingNode);
2540
+ elements.forEach((element) => {
2541
+ wrappingNode.append(element);
2542
+ });
2543
+
2544
+ $setSelection(null);
2545
+ });
2546
+ }
2547
+
2548
+ #removeTrailingEmptyParagraphs(elements) {
2549
+ let lastNonEmptyIndex = elements.length - 1;
2550
+
2551
+ // Find the last non-empty paragraph
2552
+ while (lastNonEmptyIndex >= 0) {
2553
+ const element = elements[lastNonEmptyIndex];
2554
+ if (!$isParagraphNode(element) || !this.#isElementEmpty(element)) {
2555
+ break
2556
+ }
2557
+ lastNonEmptyIndex--;
2558
+ }
2559
+
2560
+ return elements.slice(0, lastNonEmptyIndex + 1)
2561
+ }
2562
+
2563
+ #isElementEmpty(element) {
2564
+ // Check text content first
2565
+ if (element.getTextContent().trim() !== "") return false
2566
+
2567
+ // Check if it only contains line breaks
2568
+ const children = element.getChildren();
2569
+ return children.length === 0 || children.every(child => $isLineBreakNode(child))
2570
+ }
2571
+
2572
+ #insertNodeWrappingAllSelectedLines(newNodeFn) {
2573
+ this.editor.update(() => {
2574
+ const selection = $getSelection();
2575
+ if (!$isRangeSelection(selection)) return
2576
+
2577
+ if (selection.isCollapsed()) {
2578
+ this.#wrapCurrentLine(selection, newNodeFn);
2579
+ } else {
2580
+ this.#wrapMultipleSelectedLines(selection, newNodeFn);
2581
+ }
2582
+ });
2583
+ }
2584
+
2241
2585
  #wrapCurrentLine(selection, newNodeFn) {
2242
2586
  const anchorNode = selection.anchor.getNode();
2243
2587
  const topLevelElement = anchorNode.getTopLevelElementOrThrow();
@@ -2566,7 +2910,7 @@ class Clipboard {
2566
2910
 
2567
2911
  if (!clipboardData) return false
2568
2912
 
2569
- if (this.#isOnlyPlainTextPasted(clipboardData)) {
2913
+ if (this.#isOnlyPlainTextPasted(clipboardData) && !this.#isPastingIntoCodeBlock()) {
2570
2914
  this.#pastePlainText(clipboardData);
2571
2915
  event.preventDefault();
2572
2916
  return true
@@ -2580,6 +2924,27 @@ class Clipboard {
2580
2924
  return types.length === 1 && types[0] === "text/plain"
2581
2925
  }
2582
2926
 
2927
+ #isPastingIntoCodeBlock() {
2928
+ let result = false;
2929
+
2930
+ this.editor.getEditorState().read(() => {
2931
+ const selection = $getSelection();
2932
+ if (!$isRangeSelection(selection)) return
2933
+
2934
+ let currentNode = selection.anchor.getNode();
2935
+
2936
+ while (currentNode) {
2937
+ if ($isCodeNode(currentNode)) {
2938
+ result = true;
2939
+ return
2940
+ }
2941
+ currentNode = currentNode.getParent();
2942
+ }
2943
+ });
2944
+
2945
+ return result
2946
+ }
2947
+
2583
2948
  #pastePlainText(clipboardData) {
2584
2949
  const item = clipboardData.items[0];
2585
2950
  item.getAsString((text) => {
@@ -3316,8 +3681,8 @@ class LexicalPromptElement extends HTMLElement {
3316
3681
 
3317
3682
  async #showPopover() {
3318
3683
  this.popoverElement ??= await this.#buildPopover();
3684
+ this.#resetPopoverPosition();
3319
3685
  await this.#filterOptions();
3320
- this.#positionPopover();
3321
3686
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", true);
3322
3687
  this.#selectFirstOption();
3323
3688
 
@@ -3372,19 +3737,29 @@ class LexicalPromptElement extends HTMLElement {
3372
3737
  const contentRect = this.#editorContentElement.getBoundingClientRect();
3373
3738
  const verticalOffset = contentRect.top - editorRect.top;
3374
3739
 
3375
- this.popoverElement.style.left = `${x}px`;
3740
+ if (!this.popoverElement.hasAttribute("data-anchored")) {
3741
+ this.popoverElement.style.left = `${x}px`;
3742
+ this.popoverElement.toggleAttribute("data-anchored", true);
3743
+ }
3744
+
3376
3745
  this.popoverElement.style.top = `${y + verticalOffset}px`;
3377
3746
  this.popoverElement.style.bottom = "auto";
3378
3747
 
3379
3748
  const popoverRect = this.popoverElement.getBoundingClientRect();
3380
3749
  const isClippedAtBottom = popoverRect.bottom > window.innerHeight;
3381
3750
 
3382
- if (isClippedAtBottom) {
3383
- this.popoverElement.style.bottom = `${y - verticalOffset + fontSize}px`;
3384
- this.popoverElement.style.top = "auto";
3751
+ if (isClippedAtBottom || this.popoverElement.hasAttribute("data-clipped-at-bottom")) {
3752
+ this.popoverElement.style.top = `${y + verticalOffset - popoverRect.height - fontSize}px`;
3753
+ this.popoverElement.style.bottom = "auto";
3754
+ this.popoverElement.toggleAttribute("data-clipped-at-bottom", true);
3385
3755
  }
3386
3756
  }
3387
3757
 
3758
+ #resetPopoverPosition() {
3759
+ this.popoverElement.removeAttribute("data-clipped-at-bottom");
3760
+ this.popoverElement.removeAttribute("data-anchored");
3761
+ }
3762
+
3388
3763
  async #hidePopover() {
3389
3764
  this.#clearSelection();
3390
3765
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", false);
@@ -3410,6 +3785,7 @@ class LexicalPromptElement extends HTMLElement {
3410
3785
 
3411
3786
  if (this.#editorContents.containsTextBackUntil(this.trigger)) {
3412
3787
  await this.#showFilteredOptions();
3788
+ this.#positionPopover();
3413
3789
  } else {
3414
3790
  this.#hidePopover();
3415
3791
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.15-beta",
3
+ "version": "0.1.16-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",