@37signals/lexxy 0.9.8-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);
@@ -6478,6 +6500,10 @@ class FormatEscapeExtension extends LexxyExtension {
6478
6500
  return this.editorElement.supportsRichText
6479
6501
  }
6480
6502
 
6503
+ get allowedElements() {
6504
+ return [ { tag: "li", attributes: [ "value" ] } ]
6505
+ }
6506
+
6481
6507
  get lexicalExtension() {
6482
6508
  return defineExtension({
6483
6509
  name: "lexxy/format-escape",
@@ -6551,6 +6577,65 @@ function $handleArrowDownInCodeBlock(event) {
6551
6577
  return false
6552
6578
  }
6553
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
+
6554
6639
  class LexicalEditorElement extends HTMLElement {
6555
6640
  static formAssociated = true
6556
6641
  static debug = false
@@ -6655,7 +6740,8 @@ class LexicalEditorElement extends HTMLElement {
6655
6740
  TrixContentExtension,
6656
6741
  TablesExtension,
6657
6742
  AttachmentsExtension,
6658
- FormatEscapeExtension
6743
+ FormatEscapeExtension,
6744
+ LinkOpenerExtension
6659
6745
  ]
6660
6746
  }
6661
6747
 
@@ -6905,7 +6991,9 @@ class LexicalEditorElement extends HTMLElement {
6905
6991
  }
6906
6992
 
6907
6993
  #handleTurboBeforeCache = (event) => {
6908
- this.#reset();
6994
+ if (!this.closest("[data-turbo-permanent]")) {
6995
+ this.#reset();
6996
+ }
6909
6997
  }
6910
6998
 
6911
6999
  #synchronizeWithChanges() {
@@ -7178,19 +7266,30 @@ class LexicalEditorElement extends HTMLElement {
7178
7266
  ]
7179
7267
  }
7180
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.
7181
7274
  #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 }
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 }
7191
7283
  });
7192
7284
 
7193
- 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();
7194
7293
  return resolved
7195
7294
  }
7196
7295
 
@@ -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.8-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",