@37signals/lexxy 0.9.9-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
@@ -1464,6 +1464,35 @@ function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1464
1464
  && previousNode instanceof CustomActionTextAttachmentNode
1465
1465
  }
1466
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
+
1467
1496
  function isSelectionHighlighted(selection) {
1468
1497
  if (!$isRangeSelection(selection)) return false
1469
1498
 
@@ -1544,10 +1573,11 @@ class StyleCanonicalizer {
1544
1573
  }
1545
1574
  }
1546
1575
 
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.
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.
1551
1581
  function computeStyleValues(property, values) {
1552
1582
  const fragment = document.createDocumentFragment();
1553
1583
 
@@ -1557,7 +1587,7 @@ function computeStyleValues(property, values) {
1557
1587
  return element
1558
1588
  });
1559
1589
 
1560
- document.body.appendChild(fragment);
1590
+ styleResolverRoot().appendChild(fragment);
1561
1591
 
1562
1592
  const computed = elements.map(element =>
1563
1593
  window.getComputedStyle(element).getPropertyValue(property)
@@ -6644,6 +6674,7 @@ class LexicalEditorElement extends HTMLElement {
6644
6674
  static observedAttributes = [ "connected", "required" ]
6645
6675
 
6646
6676
  #initialValue = ""
6677
+ #initialValueLoaded = false
6647
6678
  #validationTextArea = document.createElement("textarea")
6648
6679
  #editorInitializedRafId = null
6649
6680
  #listeners = new ListenerBin()
@@ -6821,9 +6852,19 @@ class LexicalEditorElement extends HTMLElement {
6821
6852
  }
6822
6853
 
6823
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
+
6824
6861
  this.editor.focus(() => this.#onFocus());
6825
6862
  }
6826
6863
 
6864
+ get #isContentFocused() {
6865
+ return !!this.editorContentElement && this.editorContentElement.contains(document.activeElement)
6866
+ }
6867
+
6827
6868
  get value() {
6828
6869
  if (!this.cachedValue) {
6829
6870
  this.editor?.getEditorState().read(() => {
@@ -6835,6 +6876,8 @@ class LexicalEditorElement extends HTMLElement {
6835
6876
  }
6836
6877
 
6837
6878
  set value(html) {
6879
+ const wasEmpty = !this.#initialValueLoaded;
6880
+
6838
6881
  this.editor.update(() => {
6839
6882
  $addUpdateTag(SKIP_DOM_SELECTION_TAG);
6840
6883
  const root = $getRoot();
@@ -6844,11 +6887,17 @@ class LexicalEditorElement extends HTMLElement {
6844
6887
 
6845
6888
  this.#toggleEmptyStatus();
6846
6889
 
6847
- // The first time you set the value, when the editor is empty, it seems to leave Lexical
6848
- // in an inconsistent state until, at least, you focus. You can type but adding attachments
6849
- // fails because no root node detected. This is a workaround to deal with the issue.
6850
- 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
+ }
6851
6898
  });
6899
+
6900
+ this.#initialValueLoaded = true;
6852
6901
  }
6853
6902
 
6854
6903
  #parseHtmlIntoLexicalNodes(html) {
@@ -7282,7 +7331,7 @@ class LexicalEditorElement extends HTMLElement {
7282
7331
  return { element, name: cssValue }
7283
7332
  });
7284
7333
 
7285
- this.appendChild(container);
7334
+ styleResolverRoot().appendChild(container);
7286
7335
 
7287
7336
  const resolved = resolvers.map(({ element, name }) => ({
7288
7337
  name,
@@ -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.9-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",