@37signals/lexxy 0.9.7-beta → 0.9.9-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.
package/dist/lexxy.esm.js CHANGED
@@ -1,12 +1,12 @@
1
- import { createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
1
+ import { isActiveAndVisible, createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
2
2
  export { highlightCode } from './lexxy_helpers.esm.js';
3
3
  import DOMPurify from 'dompurify';
4
4
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType, $forEachSelectedTextNode, $ensureForwardRangeSelection } from '@lexical/selection';
5
5
  import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, DecoratorNode, $createParagraphNode, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, $createTextNode, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isElementNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, ParagraphNode, $isRootOrShadowRoot, ElementNode, $splitNode, $isParagraphNode, $createLineBreakNode, $isRootNode, $getChildCaretAtIndex, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, CLEAR_HISTORY_COMMAND, $addUpdateTag, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
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';
8
- import { $createAutoLinkNode, $toggleLink, LinkNode, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
9
- import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, $descendantsMatching } from '@lexical/utils';
8
+ import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
9
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, $descendantsMatching, IS_APPLE } from '@lexical/utils';
10
10
  import { registerPlainText } from '@lexical/plain-text';
11
11
  import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
12
12
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
@@ -209,10 +209,6 @@ class NextElementFinder {
209
209
  }
210
210
  }
211
211
 
212
- function isActiveAndVisible(element) {
213
- return element && !element.disabled && element.checkVisibility()
214
- }
215
-
216
212
  var ToolbarIcons = {
217
213
  "bold":
218
214
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
@@ -486,7 +482,8 @@ class LexicalToolbarElement extends HTMLElement {
486
482
  }
487
483
 
488
484
  #handleEditorFocus = () => {
489
- this.#focusableItems[0].tabIndex = 0;
485
+ const firstVisible = this.#focusableItems.find(isActiveAndVisible);
486
+ if (firstVisible) firstVisible.tabIndex = 0;
490
487
  }
491
488
 
492
489
  #handleEditorBlur = () => {
@@ -579,12 +576,6 @@ class LexicalToolbarElement extends HTMLElement {
579
576
  }
580
577
  }
581
578
 
582
- #toolbarIsOverflowing() {
583
- // Safari can report inconsistent clientWidth values on more than 100% window zoom level,
584
- // that was affecting the toolbar overflow calculation. We're adding +1 to get around this issue.
585
- return (this.scrollWidth - this.#overflow.clientWidth) > this.clientWidth + 1
586
- }
587
-
588
579
  #refreshToolbarOverflow = () => {
589
580
  this.#resetToolbarOverflow();
590
581
  this.#compactMenu();
@@ -597,29 +588,46 @@ class LexicalToolbarElement extends HTMLElement {
597
588
  this.#overflowMenu.toggleAttribute("disabled", !isOverflowing);
598
589
  }
599
590
 
591
+ // Separates layout reads from DOM writes to avoid forced reflows during init.
592
+ // Measures every button's right edge in a single read pass, figures out which
593
+ // buttons overflow using math, and then moves them in a single write pass.
594
+ // The previous implementation interleaved `scrollWidth`/`clientWidth` reads with
595
+ // `prepend()` writes inside a loop, forcing one full browser reflow per button.
600
596
  #compactMenu() {
601
- const buttons = this.#buttons.reverse();
602
- let movedToOverflow = false;
597
+ const buttons = this.#buttons;
598
+ if (buttons.length === 0) return
603
599
 
