@37signals/lexxy 0.9.8-beta → 0.9.9-beta-preview1

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) {
@@ -1456,6 +1464,35 @@ function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1456
1464
  && previousNode instanceof CustomActionTextAttachmentNode
1457
1465
  }
1458
1466
 
1467
+ // Shared, strictly-contained element used to attach ephemeral nodes when we
1468
+ // need to read computed styles (e.g. canonicalizing style values, resolving
1469
+ // CSS custom properties). The container is created once and attached to
1470
+ // `document.body` once; subsequent child mutations happen *inside* the
1471
+ // contained subtree so they do not invalidate style on the rest of the page.
1472
+ //
1473
+ // Without this, `document.body.appendChild(...)` / `element.remove()` calls
1474
+ // forced the browser to re-evaluate every ancestor-dependent selector (`:has()`,
1475
+ // descendant combinators, universal sibling rules) across the document on each
1476
+ // invocation — a 13,000+ element style recalc per call on a typical Basecamp
1477
+ // page.
1478
+
1479
+ let resolverRoot = null;
1480
+
1481
+ function styleResolverRoot() {
1482
+ if (resolverRoot && resolverRoot.isConnected) return resolverRoot
1483
+
1484
+ resolverRoot = document.createElement("div");
1485
+ resolverRoot.setAttribute("aria-hidden", "true");
1486
+ resolverRoot.setAttribute("data-lexxy-style-resolver", "");
1487
+ // `contain: strict` (size, layout, paint, style) isolates everything.
1488
+ // The root itself paints nothing (visibility hidden), has zero
1489
+ // geometric impact (position fixed, intrinsic size via contain), and
1490
+ // never leaks style invalidation to its ancestors.
1491
+ resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;";
1492
+ document.body.appendChild(resolverRoot);
1493
+ return resolverRoot
1494
+ }
1495
+
1459
1496
  function isSelectionHighlighted(selection) {
1460
1497
  if (!$isRangeSelection(selection)) return false
1461
1498
 
@@ -1525,26 +1562,39 @@ class StyleCanonicalizer {
1525
1562
 
1526
1563
  #resolveCannonicalValue(value) {
1527
1564
  let index = this.#computedAllowedValues.indexOf(value);
1528
- index ||= this.#computedAllowedValues.indexOf(getComputedStyleForProperty(this._property, value));
1565
+ if (index === -1) {
1566
+ index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
1567
+ }
1529
1568
  return index === -1 ? null : this._allowedValues[index]
1530
1569
  }
1531
1570
 
1532
1571
  get #computedAllowedValues() {
1533
- return this._computedAllowedValues ||= this._allowedValues.map(
1534
- value => getComputedStyleForProperty(this._property, value)
1535
- )
1572
+ return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
1536
1573
  }
1537
1574
  }
1538
1575
 
