@37signals/lexxy 0.9.2-beta → 0.9.3-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 +389 -37
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -10,10 +10,11 @@ import 'prismjs/components/prism-json';
10
10
  import 'prismjs/components/prism-diff';
11
11
  import DOMPurify from 'dompurify';
12
12
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType } from '@lexical/selection';
13
- import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, DecoratorNode, $createTextNode, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isElementNode, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createParagraphNode, 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, mergeRegister as mergeRegister$1, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isRootOrShadowRoot, ElementNode, $splitNode, $isParagraphNode, $createLineBreakNode, $isRootNode, ParagraphNode, 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';
13
+ import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, DecoratorNode, $createTextNode, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isElementNode, $createParagraphNode, 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, mergeRegister as mergeRegister$1, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isRootOrShadowRoot, ElementNode, $splitNode, $isParagraphNode, $createLineBreakNode, $isRootNode, ParagraphNode, 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';
14
14
  import { buildEditorFromExtensions } from '@lexical/extension';
15
15
  import { ListNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListItemNode, $getListDepth, $isListItemNode, $isListNode, registerList } from '@lexical/list';
16
16
  import { $createAutoLinkNode, $toggleLink, LinkNode, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
17
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $descendantsMatching } from '@lexical/utils';
17
18
  import { registerPlainText } from '@lexical/plain-text';
18
19
  import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
19
20
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
@@ -23,7 +24,6 @@ import { createEmptyHistoryState, registerHistory } from '@lexical/history';
23
24
  import { createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
24
25
  export { highlightCode as highlightAll, highlightCode } from './lexxy_helpers.esm.js';
25
26
  import { INSERT_TABLE_COMMAND, $getTableCellNodeFromLexicalNode, TableCellNode, TableNode, TableRowNode, setScrollableTablesActive, registerTablePlugin, registerTableSelectionObserver, TableCellHeaderStates, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $findCellNode, $getElementForTableNode } from '@lexical/table';
26
- import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $descendantsMatching } from '@lexical/utils';
27
27
  import { marked } from 'marked';
28
28
  import { $insertDataTransferForRichText } from '@lexical/clipboard';
29
29
 
@@ -1409,6 +1409,24 @@ function isSelectionHighlighted(selection) {
1409
1409
  }
1410
1410
  }
1411
1411
 