604
- for (const button of buttons) {
605
- if (this.#toolbarIsOverflowing()) {
606
- this.#overflowMenu.prepend(button);
607
- movedToOverflow = true;
608
- } else {
609
- if (movedToOverflow) this.#overflowMenu.prepend(button);
600
+ const availableWidth = this.clientWidth + 1; // +1 for Safari zoom rounding
601
+ const buttonRightEdges = buttons.map(button => button.offsetLeft + button.offsetWidth);
602
+
603
+ let firstOverflowing = -1;
604
+ for (let i = 0; i < buttons.length; i++) {
605
+ if (buttonRightEdges[i] > availableWidth) {
606
+ firstOverflowing = i;
610
607
  break
611
608
  }
612
609
  }
610
+
611
+ if (firstOverflowing === -1) return
612
+
613
+ // Move one extra button to reserve space for the overflow control, which is
614
+ // `display: none` until we show it — matching the previous implementation's
615
+ // "move one more after it stops overflowing" behaviour.
616
+ const overflowIndex = Math.max(0, firstOverflowing - 1);
617
+ const overflowButtons = buttons.slice(overflowIndex).reverse();
618
+ for (const button of overflowButtons) {
619
+ this.#overflowMenu.prepend(button);
620
+ }
613
621
  }
614
622
 
615
623
  #resetToolbarOverflow() {
616
624
  const items = Array.from(this.#overflowMenu.children);
617
625
  items.sort((a, b) => this.#itemPosition(b) - this.#itemPosition(a));
618
626
 
619
- items.forEach((item) => {
627
+ for (const item of items) {
620
628
  const nextItem = this.querySelector(`[data-position="${this.#itemPosition(item) + 1}"]`) ?? this.#overflow;
621
629
  this.insertBefore(item, nextItem);
622
- });
630
+ }
623
631
  }
624
632
 
625
633
  #itemPosition(item) {
@@ -1525,26 +1533,38 @@ class StyleCanonicalizer {
1525
1533
 
1526
1534
  #resolveCannonicalValue(value) {
1527
1535
  let index = this.#computedAllowedValues.indexOf(value);
1528
- index ||= this.#computedAllowedValues.indexOf(getComputedStyleForProperty(this._property, value));
1536
+ if (index === -1) {
1537
+ index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
1538
+ }
1529
1539
  return index === -1 ? null : this._allowedValues[index]
1530
1540
  }
1531
1541
 
1532
1542
  get #computedAllowedValues() {
1533
- return this._computedAllowedValues ||= this._allowedValues.map(
1534
- value => getComputedStyleForProperty(this._property, value)
1535
- )
1543
+ return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
1536
1544
  }
1537
1545
  }
1538
1546
 