1539
- function getComputedStyleForProperty(property, value) {
1540
- const style = `${property}: ${value};`;
1576
+ // Separates DOM writes from layout reads to avoid forced reflows, and attaches
1577
+ // resolver elements to a strictly-contained root (outside the normal document
1578
+ // flow) so neither the attach nor the detach invalidate styles on the rest of
1579
+ // the page. Without containment, appending to `document.body` triggered a
1580
+ // page-wide style recalc on every canonicalization pass.
1581
+ function computeStyleValues(property, values) {
1582
+ const fragment = document.createDocumentFragment();
1583
+
1584
+ const elements = values.map(value => {
1585
+ const element = createElement("span", { style: `display: none; ${property}: ${value};` });
1586
+ fragment.appendChild(element);
1587
+ return element
1588
+ });
1589
+
1590
+ styleResolverRoot().appendChild(fragment);
1541
1591
 
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();
1592
+ const computed = elements.map(element =>
1593
+ window.getComputedStyle(element).getPropertyValue(property)
1594
+ );
1546
1595
 
1547
- return computedStyle
1596
+ elements.forEach(element => element.remove());
1597
+ return computed
1548
1598
  }
1549
1599
 
1550
1600
  class LexxyExtension {
@@ -2162,7 +2212,9 @@ class CommandDispatcher {
2162
2212
  const selection = $getSelection();
2163
2213
  if (!$isRangeSelection(selection)) return
2164
2214
 
2165
- if (selection.isCollapsed()) {
2215
+ const anchorNode = selection.anchor.getNode();
2216
+
2217
+ if (selection.isCollapsed() && !$getNearestNodeOfType(anchorNode, LinkNode)) {
2166
2218
  const autoLinkNode = $createAutoLinkNode(url);
2167
2219
  const textNode = $createTextNode(url);
2168
2220
  autoLinkNode.append(textNode);
@@ -6478,6 +6530,10 @@ class FormatEscapeExtension extends LexxyExtension {
6478
6530
  return this.editorElement.supportsRichText
6479
6531
  }
6480
6532
 
6533
+ get allowedElements() {
6534
+ return [ { tag: "li", attributes: [ "value" ] } ]
6535
+ }
6536
+
6481
6537
  get lexicalExtension() {
6482
6538
  return defineExtension({
6483
6539
  name: "lexxy/format-escape",
@@ -6551,6 +6607,65 @@ function $handleArrowDownInCodeBlock(event) {
6551
6607
  return false
6552
6608
  }
6553
6609
 
6610
+ class LinkOpenerExtension extends LexxyExtension {
6611
+ get enabled() {
6612
+ return this.editorElement.supportsRichText
6613
+ }
6614
+
6615
+ get lexicalExtension() {
6616
+ return defineExtension({
6617
+ name: "lexxy/link-opener",
6618
+ register: () => {
6619
+ return mergeRegister(
6620
+ registerEventListener(window, "keydown", this.#update.bind(this)),
6621
+ registerEventListener(window, "keyup", this.#update.bind(this)),
6622
+ registerEventListener(window, "blur", this.#disable.bind(this)),
6623
+ registerEventListener(window, "focus", this.#refresh.bind(this))
6624
+ )
6625
+ }
6626
+ })
6627
+ }
6628
+
6629
+ #update(event) {
6630
+ if (this.#isModified(event)) {
6631
+ this.#enable();
6632
+ } else {
6633
+ this.#disable();
6634
+ }
6635
+ }
6636
+
6637
+ #refresh() {
6638
+ // Chrome dispatches events without modifier keys *for a while* after changing tabs
6639
+ setTimeout(() => {
6640
+ window.addEventListener("mousemove", this.#update.bind(this), { once: true });
6641
+ }, 200);
6642
+ }
6643
+
6644
+ #isModified(event) {
6645
+ return IS_APPLE ? event.metaKey : event.ctrlKey
6646
+ }
6647
+
6648
+ #enable() {
6649
+ for (const anchor of this.#anchors) {
6650
+ anchor.setAttribute("contenteditable", "false");
6651
+ anchor.setAttribute("target", "_blank");
6652
+ anchor.setAttribute("rel", "noopener noreferrer");
6653
+ }
6654
+ }
6655
+
6656
+ #disable() {
6657
+ for (const anchor of this.#anchors) {
6658
+ anchor.removeAttribute("contenteditable");
6659
+ anchor.removeAttribute("target");
6660
+ anchor.removeAttribute("rel");
6661
+ }
6662
+ }
6663
+
6664
+ get #anchors() {
6665
+ return this.editorElement.editorContentElement?.querySelectorAll("a") ?? []
6666
+ }
6667
+ }
6668
+
6554
6669
  class LexicalEditorElement extends HTMLElement {
6555
6670
  static formAssociated = true
6556
6671
  static debug = false
@@ -6559,6 +6674,7 @@ class LexicalEditorElement extends HTMLElement {
6559
6674
  static observedAttributes = [ "connected", "required" ]
6560
6675
 
6561
6676
  #initialValue = ""
6677
+ #initialValueLoaded = false
6562
6678
  #validationTextArea = document.createElement("textarea")
6563
6679
  #editorInitializedRafId = null
6564
6680
  #listeners = new ListenerBin()
@@ -6655,7 +6771,8 @@ class LexicalEditorElement extends HTMLElement {
6655
6771
  TrixContentExtension,
6656
6772
  TablesExtension,
6657
6773
  AttachmentsExtension,
6658
- FormatEscapeExtension
6774
+ FormatEscapeExtension,
6775
+ LinkOpenerExtension
6659
6776
  ]
6660
6777
  }
6661
6778
 
@@ -6735,9 +6852,19 @@ class LexicalEditorElement extends HTMLElement {
6735
6852
  }
6736
6853
 
6737
6854
  focus() {
6855
+ // `editor.focus()` commits a reconciler update to position the cursor.
6856
+ // Skip if the contenteditable already owns focus — the update would be a
6857
+ // no-op but still triggers a full style/layout pass on pages with large
6858
+ // DOMs.
6859
+ if (this.#isContentFocused) return
6860
+
6738
6861
  this.editor.focus(() => this.#onFocus());
6739
6862
  }
6740
6863
 
6864
+ get #isContentFocused() {
6865
+ return !!this.editorContentElement && this.editorContentElement.contains(document.activeElement)
6866
+ }
6867
+
6741
6868
  get value() {
6742
6869
  if (!this.cachedValue) {
6743
6870
  this.editor?.getEditorState().read(() => {
@@ -6749,6 +6876,8 @@ class LexicalEditorElement extends HTMLElement {
6749
6876
  }
6750
6877
 
6751
6878
  set value(html) {
6879
+ const wasEmpty = !this.#initialValueLoaded;
6880
+
6752
6881
  this.editor.update(() => {
6753
6882
  $addUpdateTag(SKIP_DOM_SELECTION_TAG);
6754
6883
  const root = $getRoot();
@@ -6758,11 +6887,17 @@ class LexicalEditorElement extends HTMLElement {
6758
6887
 
6759
6888
  this.#toggleEmptyStatus();
6760
6889
 
6761
- // The first time you set the value, when the editor is empty, it seems to leave Lexical
6762
- // in an inconsistent state until, at least, you focus. You can type but adding attachments
6763
- // fails because no root node detected. This is a workaround to deal with the issue.
6764
- requestAnimationFrame(() => this.editor?.update(() => { }));
6890
+ // The first time you set the value on an empty editor, Lexical can be
6891
+ // left in an inconsistent state until the next update (adding attachments
6892
+ // fails because no root node is detected). A no-op update works around
6893
+ // it. Only fire on the first load — subsequent set value calls don't hit
6894
+ // the inconsistent state and the extra reconciler cycle is pure overhead.
6895
+ if (wasEmpty) {
6896
+ requestAnimationFrame(() => this.editor?.update(() => { }));
6897
+ }
6765
6898
  });
6899
+
6900
+ this.#initialValueLoaded = true;
6766
6901
  }
6767
6902
 
6768
6903
  #parseHtmlIntoLexicalNodes(html) {
@@ -6905,7 +7040,9 @@ class LexicalEditorElement extends HTMLElement {
6905
7040
  }
6906
7041
 
6907
7042
  #handleTurboBeforeCache = (event) => {
6908
- this.#reset();
7043
+ if (!this.closest("[data-turbo-permanent]")) {
7044
+ this.#reset();
7045
+ }
6909
7046
  }
6910
7047
 
6911
7048
  #synchronizeWithChanges() {
@@ -7178,19 +7315,30 @@ class LexicalEditorElement extends HTMLElement {
7178
7315
  ]
7179
7316
  }
7180
7317
 
7318
+ // Builds one resolver element per CSS value inside a hidden container, attaches
7319
+ // the container in a single DOM write, then reads all computed values in one pass
7320
+ // — triggering at most one forced reflow. The previous implementation interleaved
7321
+ // setProperty/getComputedStyle/removeProperty on the same element, forcing a style
7322
+ // recalc on every iteration during editor initialization.
7181
7323
  #resolveColors(property, cssValues) {
7182
- const resolver = document.createElement("span");
7183
- resolver.style.display = "none";
7184
- this.appendChild(resolver);
7185
-
7186
- const resolved = cssValues.map(cssValue => {
7187
- resolver.style.setProperty(property, cssValue);
7188
- const value = window.getComputedStyle(resolver).getPropertyValue(property);
7189
- resolver.style.removeProperty(property);
7190
- return { name: cssValue, value }
7324
+ const container = document.createElement("span");
7325
+ container.style.display = "none";
7326
+
7327
+ const resolvers = cssValues.map(cssValue => {
7328
+ const element = document.createElement("span");
7329
+ element.style.setProperty(property, cssValue);
7330
+ container.appendChild(element);
7331
+ return { element, name: cssValue }
7191
7332
  });
7192
7333
 
7193
- resolver.remove();
7334
+ styleResolverRoot().appendChild(container);
7335
+
7336
+ const resolved = resolvers.map(({ element, name }) => ({
7337
+ name,
7338
+ value: window.getComputedStyle(element).getPropertyValue(property)
7339
+ }));
7340
+
7341
+ container.remove();
7194
7342
  return resolved
7195
7343
  }
7196
7344
 
@@ -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 };
@@ -399,6 +399,13 @@
399
399
  min-block-size: var(--lexxy-editor-rows);
400
400
  outline: 0;
401
401
  padding: var(--lexxy-editor-padding);
402
+
403
+ /* Isolate the contenteditable root's layout and style. Lexical's reconciler
404
+ commits mutations inside this element (nodes appended, text inserted,
405
+ class flipped) on every update; containment keeps those mutations from
406
+ invalidating ancestor-dependent selectors and sibling layout elsewhere
407
+ in the editor. */
408
+ contain: layout style;
402
409
  }
403
410
 
404
411
  :where(.lexxy-editor--drag-over) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.8-beta",
3
+ "version": "0.9.9-beta-preview1",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",