1412
+ function getHighlightStyles(selection) {
1413
+ if (!$isRangeSelection(selection)) return null
1414
+
1415
+ let styles = getStyleObjectFromCSS(selection.style);
1416
+ if (!styles.color && !styles["background-color"]) {
1417
+ const anchorNode = selection.anchor.getNode();
1418
+ if ($isTextNode(anchorNode)) {
1419
+ styles = getStyleObjectFromCSS(anchorNode.getStyle());
1420
+ }
1421
+ }
1422
+
1423
+ const color = styles.color || null;
1424
+ const backgroundColor = styles["background-color"] || null;
1425
+ if (!color && !backgroundColor) return null
1426
+
1427
+ return { color, backgroundColor }
1428
+ }
1429
+
1412
1430
  function hasHighlightStyles(cssOrStyles) {
1413
1431
  const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
1414
1432
  return !!(styles.color || styles["background-color"])
@@ -1971,6 +1989,7 @@ const COMMANDS = [
1971
1989
  "insertOrderedList",
1972
1990
  "insertQuoteBlock",
1973
1991
  "insertCodeBlock",
1992
+ "setCodeLanguage",
1974
1993
  "insertHorizontalDivider",
1975
1994
  "uploadImage",
1976
1995
  "uploadFile",
@@ -2046,7 +2065,14 @@ class CommandDispatcher {
2046
2065
  }
2047
2066
 
2048
2067
  dispatchUnlink() {
2049
- this.#toggleLink(null);
2068
+ this.editor.update(() => {
2069
+ // Let adapters signal whether unlink should target a frozen link key.
2070
+ if (this.editorElement.adapter.unlinkFrozenNode?.()) {
2071
+ return
2072
+ }
2073
+
2074
+ $toggleLink(null);
2075
+ });
2050
2076
  }
2051
2077
 
2052
2078
  dispatchInsertUnorderedList() {
@@ -2137,6 +2163,17 @@ class CommandDispatcher {
2137
2163
  }
2138
2164
  }
2139
2165
 
2166
+ dispatchSetCodeLanguage(language) {
2167
+ this.editor.update(() => {
2168
+ if (!this.selection.isInsideCodeBlock) return
2169
+
2170
+ const codeNode = this.selection.nearestNodeOfType(CodeNode);
2171
+ if (!codeNode) return
2172
+
2173
+ codeNode.setLanguage(language);
2174
+ });
2175
+ }
2176
+
2140
2177
  dispatchInsertHorizontalDivider() {
2141
2178
  this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
2142
2179
  this.editor.focus();
@@ -2335,16 +2372,6 @@ class CommandDispatcher {
2335
2372
  return $isRangeSelection(selection) && selection.isCollapsed()
2336
2373
  }
2337
2374
 
2338
- // Not using TOGGLE_LINK_COMMAND because it's not handled unless you use React/LinkPlugin
2339
- #toggleLink(url) {
2340
- this.editor.update(() => {
2341
- if (url === null) {
2342
- $toggleLink(null);
2343
- } else {
2344
- $toggleLink(url);
2345
- }
2346
- });
2347
- }
2348
2375
  }
2349
2376
 
2350
2377
  function capitalize(str) {
@@ -2571,8 +2598,8 @@ class ActionTextAttachmentNode extends DecoratorNode {
2571
2598
  return null
2572
2599
  }
2573
2600
 
2574
- createAttachmentFigure() {
2575
- const figure = createAttachmentFigure(this.contentType, this.isPreviewableAttachment, this.fileName);
2601
+ createAttachmentFigure(previewable = this.isPreviewableAttachment) {
2602
+ const figure = createAttachmentFigure(this.contentType, previewable, this.fileName);
2576
2603
  figure.draggable = true;
2577
2604
  figure.dataset.lexicalNodeKey = this.__key;
2578
2605
 
@@ -3657,9 +3684,12 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3657
3684
  // node is reloaded from saved state such as from history.
3658
3685
  this.#startUploadIfNeeded();
3659
3686
 
3660
- const figure = this.createAttachmentFigure();
3687
+ // Bridge-managed uploads (uploadUrl is null) don't have file data to show
3688
+ // an image preview, so always show the file icon during upload.
3689
+ const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
3690
+ const figure = this.createAttachmentFigure(canPreviewFile);
3661
3691
 
3662
- if (this.isPreviewableAttachment) {
3692
+ if (canPreviewFile) {
3663
3693
  const img = figure.appendChild(this.#createDOMForImage());
3664
3694
 
3665
3695
  // load file locally to set dimensions and prevent vertical shifting
@@ -3759,6 +3789,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3759
3789
 
3760
3790
  async #startUploadIfNeeded() {
3761
3791
  if (this.#uploadStarted) return
3792
+ if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
3762
3793
 
3763
3794
  this.#setUploadStarted();
3764
3795
 
@@ -3775,7 +3806,9 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3775
3806
  this.#handleUploadError(error);
3776
3807
  } else {
3777
3808
  this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
3778
- this.#showUploadedAttachment(blob);
3809
+ this.editor.update(() => {
3810
+ this.showUploadedAttachment(blob);
3811
+ }, { tag: this.#backgroundUpdateTags });
3779
3812
  }
3780
3813
  });
3781
3814
  }
@@ -3819,17 +3852,15 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3819
3852
  }, { tag: this.#backgroundUpdateTags });
3820
3853
  }
3821
3854
 
3822
- #showUploadedAttachment(blob) {
3823
- const editorHasFocus = this.#editorHasFocus;
3855
+ showUploadedAttachment(blob) {
3856
+ const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
3857
+ this.replace(replacementNode);
3824
3858
 
3825
- this.editor.update(() => {
3826
- const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
3827
- this.replace(replacementNode);
3859
+ if ($isRootOrShadowRoot(replacementNode.getParent())) {
3860
+ replacementNode.selectNext();
3861
+ }
3828
3862
 
3829
- if (editorHasFocus && $isRootOrShadowRoot(replacementNode.getParent())) {
3830
- replacementNode.selectNext();
3831
- }
3832
- }, { tag: this.#backgroundUpdateTags });
3863
+ return replacementNode.getKey()
3833
3864
  }
3834
3865
 
3835
3866
  // Upload lifecycle methods (progress, completion, errors) run asynchronously and may
@@ -4431,6 +4462,53 @@ class Contents {
4431
4462
  });
4432
4463
  }
4433
4464
 
4465
+ insertPendingAttachment(file) {
4466
+ if (!this.editorElement.supportsAttachments) return null
4467
+
4468
+ let nodeKey = null;
4469
+ this.editor.update(() => {
4470
+ const uploadNode = new ActionTextAttachmentUploadNode({
4471
+ file,
4472
+ uploadUrl: null,
4473
+ blobUrlTemplate: this.editorElement.blobUrlTemplate,
4474
+ editor: this.editor
4475
+ });
4476
+ this.insertAtCursor(uploadNode);
4477
+ nodeKey = uploadNode.getKey();
4478
+ }, { tag: HISTORY_MERGE_TAG });
4479
+
4480
+ if (!nodeKey) return null
4481
+
4482
+ const editor = this.editor;
4483
+ return {
4484
+ setAttributes(blob) {
4485
+ editor.update(() => {
4486
+ const node = $getNodeByKey(nodeKey);
4487
+ if (!(node instanceof ActionTextAttachmentUploadNode)) return
4488
+
4489
+ const replacementNodeKey = node.showUploadedAttachment(blob);
4490
+ if (replacementNodeKey) {
4491
+ nodeKey = replacementNodeKey;
4492
+ }
4493
+ }, { tag: HISTORY_MERGE_TAG });
4494
+ },
4495
+ setUploadProgress(progress) {
4496
+ editor.update(() => {
4497
+ const node = $getNodeByKey(nodeKey);
4498
+ if (!(node instanceof ActionTextAttachmentUploadNode)) return
4499
+
4500
+ node.getWritable().progress = progress;
4501
+ }, { tag: HISTORY_MERGE_TAG });
4502
+ },
4503
+ remove() {
4504
+ editor.update(() => {
4505
+ const node = $getNodeByKey(nodeKey);
4506
+ if (node) node.remove();
4507
+ });
4508
+ }
4509
+ }
4510
+ }
4511
+
4434
4512
  replaceNodeWithHTML(nodeKey, html, options = {}) {
4435
4513
  this.editor.update(() => {
4436
4514
  const node = $getNodeByKey(nodeKey);
@@ -4915,6 +4993,18 @@ class Extensions {
4915
4993
  }
4916
4994
  }
4917
4995
 
4996
+ class BrowserAdapter {
4997
+ frozenLinkKey = null
4998
+
4999
+ dispatchAttributesChange(attributes, linkHref, highlight, headingTag) {}
5000
+ dispatchEditorInitialized(detail) {}
5001
+ freeze() {}
5002
+ thaw() {}
5003
+ unlinkFrozenNode() {
5004
+ return false
5005
+ }
5006
+ }
5007
+
4918
5008
  // Custom TextNode exportDOM that avoids redundant bold/italic wrapping.
4919
5009
  //
4920
5010
  // Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags
@@ -5966,6 +6056,7 @@ class LexicalEditorElement extends HTMLElement {
5966
6056
 
5967
6057
  #initialValue = ""
5968
6058
  #validationTextArea = document.createElement("textarea")
6059
+ #editorInitializedRafId = null
5969
6060
  #disposables = []
5970
6061
 
5971
6062
  constructor() {
@@ -5975,7 +6066,7 @@ class LexicalEditorElement extends HTMLElement {
5975
6066
  }
5976
6067
 
5977
6068
  connectedCallback() {
5978
- this.id ??= generateDomId("lexxy-editor");
6069
+ this.id ||= generateDomId("lexxy-editor");
5979
6070
  this.config = new EditorConfiguration(this);
5980
6071
  this.extensions = new Extensions(this);
5981
6072
 
@@ -5989,13 +6080,14 @@ class LexicalEditorElement extends HTMLElement {
5989
6080
  this.#disposables.push(this.selection);
5990
6081
 
5991
6082
  this.clipboard = new Clipboard(this);
6083
+ this.adapter = new BrowserAdapter();
5992
6084
 
5993
6085
  const commandDispatcher = CommandDispatcher.configureFor(this);
5994
6086
  this.#disposables.push(commandDispatcher);
5995
6087
 
5996
6088
  this.#initialize();
5997
6089
 
5998
- requestAnimationFrame(() => dispatch(this, "lexxy:initialize"));
6090
+ this.#scheduleEditorInitializedDispatch();
5999
6091
  this.toggleAttribute("connected", true);
6000
6092
 
6001
6093
  this.#handleAutofocus();
@@ -6004,6 +6096,7 @@ class LexicalEditorElement extends HTMLElement {
6004
6096
  }
6005
6097
 
6006
6098
  disconnectedCallback() {
6099
+ this.#cancelEditorInitializedDispatch();
6007
6100
  this.valueBeforeDisconnect = this.value;
6008
6101
  this.#reset(); // Prevent hangs with Safari when morphing
6009
6102
  }
@@ -6100,6 +6193,32 @@ class LexicalEditorElement extends HTMLElement {
6100
6193
  return this.config.get("richText")
6101
6194
  }
6102
6195
 
6196
+ registerAdapter(adapter) {
6197
+ this.adapter = adapter;
6198
+
6199
+ if (!this.editor) return
6200
+
6201
+ this.#cancelEditorInitializedDispatch();
6202
+ this.#dispatchEditorInitialized();
6203
+ this.#dispatchAttributesChange();
6204
+ }
6205
+
6206
+ freezeSelection() {
6207
+ this.adapter.freeze();
6208
+ }
6209
+
6210
+ thawSelection() {
6211
+ this.adapter.thaw();
6212
+ }
6213
+
6214
+ dispatchAttributesChange() {
6215
+ this.#dispatchAttributesChange();
6216
+ }
6217
+
6218
+ dispatchEditorInitialized() {
6219
+ this.#dispatchEditorInitialized();
6220
+ }
6221
+
6103
6222
  // TODO: Deprecate `single-line` attribute
6104
6223
  get isSingleLineMode() {
6105
6224
  return this.hasAttribute("single-line")
@@ -6224,6 +6343,7 @@ class LexicalEditorElement extends HTMLElement {
6224
6343
  const editorContentElement = createElement("div", {
6225
6344
  classList: "lexxy-editor__content",
6226
6345
  contenteditable: true,
6346
+ autocapitalize: "none",
6227
6347
  role: "textbox",
6228
6348
  "aria-multiline": true,
6229
6349
  "aria-label": this.#labelText,
@@ -6285,6 +6405,7 @@ class LexicalEditorElement extends HTMLElement {
6285
6405
  this.#internalFormValue = this.value;
6286
6406
  this.#toggleEmptyStatus();
6287
6407
  this.#setValidity();
6408
+ this.#dispatchAttributesChange();
6288
6409
  }));
6289
6410
  }
6290
6411
 
@@ -6380,6 +6501,7 @@ class LexicalEditorElement extends HTMLElement {
6380
6501
 
6381
6502
  #handleFocusIn(event) {
6382
6503
  if (this.#elementInEditorOrToolbar(event.target) && !this.currentlyFocused) {
6504
+ this.#dispatchAttributesChange();
6383
6505
  dispatch(this, "lexxy:focus");
6384
6506
  this.currentlyFocused = true;
6385
6507
  }
@@ -6460,7 +6582,112 @@ class LexicalEditorElement extends HTMLElement {
6460
6582
  }
6461
6583
  }
6462
6584
 
6585
+ #dispatchAttributesChange() {
6586
+ let attributes = null;
6587
+ let linkHref = null;
6588
+ let highlight = null;
6589
+ let headingTag = null;
6590
+
6591
+ this.editor.getEditorState().read(() => {
6592
+ const selection = $getSelection();
6593
+ if (!$isRangeSelection(selection)) return
6594
+
6595
+ const format = this.selection.getFormat();
6596
+ if (Object.keys(format).length === 0) return
6597
+
6598
+ const anchorNode = selection.anchor.getNode();
6599
+ const linkNode = $getNearestNodeOfType(anchorNode, LinkNode);
6600
+
6601
+ attributes = {
6602
+ bold: { active: format.isBold, enabled: true },
6603
+ italic: { active: format.isItalic, enabled: true },
6604
+ strikethrough: { active: format.isStrikethrough, enabled: true },
6605
+ code: { active: format.isInCode, enabled: true },
6606
+ highlight: { active: format.isHighlight, enabled: true },
6607
+ link: { active: format.isInLink, enabled: true },
6608
+ quote: { active: format.isInQuote, enabled: true },
6609
+ heading: { active: format.isInHeading, enabled: true },
6610
+ "unordered-list": { active: format.isInList && format.listType === "bullet", enabled: true },
6611
+ "ordered-list": { active: format.isInList && format.listType === "number", enabled: true },
6612
+ undo: { active: false, enabled: this.historyState?.undoStack.length > 0 },
6613
+ redo: { active: false, enabled: this.historyState?.redoStack.length > 0 }
6614
+ };
6615
+
6616
+ linkHref = linkNode ? linkNode.getURL() : null;
6617
+ highlight = format.isHighlight ? getHighlightStyles(selection) : null;
6618
+ headingTag = format.headingTag ?? null;
6619
+ });
6620
+
6621
+ if (attributes) {
6622
+ this.adapter.dispatchAttributesChange(attributes, linkHref, highlight, headingTag);
6623
+ }
6624
+ }
6625
+
6626
+ #dispatchEditorInitialized() {
6627
+ if (!this.adapter) return
6628
+
6629
+ this.adapter.dispatchEditorInitialized({
6630
+ highlightColors: this.#resolvedHighlightColors,
6631
+ headingFormats: this.#supportedHeadingFormats
6632
+ });
6633
+ }
6634
+
6635
+ #scheduleEditorInitializedDispatch() {
6636
+ this.#cancelEditorInitializedDispatch();
6637
+ this.#editorInitializedRafId = requestAnimationFrame(() => {
6638
+ this.#editorInitializedRafId = null;
6639
+ if (!this.isConnected || !this.adapter) return
6640
+
6641
+ dispatch(this, "lexxy:initialize");
6642
+ this.#dispatchEditorInitialized();
6643
+ });
6644
+ }
6645
+
6646
+ #cancelEditorInitializedDispatch() {
6647
+ if (this.#editorInitializedRafId == null) return
6648
+
6649
+ cancelAnimationFrame(this.#editorInitializedRafId);
6650
+ this.#editorInitializedRafId = null;
6651
+ }
6652
+
6653
+ get #resolvedHighlightColors() {
6654
+ const buttons = this.config.get("highlight.buttons");
6655
+ if (!buttons) return null
6656
+
6657
+ const colors = this.#resolveColors("color", buttons.color || []);
6658
+ const backgroundColors = this.#resolveColors("background-color", buttons["background-color"] || []);
6659
+ return { colors, backgroundColors }
6660
+ }
6661
+
6662
+ get #supportedHeadingFormats() {
6663
+ if (!this.supportsRichText) return []
6664
+
6665
+ return [
6666
+ { label: "Normal", command: "setFormatParagraph", tag: null },
6667
+ { label: "Large heading", command: "setFormatHeadingLarge", tag: "h2" },
6668
+ { label: "Medium heading", command: "setFormatHeadingMedium", tag: "h3" },
6669
+ { label: "Small heading", command: "setFormatHeadingSmall", tag: "h4" },
6670
+ ]
6671
+ }
6672
+
6673
+ #resolveColors(property, cssValues) {
6674
+ const resolver = document.createElement("span");
6675
+ resolver.style.display = "none";
6676
+ this.appendChild(resolver);
6677
+
6678
+ const resolved = cssValues.map(cssValue => {
6679
+ resolver.style.setProperty(property, cssValue);
6680
+ const value = window.getComputedStyle(resolver).getPropertyValue(property);
6681
+ resolver.style.removeProperty(property);
6682
+ return { name: cssValue, value }
6683
+ });
6684
+
6685
+ resolver.remove();
6686
+ return resolved
6687
+ }
6688
+
6463
6689
  #reset() {
6690
+ this.#cancelEditorInitializedDispatch();
6464
6691
  this.#dispose();
6465
6692
  this.editorContentElement?.remove();
6466
6693
  this.editorContentElement = null;
@@ -6472,6 +6699,7 @@ class LexicalEditorElement extends HTMLElement {
6472
6699
 
6473
6700
  #dispose() {
6474
6701
  this.#unregisterHandlers();
6702
+ this.adapter = null;
6475
6703
  document.removeEventListener("turbo:before-cache", this.#handleTurboBeforeCache);
6476
6704
 
6477
6705
  while (this.#disposables.length) {
@@ -7377,10 +7605,13 @@ class LexicalPromptElement extends HTMLElement {
7377
7605
  }
7378
7606
 
7379
7607
  class CodeLanguagePicker extends HTMLElement {
7608
+ #abortController = null
7609
+
7380
7610
  connectedCallback() {
7381
7611
  this.editorElement = this.closest("lexxy-editor");
7382
7612
  this.editor = this.editorElement.editor;
7383
7613
  this.classList.add("lexxy-floating-controls");
7614
+ this.#abortController = new AbortController();
7384
7615
 
7385
7616
  this.#attachLanguagePicker();
7386
7617
  this.#hide();
@@ -7392,13 +7623,27 @@ class CodeLanguagePicker extends HTMLElement {
7392
7623
  }
7393
7624
 
7394
7625
  dispose() {
7626
+ this.#abortController?.abort();
7627
+ this.#abortController = null;
7395
7628
  this.unregisterUpdateListener?.();
7396
7629
  this.unregisterUpdateListener = null;
7397
7630
  }
7398
7631
 
7399
7632
  #attachLanguagePicker() {
7400
7633
  this.languagePickerElement = this.#findLanguagePicker() ?? this.#createLanguagePicker();
7401
- this.append(this.languagePickerElement);
7634
+
7635
+ const signal = this.#abortController.signal;
7636
+
7637
+ this.languagePickerElement.addEventListener("change", () => {
7638
+ this.#updateCodeBlockLanguage(this.languagePickerElement.value);
7639
+ }, { signal });
7640
+
7641
+ this.languagePickerElement.addEventListener("mousedown", (event) => {
7642
+ this.#dispatchOpenEvent(event);
7643
+ }, { signal });
7644
+
7645
+ this.languagePickerElement.setAttribute("nonce", getNonce());
7646
+ this.appendChild(this.languagePickerElement);
7402
7647
  }
7403
7648
 
7404
7649
  #findLanguagePicker() {
@@ -7415,12 +7660,6 @@ class CodeLanguagePicker extends HTMLElement {
7415
7660
  selectElement.appendChild(option);
7416
7661
  }
7417
7662
 
7418
- selectElement.addEventListener("change", () => {
7419
- this.#updateCodeBlockLanguage(this.languagePickerElement.value);
7420
- });
7421
-
7422
- selectElement.setAttribute("nonce", getNonce());
7423
-
7424
7663
  return selectElement
7425
7664
  }
7426
7665
 
@@ -7443,6 +7682,21 @@ class CodeLanguagePicker extends HTMLElement {
7443
7682
  return Object.fromEntries([ plainEntry, ...sortedEntries ])
7444
7683
  }
7445
7684
 
7685
+ #dispatchOpenEvent(event) {
7686
+ const handled = !dispatch(this.editorElement, "lexxy:code-language-picker-open", {
7687
+ languages: this.#bridgeLanguages,
7688
+ currentLanguage: this.languagePickerElement.value
7689
+ }, true);
7690
+
7691
+ if (handled) {
7692
+ event.preventDefault();
7693
+ }
7694
+ }
7695
+
7696
+ get #bridgeLanguages() {
7697
+ return Object.entries(this.#languages).map(([ key, name ]) => ({ key, name }))
7698
+ }
7699
+
7446
7700
  #updateCodeBlockLanguage(language) {
7447
7701
  this.editor.update(() => {
7448
7702
  const codeNode = this.#getCurrentCodeNode();
@@ -8304,9 +8558,107 @@ function defineElements() {
8304
8558
  });
8305
8559
  }
8306
8560
 
8561
+ class NativeAdapter {
8562
+ frozenLinkKey = null
8563
+
8564
+ constructor(editorElement) {
8565
+ this.editorElement = editorElement;
8566
+ this.editorContentElement = editorElement.editorContentElement;
8567
+ }
8568
+
8569
+ dispatchAttributesChange(attributes, linkHref, highlight, headingTag) {
8570
+ dispatch(this.editorElement, "lexxy:attributes-change", {
8571
+ attributes,
8572
+ link: linkHref ? { href: linkHref } : null,
8573
+ highlight,
8574
+ headingTag
8575
+ });
8576
+ }
8577
+
8578
+ dispatchEditorInitialized(detail) {
8579
+ dispatch(this.editorElement, "lexxy:editor-initialized", detail);
8580
+ }
8581
+
8582
+ freeze() {
8583
+ let frozenLinkKey = null;
8584
+ this.editorElement.editor?.getEditorState().read(() => {
8585
+ const selection = $getSelection();
8586
+ if (!$isRangeSelection(selection)) return
8587
+
8588
+ const linkNode = $getNearestNodeOfType(selection.anchor.getNode(), LinkNode);
8589
+ if (linkNode) {
8590
+ frozenLinkKey = linkNode.getKey();
8591
+ }
8592
+ });
8593
+
8594
+ this.frozenLinkKey = frozenLinkKey;
8595
+ this.editorContentElement.contentEditable = "false";
8596
+ }
8597
+
8598
+ thaw() {
8599
+ this.editorContentElement.contentEditable = "true";
8600
+ }
8601
+
8602
+ unlinkFrozenNode() {
8603
+ const key = this.frozenLinkKey;
8604
+ if (!key) return false
8605
+
8606
+ const linkNode = $getNodeByKey(key);
8607
+ if (!$isLinkNode(linkNode)) {
8608
+ this.frozenLinkKey = null;
8609
+ return false
8610
+ }
8611
+
8612
+ const children = linkNode.getChildren();
8613
+ for (const child of children) {
8614
+ linkNode.insertBefore(child);
8615
+ }
8616
+ linkNode.remove();
8617
+
8618
+ // Select the former link text so a follow-up createLink can re-wrap it.
8619
+ const firstText = this.#findFirstTextDescendant(children);
8620
+ const lastText = this.#findLastTextDescendant(children);
8621
+ if (firstText && lastText) {
8622
+ const selection = $getSelection();
8623
+ if ($isRangeSelection(selection)) {
8624
+ selection.anchor.set(firstText.getKey(), 0, "text");
8625
+ selection.focus.set(lastText.getKey(), lastText.getTextContent().length, "text");
8626
+ }
8627
+ }
8628
+
8629
+ this.frozenLinkKey = null;
8630
+ return true
8631
+ }
8632
+
8633
+ #findFirstTextDescendant(nodes) {
8634
+ for (const node of nodes) {
8635
+ if ($isTextNode(node)) return node
8636
+ if ($isElementNode(node)) {
8637
+ const nestedTextNode = this.#findFirstTextDescendant(node.getChildren());
8638
+ if (nestedTextNode) return nestedTextNode
8639
+ }
8640
+ }
8641
+
8642
+ return null
8643
+ }
8644
+
8645
+ #findLastTextDescendant(nodes) {
8646
+ for (let index = nodes.length - 1; index >= 0; index--) {
8647
+ const node = nodes[index];
8648
+ if ($isTextNode(node)) return node
8649
+ if ($isElementNode(node)) {
8650
+ const nestedTextNode = this.#findLastTextDescendant(node.getChildren());
8651
+ if (nestedTextNode) return nestedTextNode
8652
+ }
8653
+ }
8654
+
8655
+ return null
8656
+ }
8657
+ }
8658
+
8307
8659
  const configure = Lexxy.configure;
8308
8660
 
8309
8661
  // Pushing elements definition to after the current call stack to allow global configuration to take place first
8310
8662
  setTimeout(defineElements, 0);
8311
8663
 
8312
- export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, configure };
8664
+ export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, configure };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.2-beta",
3
+ "version": "0.9.3-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",