1539
- function getComputedStyleForProperty(property, value) {
1540
- const style = `${property}: ${value};`;
1547
+ // Separates DOM writes from layout reads to avoid forced reflows. All resolver
1548
+ // elements are built inside a fragment, attached once, then read in a single pass.
1549
+ // Reading `getComputedStyle` after a write forces the browser to recompute layout,
1550
+ // so interleaving writes and reads inside a loop turns one reflow into N.
1551
+ function computeStyleValues(property, values) {
1552
+ const fragment = document.createDocumentFragment();
1553
+
1554
+ const elements = values.map(value => {
1555
+ const element = createElement("span", { style: `display: none; ${property}: ${value};` });
1556
+ fragment.appendChild(element);
1557
+ return element
1558
+ });
1559
+
1560
+ document.body.appendChild(fragment);
1541
1561
 
1542
- // the element has to be attached to the DOM have computed styles
1543
- const element = document.body.appendChild(createElement("span", { style: "display: none;" + style }));
1544
- const computedStyle = window.getComputedStyle(element).getPropertyValue(property);
1545
- element.remove();
1562
+ const computed = elements.map(element =>
1563
+ window.getComputedStyle(element).getPropertyValue(property)
1564
+ );
1546
1565
 
1547
- return computedStyle
1566
+ elements.forEach(element => element.remove());
1567
+ return computed
1548
1568
  }
1549
1569
 
1550
1570
  class LexxyExtension {
@@ -2162,7 +2182,9 @@ class CommandDispatcher {
2162
2182
  const selection = $getSelection();
2163
2183
  if (!$isRangeSelection(selection)) return
2164
2184
 
2165
- if (selection.isCollapsed()) {
2185
+ const anchorNode = selection.anchor.getNode();
2186
+
2187
+ if (selection.isCollapsed() && !$getNearestNodeOfType(anchorNode, LinkNode)) {
2166
2188
  const autoLinkNode = $createAutoLinkNode(url);
2167
2189
  const textNode = $createTextNode(url);
2168
2190
  autoLinkNode.append(textNode);
@@ -2542,14 +2564,24 @@ function normalizeFilteredText(string) {
2542
2564
  .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
2543
2565
  }
2544
2566
 
2545
- function filterMatches(text, potentialMatch) {
2546
- return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))
2567
+ function filterMatchPosition(text, potentialMatch) {
2568
+ const normalizedText = normalizeFilteredText(text);
2569
+ const normalizedMatch = normalizeFilteredText(potentialMatch);
2570
+
2571
+ if (!normalizedMatch) return 0
2572
+
2573
+ const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
2574
+ return match ? match.index : -1
2547
2575
  }
2548
2576
 
2549
2577
  function upcaseFirst(string) {
2550
2578
  return string.charAt(0).toUpperCase() + string.slice(1)
2551
2579
  }
2552
2580
 
2581
+ function escapeForRegExp(string) {
2582
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2583
+ }
2584
+
2553
2585
  // Parses a value that may arrive as a boolean or as a string (e.g. from DOM
2554
2586
  // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
2555
2587
  function parseBoolean(value) {
@@ -6468,6 +6500,10 @@ class FormatEscapeExtension extends LexxyExtension {
6468
6500
  return this.editorElement.supportsRichText
6469
6501
  }
6470
6502
 
6503
+ get allowedElements() {
6504
+ return [ { tag: "li", attributes: [ "value" ] } ]
6505
+ }
6506
+
6471
6507
  get lexicalExtension() {
6472
6508
  return defineExtension({
6473
6509
  name: "lexxy/format-escape",
@@ -6541,6 +6577,65 @@ function $handleArrowDownInCodeBlock(event) {
6541
6577
  return false
6542
6578
  }
6543
6579
 
6580
+ class LinkOpenerExtension extends LexxyExtension {
6581
+ get enabled() {
6582
+ return this.editorElement.supportsRichText
6583
+ }
6584
+
6585
+ get lexicalExtension() {
6586
+ return defineExtension({
6587
+ name: "lexxy/link-opener",
6588
+ register: () => {
6589
+ return mergeRegister(
6590
+ registerEventListener(window, "keydown", this.#update.bind(this)),
6591
+ registerEventListener(window, "keyup", this.#update.bind(this)),
6592
+ registerEventListener(window, "blur", this.#disable.bind(this)),
6593
+ registerEventListener(window, "focus", this.#refresh.bind(this))
6594
+ )
6595
+ }
6596
+ })
6597
+ }
6598
+
6599
+ #update(event) {
6600
+ if (this.#isModified(event)) {
6601
+ this.#enable();
6602
+ } else {
6603
+ this.#disable();
6604
+ }
6605
+ }
6606
+
6607
+ #refresh() {
6608
+ // Chrome dispatches events without modifier keys *for a while* after changing tabs
6609
+ setTimeout(() => {
6610
+ window.addEventListener("mousemove", this.#update.bind(this), { once: true });
6611
+ }, 200);
6612
+ }
6613
+
6614
+ #isModified(event) {
6615
+ return IS_APPLE ? event.metaKey : event.ctrlKey
6616
+ }
6617
+
6618
+ #enable() {
6619
+ for (const anchor of this.#anchors) {
6620
+ anchor.setAttribute("contenteditable", "false");
6621
+ anchor.setAttribute("target", "_blank");
6622
+ anchor.setAttribute("rel", "noopener noreferrer");
6623
+ }
6624
+ }
6625
+
6626
+ #disable() {
6627
+ for (const anchor of this.#anchors) {
6628
+ anchor.removeAttribute("contenteditable");
6629
+ anchor.removeAttribute("target");
6630
+ anchor.removeAttribute("rel");
6631
+ }
6632
+ }
6633
+
6634
+ get #anchors() {
6635
+ return this.editorElement.editorContentElement?.querySelectorAll("a") ?? []
6636
+ }
6637
+ }
6638
+
6544
6639
  class LexicalEditorElement extends HTMLElement {
6545
6640
  static formAssociated = true
6546
6641
  static debug = false
@@ -6645,7 +6740,8 @@ class LexicalEditorElement extends HTMLElement {
6645
6740
  TrixContentExtension,
6646
6741
  TablesExtension,
6647
6742
  AttachmentsExtension,
6648
- FormatEscapeExtension
6743
+ FormatEscapeExtension,
6744
+ LinkOpenerExtension
6649
6745
  ]
6650
6746
  }
6651
6747
 
@@ -6895,7 +6991,9 @@ class LexicalEditorElement extends HTMLElement {
6895
6991
  }
6896
6992
 
6897
6993
  #handleTurboBeforeCache = (event) => {
6898
- this.#reset();
6994
+ if (!this.closest("[data-turbo-permanent]")) {
6995
+ this.#reset();
6996
+ }
6899
6997
  }
6900
6998
 
6901
6999
  #synchronizeWithChanges() {
@@ -7168,19 +7266,30 @@ class LexicalEditorElement extends HTMLElement {
7168
7266
  ]
7169
7267
  }
7170
7268
 
7269
+ // Builds one resolver element per CSS value inside a hidden container, attaches
7270
+ // the container in a single DOM write, then reads all computed values in one pass
7271
+ // — triggering at most one forced reflow. The previous implementation interleaved
7272
+ // setProperty/getComputedStyle/removeProperty on the same element, forcing a style
7273
+ // recalc on every iteration during editor initialization.
7171
7274
  #resolveColors(property, cssValues) {
7172
- const resolver = document.createElement("span");
7173
- resolver.style.display = "none";
7174
- this.appendChild(resolver);
7175
-
7176
- const resolved = cssValues.map(cssValue => {
7177
- resolver.style.setProperty(property, cssValue);
7178
- const value = window.getComputedStyle(resolver).getPropertyValue(property);
7179
- resolver.style.removeProperty(property);
7180
- return { name: cssValue, value }
7275
+ const container = document.createElement("span");
7276
+ container.style.display = "none";
7277
+
7278
+ const resolvers = cssValues.map(cssValue => {
7279
+ const element = document.createElement("span");
7280
+ element.style.setProperty(property, cssValue);
7281
+ container.appendChild(element);
7282
+ return { element, name: cssValue }
7181
7283
  });
7182
7284
 
7183
- resolver.remove();
7285
+ this.appendChild(container);
7286
+
7287
+ const resolved = resolvers.map(({ element, name }) => ({
7288
+ name,
7289
+ value: window.getComputedStyle(element).getPropertyValue(property)
7290
+ }));
7291
+
7292
+ container.remove();
7184
7293
  return resolved
7185
7294
  }
7186
7295
 
@@ -7370,7 +7479,7 @@ class LinkDropdown extends ToolbarDropdown {
7370
7479
  get #selectedLinkUrl() {
7371
7480
  return this.editor.getEditorState().read(() => {
7372
7481
  const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode);
7373
- return linkNode?.getUrl() ?? null
7482
+ return linkNode?.getURL() ?? ""
7374
7483
  })
7375
7484
  }
7376
7485
  }
@@ -7534,21 +7643,41 @@ class LocalFilterSource extends BaseSource {
7534
7643
  }
7535
7644
 
7536
7645
  #buildListItemsFromPromptItems(promptItems, filter) {
7537
- const listItems = [];
7538
7646
  this.promptItemByListItem = new WeakMap();
7539
7647
 
7540
- for (const promptItem of promptItems) {
7541
- if (listItems.length >= MAX_RENDERED_SUGGESTIONS$1) break
7648
+ if (!filter) {
7649
+ return this.#buildAllListItems(promptItems)
7650
+ }
7542
7651
 
7652
+ const matches = [];
7653
+ for (const promptItem of promptItems) {
7543
7654
  const searchableText = promptItem.getAttribute("search");
7544
-
7545
- if (!filter || filterMatches(searchableText, filter)) {
7546
- const listItem = this.buildListItemElementFor(promptItem);
7547
- this.promptItemByListItem.set(listItem, promptItem);
7548
- listItems.push(listItem);
7655
+ const position = filterMatchPosition(searchableText, filter);
7656
+ if (position >= 0) {
7657
+ matches.push({ promptItem, position });
7549
7658
  }
7550
7659
  }
7551
7660
 
7661
+ matches.sort((a, b) => a.position - b.position);
7662
+
7663
+ const listItems = [];
7664
+ for (const { promptItem } of matches) {
7665
+ if (listItems.length >= MAX_RENDERED_SUGGESTIONS$1) break
7666
+ const listItem = this.buildListItemElementFor(promptItem);
7667
+ this.promptItemByListItem.set(listItem, promptItem);
7668
+ listItems.push(listItem);
7669
+ }
7670
+ return listItems
7671
+ }
7672
+
7673
+ #buildAllListItems(promptItems) {
7674
+ const listItems = [];
7675
+ for (const promptItem of promptItems) {
7676
+ if (listItems.length >= MAX_RENDERED_SUGGESTIONS$1) break
7677
+ const listItem = this.buildListItemElementFor(promptItem);
7678
+ this.promptItemByListItem.set(listItem, promptItem);
7679
+ listItems.push(listItem);
7680
+ }
7552
7681
  return listItems
7553
7682
  }
7554
7683
  }
@@ -82,6 +82,10 @@ function extractPlainTextFromHtml(innerHtml = "") {
82
82
  return parseHtml(innerHtml).body.textContent.trim()
83
83
  }
84
84
 
85
+ function isActiveAndVisible(element) {
86
+ return element && !element.disabled && element.checkVisibility()
87
+ }
88
+
85
89
  function highlightCode() {
86
90
  const elements = document.querySelectorAll("pre[data-language]");
87
91
 
@@ -216,4 +220,4 @@ function collectTextNodes(root) {
216
220
  return nodes
217
221
  }
218
222
 
219
- export { addBlockSpacing, createAttachmentFigure, createElement, dispatch, extractPlainTextFromHtml, generateDomId, highlightCode, isPreviewableImage, parseHtml };
223
+ export { addBlockSpacing, createAttachmentFigure, createElement, dispatch, extractPlainTextFromHtml, generateDomId, highlightCode, isActiveAndVisible, isPreviewableImage, parseHtml };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.7-beta",
3
+ "version": "0.9.9-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",