@37signals/lexxy 0.1.23-beta → 0.1.25-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/README.md CHANGED
@@ -23,7 +23,7 @@ A modern rich text editor for Rails.
23
23
  Add this line to your application's Gemfile:
24
24
 
25
25
  ```ruby
26
- gem 'lexxy', '~> 0.1.22.beta' # Need to specify the version since it's a pre-release
26
+ gem 'lexxy', '~> 0.1.23.beta' # Need to specify the version since it's a pre-release
27
27
  ```
28
28
 
29
29
  And then execute:
package/dist/lexxy.esm.js CHANGED
@@ -1,24 +1,34 @@
1
- import DOMPurify from 'dompurify';
2
- import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
3
- import { $isTextNode, TextNode, $isRangeSelection, $getSelection, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_DELETE_COMMAND, KEY_BACKSPACE_COMMAND, SELECTION_CHANGE_COMMAND, $createNodeSelection, $setSelection, $createParagraphNode, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, $insertNodes, $createLineBreakNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, SKIP_DOM_SELECTION_TAG, createEditor, BLUR_COMMAND, FOCUS_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
4
- import { $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, ListNode, ListItemNode, registerList } from '@lexical/list';
5
- import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
6
- import { $isCodeNode, CodeNode, normalizeCodeLang, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
7
- import { $isLinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
1
+ import Prism from 'prismjs';
2
+ import 'prismjs/components/prism-clike';
3
+ import 'prismjs/components/prism-markup';
4
+ import 'prismjs/components/prism-markup-templating';
8
5
  import 'prismjs/components/prism-ruby';
9
6
  import 'prismjs/components/prism-php';
10
7
  import 'prismjs/components/prism-go';
11
8
  import 'prismjs/components/prism-bash';
12
9
  import 'prismjs/components/prism-json';
13
10
  import 'prismjs/components/prism-diff';
11
+ import DOMPurify from 'dompurify';
12
+ import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
13
+ import { $isTextNode, TextNode, $isRangeSelection, $getSelection, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_DELETE_COMMAND, KEY_BACKSPACE_COMMAND, SELECTION_CHANGE_COMMAND, $createNodeSelection, $setSelection, $createParagraphNode, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, $insertNodes, $createLineBreakNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, SKIP_DOM_SELECTION_TAG, createEditor, BLUR_COMMAND, FOCUS_COMMAND, KEY_DOWN_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
14
+ import { $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, ListNode, ListItemNode, registerList } from '@lexical/list';
15
+ import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
16
+ import { $isCodeNode, CodeNode, normalizeCodeLang, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
17
+ import { $isLinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
18
+ import { $getTableCellNodeFromLexicalNode, INSERT_TABLE_COMMAND, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, TableNode, TableCellNode, TableRowNode, registerTablePlugin, registerTableSelectionObserver, setScrollableTablesActive, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $getElementForTableNode, $isTableCellNode, TableCellHeaderStates } from '@lexical/table';
14
19
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
15
20
  import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
16
21
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
17
22
  import { DirectUpload } from '@rails/activestorage';
18
23
  import { marked } from 'marked';
19
24
 
25
+ // Configure Prism for manual highlighting mode
26
+ // This must be set before importing prismjs
27
+ window.Prism = window.Prism || {};
28
+ window.Prism.manual = true;
29
+
20
30
  const ALLOWED_HTML_TAGS = [ "a", "action-text-attachment", "b", "blockquote", "br", "code", "em",
21
- "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "ul" ];
31
+ "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "ul", "table", "tbody", "tr", "th", "td" ];
22
32
 
23
33
  const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
24
34
  "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation",
@@ -285,6 +295,7 @@ class LexicalToolbarElement extends HTMLElement {
285
295
  const isInCode = $isCodeNode(topLevelElement) || selection.hasFormat("code");
286
296
  const isInList = this.#isInList(anchorNode);
287
297
  const listType = getListType(anchorNode);
298
+ const isInTable = $getTableCellNodeFromLexicalNode(anchorNode) !== null;
288
299
 
289
300
  this.#setButtonPressed("bold", isBold);
290
301
  this.#setButtonPressed("italic", isItalic);
@@ -296,6 +307,7 @@ class LexicalToolbarElement extends HTMLElement {
296
307
  this.#setButtonPressed("code", isInCode);
297
308
  this.#setButtonPressed("unordered-list", isInList && listType === "bullet");
298
309
  this.#setButtonPressed("ordered-list", isInList && listType === "number");
310
+ this.#setButtonPressed("table", isInTable);
299
311
 
300
312
  this.#updateUndoRedoButtonStates();
301
313
  }
@@ -467,6 +479,10 @@ class LexicalToolbarElement extends HTMLElement {
467
479
  <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16 8a2 2 0 110 4 2 2 0 010-4z""/><path d="M22 2a1 1 0 011 1v18a1 1 0 01-1 1H2a1 1 0 01-1-1V3a1 1 0 011-1h20zM3 18.714L9 11l5.25 6.75L17 15l4 4V4H3v14.714z"/></svg>
468
480
  </button>
469
481
 
482
+ <button class="lexxy-editor__toolbar-button" type="button" name="table" data-command="insertTable" title="Insert a table">
483
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.2041 2.01074C21.2128 2.113 22 2.96435 22 4V20L21.9893 20.2041C21.8938 21.1457 21.1457 21.8938 20.2041 21.9893L20 22H4C2.96435 22 2.113 21.2128 2.01074 20.2041L2 20V4C2 2.89543 2.89543 2 4 2H20L20.2041 2.01074ZM4 13V20H11V13H4ZM13 13V20H20V13H13ZM4 11H11V4H4V11ZM13 11H20V4H13V11Z"/></svg>
484
+ </button>
485
+
470
486
  <button class="lexxy-editor__toolbar-button" type="button" name="divider" data-command="insertHorizontalDivider" title="Insert a divider">
471
487
  <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 12C0 11.4477 0.447715 11 1 11H23C23.5523 11 24 11.4477 24 12C24 12.5523 23.5523 13 23 13H1C0.447716 13 0 12.5523 0 12Z"/><path d="M4 5C4 3.89543 4.89543 3 6 3H18C19.1046 3 20 3.89543 20 5C20 6.10457 19.1046 7 18 7H6C4.89543 7 4 6.10457 4 5Z"/><path d="M4 19C4 17.8954 4.89543 17 6 17H18C19.1046 17 20 17.8954 20 19C20 20.1046 19.1046 21 18 21H6C4.89543 21 4 20.1046 4 19Z"/></svg>
472
488
  </button>
@@ -499,6 +515,10 @@ var theme = {
499
515
  underline: "lexxy-content__underline",
500
516
  highlight: "lexxy-content__highlight"
501
517
  },
518
+ tableCellHeader: "lexxy-content__table-cell--header",
519
+ tableCellSelected: "lexxy-content__table-cell--selected",
520
+ tableSelection: "lexxy-content__table--selection",
521
+ tableScrollableWrapper: "lexxy-content__table-wrapper",
502
522
  list: {
503
523
  nested: {
504
524
  listitem: "lexxy-nested-listitem",
@@ -558,7 +578,7 @@ var theme = {
558
578
  }
559
579
  };
560
580
 
561
- function createElement(name, properties) {
581
+ function createElement(name, properties, content = "") {
562
582
  const element = document.createElement(name);
563
583
  for (const [ key, value ] of Object.entries(properties || {})) {
564
584
  if (key in element) {
@@ -567,6 +587,9 @@ function createElement(name, properties) {
567
587
  element.setAttribute(key, value);
568
588
  }
569
589
  }
590
+ if (content) {
591
+ element.innerHTML = content;
592
+ }
570
593
  return element
571
594
  }
572
595
 
@@ -720,6 +743,10 @@ class ActionTextAttachmentNode extends DecoratorNode {
720
743
  return true
721
744
  }
722
745
 
746
+ getTextContent() {
747
+ return `[${ this.caption || this.fileName }]\n\n`
748
+ }
749
+
723
750
  isInline() {
724
751
  return false
725
752
  }
@@ -878,6 +905,11 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
878
905
  return new ActionTextAttachmentUploadNode({ ...serializedNode })
879
906
  }
880
907
 
908
+ // Should never run since this is a transient node. Defined to remove console warning.
909
+ static importDOM() {
910
+ return null
911
+ }
912
+
881
913
  constructor({ file, uploadUrl, blobUrlTemplate, editor, progress }, key) {
882
914
  super({ contentType: file.type }, key);
883
915
  this.file = file;
@@ -1081,6 +1113,10 @@ class HorizontalDividerNode extends DecoratorNode {
1081
1113
  return true
1082
1114
  }
1083
1115
 
1116
+ getTextContent() {
1117
+ return "┄\n\n"
1118
+ }
1119
+
1084
1120
  isInline() {
1085
1121
  return false
1086
1122
  }
@@ -1117,6 +1153,16 @@ const COMMANDS = [
1117
1153
  "insertCodeBlock",
1118
1154
  "insertHorizontalDivider",
1119
1155
  "uploadAttachments",
1156
+
1157
+ "insertTable",
1158
+ "insertTableRowAbove",
1159
+ "insertTableRowBelow",
1160
+ "insertTableColumnAfter",
1161
+ "insertTableColumnBefore",
1162
+ "deleteTableRow",
1163
+ "deleteTableColumn",
1164
+ "deleteTable",
1165
+
1120
1166
  "undo",
1121
1167
  "redo"
1122
1168
  ];
@@ -1279,6 +1325,45 @@ class CommandDispatcher {
1279
1325
  setTimeout(() => input.remove(), 1000);
1280
1326
  }
1281
1327
 
1328
+ dispatchInsertTable() {
1329
+ this.editor.dispatchCommand(INSERT_TABLE_COMMAND, { "rows": 3, "columns": 3, "includeHeaders": true });
1330
+ }
1331
+
1332
+ dispatchInsertTableRowBelow() {
1333
+ $insertTableRowAtSelection(true);
1334
+ }
1335
+
1336
+ dispatchInsertTableRowAbove() {
1337
+ $insertTableRowAtSelection(false);
1338
+ }
1339
+
1340
+ dispatchInsertTableColumnAfter() {
1341
+ $insertTableColumnAtSelection(true);
1342
+ }
1343
+
1344
+ dispatchInsertTableColumnBefore() {
1345
+ $insertTableColumnAtSelection(false);
1346
+ }
1347
+
1348
+ dispatchDeleteTableRow() {
1349
+ $deleteTableRowAtSelection();
1350
+ }
1351
+
1352
+ dispatchDeleteTableColumn() {
1353
+ $deleteTableColumnAtSelection();
1354
+ }
1355
+
1356
+ dispatchDeleteTable() {
1357
+ this.editor.update(() => {
1358
+ const selection = $getSelection();
1359
+ if (!$isRangeSelection(selection)) return
1360
+
1361
+ const anchorNode = selection.anchor.getNode();
1362
+ const tableNode = $findTableNode(anchorNode);
1363
+ tableNode.remove();
1364
+ });
1365
+ }
1366
+
1282
1367
  dispatchUndo() {
1283
1368
  this.editor.dispatchCommand(UNDO_COMMAND, undefined);
1284
1369
  }
@@ -2118,6 +2203,10 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2118
2203
  return true
2119
2204
  }
2120
2205
 
2206
+ getTextContent() {
2207
+ return this.createDOM().textContent.trim() || `[${this.contentType}]`
2208
+ }
2209
+
2121
2210
  isInline() {
2122
2211
  return true
2123
2212
  }
@@ -3179,7 +3268,7 @@ class Clipboard {
3179
3268
 
3180
3269
  if (!clipboardData) return false
3181
3270
 
3182
- if (this.#isOnlyPlainTextPasted(clipboardData) && !this.#isPastingIntoCodeBlock()) {
3271
+ if (this.#isPlainTextOrURLPasted(clipboardData) && !this.#isPastingIntoCodeBlock()) {
3183
3272
  this.#pastePlainText(clipboardData);
3184
3273
  event.preventDefault();
3185
3274
  return true
@@ -3188,11 +3277,21 @@ class Clipboard {
3188
3277
  this.#handlePastedFiles(clipboardData);
3189
3278
  }
3190
3279
 
3280
+ #isPlainTextOrURLPasted(clipboardData) {
3281
+ return this.#isOnlyPlainTextPasted(clipboardData) || this.#isOnlyURLPasted(clipboardData)
3282
+ }
3283
+
3191
3284
  #isOnlyPlainTextPasted(clipboardData) {
3192
3285
  const types = Array.from(clipboardData.types);
3193
3286
  return types.length === 1 && types[0] === "text/plain"
3194
3287
  }
3195
3288
 
3289
+ #isOnlyURLPasted(clipboardData) {
3290
+ // Safari URLs are copied as a text/plain + text/uri-list object
3291
+ const types = Array.from(clipboardData.types);
3292
+ return types.length === 2 && types.includes("text/uri-list") && types.includes("text/plain")
3293
+ }
3294
+
3196
3295
  #isPastingIntoCodeBlock() {
3197
3296
  let result = false;
3198
3297
 
@@ -3408,7 +3507,7 @@ function applyLanguage(conversionOutput, element) {
3408
3507
 
3409
3508
  class LexicalEditorElement extends HTMLElement {
3410
3509
  static formAssociated = true
3411
- static debug = true
3510
+ static debug = false
3412
3511
  static commands = [ "bold", "italic", "strikethrough" ]
3413
3512
 
3414
3513
  static observedAttributes = [ "connected", "required" ]
@@ -3483,6 +3582,18 @@ class LexicalEditorElement extends HTMLElement {
3483
3582
  return this.dataset.blobUrlTemplate
3484
3583
  }
3485
3584
 
3585
+ get isEmpty() {
3586
+ return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
3587
+ }
3588
+
3589
+ get isBlank() {
3590
+ return this.isEmpty || this.toString().match(/^\s*$/g) !== null
3591
+ }
3592
+
3593
+ get hasOpenPrompt() {
3594
+ return this.querySelector(".lexxy-prompt-menu.lexxy-prompt-menu--visible") !== null
3595
+ }
3596
+
3486
3597
  get isSingleLineMode() {
3487
3598
  return this.hasAttribute("single-line")
3488
3599
  }
@@ -3526,6 +3637,16 @@ class LexicalEditorElement extends HTMLElement {
3526
3637
  });
3527
3638
  }
3528
3639
 
3640
+ toString() {
3641
+ if (!this.cachedStringValue) {
3642
+ this.editor?.getEditorState().read(() => {
3643
+ this.cachedStringValue = $getRoot().getTextContent();
3644
+ });
3645
+ }
3646
+
3647
+ return this.cachedStringValue
3648
+ }
3649
+
3529
3650
  #parseHtmlIntoLexicalNodes(html) {
3530
3651
  if (!html) html = "<p></p>";
3531
3652
  const nodes = $generateNodesFromDOM(this.editor, parseHtml(`<div>${html}</div>`));
@@ -3548,6 +3669,7 @@ class LexicalEditorElement extends HTMLElement {
3548
3669
  this.#listenForInvalidatedNodes();
3549
3670
  this.#handleEnter();
3550
3671
  this.#handleFocus();
3672
+ this.#handleTables();
3551
3673
  this.#attachDebugHooks();
3552
3674
  this.#attachToolbar();
3553
3675
  this.#loadInitialValue();
@@ -3584,6 +3706,9 @@ class LexicalEditorElement extends HTMLElement {
3584
3706
  LinkNode,
3585
3707
  AutoLinkNode,
3586
3708
  HorizontalDividerNode,
3709
+ TableNode,
3710
+ TableCellNode,
3711
+ TableRowNode,
3587
3712
 
3588
3713
  CustomActionTextAttachmentNode,
3589
3714
  ];
@@ -3631,7 +3756,7 @@ class LexicalEditorElement extends HTMLElement {
3631
3756
 
3632
3757
  this.internals.setFormValue(html);
3633
3758
  this._internalFormValue = html;
3634
- this.#validationTextArea.value = this.#isEmpty ? "" : html;
3759
+ this.#validationTextArea.value = this.isEmpty ? "" : html;
3635
3760
 
3636
3761
  if (changed) {
3637
3762
  dispatch(this, "lexxy:change");
@@ -3657,13 +3782,18 @@ class LexicalEditorElement extends HTMLElement {
3657
3782
 
3658
3783
  #synchronizeWithChanges() {
3659
3784
  this.#addUnregisterHandler(this.editor.registerUpdateListener(({ editorState }) => {
3660
- this.cachedValue = null;
3785
+ this.#clearCachedValues();
3661
3786
  this.#internalFormValue = this.value;
3662
3787
  this.#toggleEmptyStatus();
3663
3788
  this.#setValidity();
3664
3789
  }));
3665
3790
  }
3666
3791
 
3792
+ #clearCachedValues() {
3793
+ this.cachedValue = null;
3794
+ this.cachedStringValue = null;
3795
+ }
3796
+
3667
3797
  #addUnregisterHandler(handler) {
3668
3798
  this.unregisterHandlers = this.unregisterHandlers || [];
3669
3799
  this.unregisterHandlers.push(handler);
@@ -3681,13 +3811,21 @@ class LexicalEditorElement extends HTMLElement {
3681
3811
  this.historyState = createEmptyHistoryState();
3682
3812
  registerHistory(this.editor, this.historyState, 20);
3683
3813
  registerList(this.editor);
3814
+ this.#registerTableComponents();
3684
3815
  this.#registerCodeHiglightingComponents();
3685
3816
  registerMarkdownShortcuts(this.editor, TRANSFORMERS);
3686
3817
  }
3687
3818
 
3819
+ #registerTableComponents() {
3820
+ registerTablePlugin(this.editor);
3821
+ this.tableHandler = createElement("lexxy-table-handler");
3822
+ this.append(this.tableHandler);
3823
+ }
3824
+
3688
3825
  #registerCodeHiglightingComponents() {
3689
3826
  registerCodeHighlighting(this.editor);
3690
- this.append(createElement("lexxy-code-language-picker"));
3827
+ this.codeLanguagePicker = createElement("lexxy-code-language-picker");
3828
+ this.append(this.codeLanguagePicker);
3691
3829
  }
3692
3830
 
3693
3831
  #listenForInvalidatedNodes() {
@@ -3736,12 +3874,18 @@ class LexicalEditorElement extends HTMLElement {
3736
3874
  this.editor.registerCommand(FOCUS_COMMAND, () => { dispatch(this, "lexxy:focus"); }, COMMAND_PRIORITY_NORMAL);
3737
3875
  }
3738
3876
 
3877
+ #handleTables() {
3878
+ this.removeTableSelectionObserver = registerTableSelectionObserver(this.editor, true);
3879
+ setScrollableTablesActive(this.editor, true);
3880
+ }
3881
+
3739
3882
  #attachDebugHooks() {
3740
3883
  if (!LexicalEditorElement.debug) return
3741
3884
 
3742
3885
  this.#addUnregisterHandler(this.editor.registerUpdateListener(({ editorState }) => {
3743
3886
  editorState.read(() => {
3744
- console.debug("HTML: ", this.value);
3887
+ console.debug("HTML: ", this.value, "String:", this.toString());
3888
+ console.debug("empty", this.isEmpty, "blank", this.isBlank);
3745
3889
  });
3746
3890
  }));
3747
3891
  }
@@ -3770,11 +3914,7 @@ class LexicalEditorElement extends HTMLElement {
3770
3914
  }
3771
3915
 
3772
3916
  #toggleEmptyStatus() {
3773
- this.classList.toggle("lexxy-editor--empty", this.#isEmpty);
3774
- }
3775
-
3776
- get #isEmpty() {
3777
- return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
3917
+ this.classList.toggle("lexxy-editor--empty", this.isEmpty);
3778
3918
  }
3779
3919
 
3780
3920
  #setValidity() {
@@ -3801,6 +3941,16 @@ class LexicalEditorElement extends HTMLElement {
3801
3941
  this.toolbar = null;
3802
3942
  }
3803
3943
 
3944
+ if (this.codeLanguagePicker) {
3945
+ this.codeLanguagePicker.remove();
3946
+ this.codeLanguagePicker = null;
3947
+ }
3948
+
3949
+ if (this.tableHandler) {
3950
+ this.tableHandler.remove();
3951
+ this.tableHandler = null;
3952
+ }
3953
+
3804
3954
  this.selection = null;
3805
3955
 
3806
3956
  document.removeEventListener("turbo:before-cache", this.#handleTurboBeforeCache);
@@ -4053,6 +4203,494 @@ class HighlightDropdown extends ToolbarDropdown {
4053
4203
 
4054
4204
  customElements.define("lexxy-highlight-dropdown", HighlightDropdown);
4055
4205
 
4206
+ class TableHandler extends HTMLElement {
4207
+ connectedCallback() {
4208
+ this.#setUpButtons();
4209
+ this.#monitorForTableSelection();
4210
+ this.#registerKeyboardShortcuts();
4211
+ }
4212
+
4213
+ disconnectedCallback() {
4214
+ this.#unregisterKeyboardShortcuts();
4215
+ }
4216
+
4217
+ get #editor() {
4218
+ return this.#editorElement.editor
4219
+ }
4220
+
4221
+ get #editorElement() {
4222
+ return this.closest("lexxy-editor")
4223
+ }
4224
+
4225
+ get #currentCell() {
4226
+ const selection = $getSelection();
4227
+ if (!$isRangeSelection(selection)) return null
4228
+
4229
+ const anchorNode = selection.anchor.getNode();
4230
+ return $getTableCellNodeFromLexicalNode(anchorNode)
4231
+ }
4232
+
4233
+ get #currentRow() {
4234
+ const currentCell = this.#currentCell;
4235
+ if (!currentCell) return 0
4236
+ return $getTableRowIndexFromTableCellNode(currentCell)
4237
+ }
4238
+
4239
+ get #currentColumn() {
4240
+ const currentCell = this.#currentCell;
4241
+ if (!currentCell) return 0
4242
+ return $getTableColumnIndexFromTableCellNode(currentCell)
4243
+ }
4244
+
4245
+ #registerKeyboardShortcuts() {
4246
+ this.unregisterKeyboardShortcuts = this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleKeyDown.bind(this), COMMAND_PRIORITY_HIGH);
4247
+ }
4248
+
4249
+ #unregisterKeyboardShortcuts() {
4250
+ this.unregisterKeyboardShortcuts();
4251
+ }
4252
+
4253
+ #handleKeyDown(event) {
4254
+ if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "F10") {
4255
+ const firstButton = this.buttonsContainer?.querySelector("button, [tabindex]:not([tabindex='-1'])");
4256
+ this.#setFocusStateOnSelectedCell();
4257
+ firstButton?.focus();
4258
+ } else if (event.key === "Escape") {
4259
+ this.#editor.getEditorState().read(() => {
4260
+ const cell = this.#currentCell;
4261
+ if (!cell) return
4262
+
4263
+ this.#editor.update(() => {
4264
+ cell.select();
4265
+ });
4266
+ });
4267
+ this.#closeMoreMenu();
4268
+ }
4269
+ }
4270
+
4271
+ #setUpButtons() {
4272
+ this.buttonsContainer = createElement("div", {
4273
+ className: "lexxy-table-handle-buttons"
4274
+ });
4275
+
4276
+ this.buttonsContainer.appendChild(this.#createRowButtonsContainer());
4277
+ this.buttonsContainer.appendChild(this.#createColumnButtonsContainer());
4278
+
4279
+ this.moreMenu = this.#createMoreMenu();
4280
+ this.buttonsContainer.appendChild(this.moreMenu);
4281
+
4282
+ this.#editorElement.appendChild(this.buttonsContainer);
4283
+ }
4284
+
4285
+ #showTableHandlerButtons() {
4286
+ this.buttonsContainer.style.display = "flex";
4287
+ this.#closeMoreMenu();
4288
+
4289
+ this.#updateRowColumnCount();
4290
+ this.#setTableFocusState(true);
4291
+ }
4292
+
4293
+ #hideTableHandlerButtons() {
4294
+ this.buttonsContainer.style.display = "none";
4295
+ this.#closeMoreMenu();
4296
+
4297
+ this.#setTableFocusState(false);
4298
+ this.currentTableNode = null;
4299
+ }
4300
+
4301
+ #updateButtonsPosition(tableNode) {
4302
+ const tableElement = this.#editor.getElementByKey(tableNode.getKey());
4303
+ if (!tableElement) return
4304
+
4305
+ const tableRect = tableElement.getBoundingClientRect();
4306
+ const editorRect = this.#editorElement.getBoundingClientRect();
4307
+
4308
+ const relativeTop = tableRect.top - editorRect.top;
4309
+ const relativeCenter = (tableRect.left + tableRect.right) / 2 - editorRect.left;
4310
+ this.buttonsContainer.style.top = `${relativeTop}px`;
4311
+ this.buttonsContainer.style.left = `${relativeCenter}px`;
4312
+ }
4313
+
4314
+ #updateRowColumnCount() {
4315
+ if (!this.currentTableNode) return
4316
+
4317
+ const tableElement = $getElementForTableNode(this.#editor, this.currentTableNode);
4318
+ if (!tableElement) return
4319
+
4320
+ const rowCount = tableElement.rows;
4321
+ const columnCount = tableElement.columns;
4322
+
4323
+ this.rowCount.textContent = `${rowCount} row${rowCount === 1 ? "" : "s"}`;
4324
+ this.columnCount.textContent = `${columnCount} column${columnCount === 1 ? "" : "s"}`;
4325
+ }
4326
+
4327
+ #createButton(icon, label, onClick) {
4328
+ const button = createElement("button", {
4329
+ className: "lexxy-table-control__button",
4330
+ "aria-label": label,
4331
+ type: "button"
4332
+ });
4333
+ button.tabIndex = -1;
4334
+ button.innerHTML = `${icon} <span>${label}</span>`;
4335
+ button.addEventListener("click", onClick.bind(this));
4336
+
4337
+ return button
4338
+ }
4339
+
4340
+ #createRowButtonsContainer() {
4341
+ const container = createElement("div", { className: "lexxy-table-control" });
4342
+
4343
+ const plusButton = this.#createButton("+", "Add row", () => this.#insertTableRow("end"));
4344
+ const minusButton = this.#createButton("−", "Remove row", () => this.#deleteTableRow("end"));
4345
+
4346
+ this.rowCount = createElement("span");
4347
+ this.rowCount.textContent = "_ rows";
4348
+
4349
+ container.appendChild(minusButton);
4350
+ container.appendChild(this.rowCount);
4351
+ container.appendChild(plusButton);
4352
+
4353
+ return container
4354
+ }
4355
+
4356
+ #createColumnButtonsContainer() {
4357
+ const container = createElement("div", { className: "lexxy-table-control" });
4358
+
4359
+ const plusButton = this.#createButton("+", "Add column", () => this.#insertTableColumn("end"));
4360
+ const minusButton = this.#createButton("−", "Remove column", () => this.#deleteTableColumn("end"));
4361
+
4362
+ this.columnCount = createElement("span");
4363
+ this.columnCount.textContent = "_ columns";
4364
+
4365
+ container.appendChild(minusButton);
4366
+ container.appendChild(this.columnCount);
4367
+ container.appendChild(plusButton);
4368
+
4369
+ return container
4370
+ }
4371
+
4372
+ #createMoreMenu() {
4373
+ const container = createElement("details", {
4374
+ className: "lexxy-table-control lexxy-table-control__more-menu"
4375
+ });
4376
+
4377
+ container.tabIndex = -1;
4378
+
4379
+ const summary = createElement("summary", {}, "•••");
4380
+ container.appendChild(summary);
4381
+
4382
+ const details = createElement("div", { className: "lexxy-table-control__more-menu-details" });
4383
+ container.appendChild(details);
4384
+
4385
+ details.appendChild(this.#createRowSection());
4386
+ details.appendChild(this.#createColumnSection());
4387
+ details.appendChild(this.#createDeleteTableSection());
4388
+
4389
+ container.addEventListener("toggle", this.#handleMoreMenuToggle.bind(this));
4390
+
4391
+ return container
4392
+ }
4393
+
4394
+ #createColumnSection() {
4395
+ const columnSection = createElement("section", { className: "lexxy-table-control__more-menu-section" });
4396
+
4397
+ const columnButtons = [
4398
+ { icon: this.#icon("add-column-before"), label: "Add column before", onClick: () => this.#insertTableColumn("left") },
4399
+ { icon: this.#icon("add-column-after"), label: "Add column after", onClick: () => this.#insertTableColumn("right") },
4400
+ { icon: this.#icon("remove-column"), label: "Remove column", onClick: this.#deleteTableColumn },
4401
+ { icon: this.#icon("toggle-column-style"), label: "Toggle column style", onClick: this.#toggleColumnHeaderStyle },
4402
+ ];
4403
+
4404
+ columnButtons.forEach(button => {
4405
+ const buttonElement = this.#createButton(button.icon, button.label, button.onClick);
4406
+ columnSection.appendChild(buttonElement);
4407
+ });
4408
+
4409
+ return columnSection
4410
+ }
4411
+
4412
+ #createRowSection() {
4413
+ const rowSection = createElement("section", { className: "lexxy-table-control__more-menu-section" });
4414
+
4415
+ const rowButtons = [
4416
+ { icon: this.#icon("add-row-above"), label: "Add row above", onClick: () => this.#insertTableRow("above") },
4417
+ { icon: this.#icon("add-row-below"), label: "Add row below", onClick: () => this.#insertTableRow("below") },
4418
+ { icon: this.#icon("remove-row"), label: "Remove row", onClick: this.#deleteTableRow },
4419
+ { icon: this.#icon("toggle-row-style"), label: "Toggle row style", onClick: this.#toggleRowHeaderStyle }
4420
+ ];
4421
+
4422
+ rowButtons.forEach(button => {
4423
+ const buttonElement = this.#createButton(button.icon, button.label, button.onClick);
4424
+ rowSection.appendChild(buttonElement);
4425
+ });
4426
+
4427
+ return rowSection
4428
+ }
4429
+
4430
+ #createDeleteTableSection() {
4431
+ const deleteSection = createElement("section", { className: "lexxy-table-control__more-menu-section" });
4432
+
4433
+ const deleteButton = { icon: this.#icon("delete-table"), label: "Delete table", onClick: this.#deleteTable };
4434
+
4435
+ const buttonElement = this.#createButton(deleteButton.icon, deleteButton.label, deleteButton.onClick);
4436
+ deleteSection.appendChild(buttonElement);
4437
+
4438
+ return deleteSection
4439
+ }
4440
+
4441
+ #handleMoreMenuToggle() {
4442
+ if (this.moreMenu.open) {
4443
+ this.#setFocusStateOnSelectedCell();
4444
+ } else {
4445
+ this.#removeFocusStateFromSelectedCell();
4446
+ }
4447
+ }
4448
+
4449
+ #closeMoreMenu() {
4450
+ this.#removeFocusStateFromSelectedCell();
4451
+ this.moreMenu.removeAttribute("open");
4452
+ }
4453
+
4454
+ #monitorForTableSelection() {
4455
+ this.#editor.registerUpdateListener(() => {
4456
+ this.#editor.getEditorState().read(() => {
4457
+ const selection = $getSelection();
4458
+ if (!$isRangeSelection(selection)) return
4459
+
4460
+ const anchorNode = selection.anchor.getNode();
4461
+ const tableNode = $findTableNode(anchorNode);
4462
+
4463
+ if (tableNode) {
4464
+ this.#tableCellWasSelected(tableNode);
4465
+ } else {
4466
+ this.#hideTableHandlerButtons();
4467
+ }
4468
+ });
4469
+ });
4470
+ }
4471
+
4472
+ #setTableFocusState(focused) {
4473
+ this.#editorElement.querySelector("div.node--selected:has(table)")?.classList.remove("node--selected");
4474
+
4475
+ if (focused && this.currentTableNode) {
4476
+ const tableParent = this.#editor.getElementByKey(this.currentTableNode.getKey());
4477
+ if (!tableParent) return
4478
+ tableParent.classList.add("node--selected");
4479
+ }
4480
+ }
4481
+
4482
+ #tableCellWasSelected(tableNode) {
4483
+ this.currentTableNode = tableNode;
4484
+ this.#updateButtonsPosition(tableNode);
4485
+ this.#showTableHandlerButtons();
4486
+ }
4487
+
4488
+ #setFocusStateOnSelectedCell() {
4489
+ this.#editor.getEditorState().read(() => {
4490
+ const currentCell = this.#currentCell;
4491
+ if (!currentCell) return
4492
+
4493
+ const cellElement = this.#editor.getElementByKey(currentCell.getKey());
4494
+ if (!cellElement) return
4495
+
4496
+ cellElement.classList.add("table-cell--selected");
4497
+ });
4498
+ }
4499
+
4500
+ #removeFocusStateFromSelectedCell() {
4501
+ this.#editorElement.querySelector(".table-cell--selected")?.classList.remove("table-cell--selected");
4502
+ }
4503
+
4504
+ #selectLastTableCell() {
4505
+ if (!this.currentTableNode) return
4506
+
4507
+ const last = this.currentTableNode.getLastChild().getLastChild();
4508
+ if (!$isTableCellNode(last)) return
4509
+
4510
+ last.selectEnd();
4511
+ }
4512
+
4513
+ #deleteTable() {
4514
+ this.#editor.dispatchCommand("deleteTable");
4515
+
4516
+ this.#closeMoreMenu();
4517
+ this.#updateRowColumnCount();
4518
+ }
4519
+
4520
+ #insertTableRow(direction) {
4521
+ this.#executeTableCommand("insert", "row", direction);
4522
+ }
4523
+
4524
+ #insertTableColumn(direction) {
4525
+ this.#executeTableCommand("insert", "column", direction);
4526
+ }
4527
+
4528
+ #deleteTableRow(direction) {
4529
+ this.#executeTableCommand("delete", "row", direction);
4530
+ }
4531
+
4532
+ #deleteTableColumn(direction) {
4533
+ this.#executeTableCommand("delete", "column", direction);
4534
+ }
4535
+
4536
+ #executeTableCommand(action = "insert", childType = "row", direction) {
4537
+ this.#editor.update(() => {
4538
+ const currentCell = this.#currentCell;
4539
+ if (!currentCell) return
4540
+
4541
+ if (direction === "end") {
4542
+ this.#selectLastTableCell();
4543
+ }
4544
+
4545
+ this.#dispatchTableCommand(action, childType, direction);
4546
+
4547
+ if (currentCell.isAttached()) {
4548
+ currentCell.selectEnd();
4549
+ }
4550
+ });
4551
+
4552
+ this.#closeMoreMenu();
4553
+ this.#updateRowColumnCount();
4554
+ }
4555
+
4556
+ #dispatchTableCommand(action, childType, direction) {
4557
+ switch (action) {
4558
+ case "insert":
4559
+ switch (childType) {
4560
+ case "row":
4561
+ if (direction === "above") {
4562
+ this.#editor.dispatchCommand("insertTableRowAbove");
4563
+ } else {
4564
+ this.#editor.dispatchCommand("insertTableRowBelow");
4565
+ }
4566
+ break
4567
+ case "column":
4568
+ if (direction === "left") {
4569
+ this.#editor.dispatchCommand("insertTableColumnBefore");
4570
+ } else {
4571
+ this.#editor.dispatchCommand("insertTableColumnAfter");
4572
+ }
4573
+ break
4574
+ }
4575
+ break
4576
+ case "delete":
4577
+ switch (childType) {
4578
+ case "row":
4579
+ this.#editor.dispatchCommand("deleteTableRow");
4580
+ break
4581
+ case "column":
4582
+ this.#editor.dispatchCommand("deleteTableColumn");
4583
+ break
4584
+ }
4585
+ break
4586
+ }
4587
+ }
4588
+
4589
+ #toggleRowHeaderStyle() {
4590
+ this.#editor.update(() => {
4591
+ const rows = this.currentTableNode.getChildren();
4592
+
4593
+ const row = rows[this.#currentRow];
4594
+ if (!row) return
4595
+
4596
+ const cells = row.getChildren();
4597
+ const firstCell = $getTableCellNodeFromLexicalNode(cells[0]);
4598
+ if (!firstCell) return
4599
+
4600
+ const currentStyle = firstCell.getHeaderStyles();
4601
+ const newStyle = currentStyle ^ TableCellHeaderStates.ROW;
4602
+
4603
+ cells.forEach(cell => {
4604
+ this.#setHeaderStyle(cell, newStyle, TableCellHeaderStates.ROW);
4605
+ });
4606
+ });
4607
+ }
4608
+
4609
+ #toggleColumnHeaderStyle() {
4610
+ this.#editor.update(() => {
4611
+ const rows = this.currentTableNode.getChildren();
4612
+
4613
+ const row = rows[this.#currentRow];
4614
+ if (!row) return
4615
+
4616
+ const cells = row.getChildren();
4617
+ const selectedCell = $getTableCellNodeFromLexicalNode(cells[this.#currentColumn]);
4618
+ if (!selectedCell) return
4619
+
4620
+ const currentStyle = selectedCell.getHeaderStyles();
4621
+ const newStyle = currentStyle ^ TableCellHeaderStates.COLUMN;
4622
+
4623
+ rows.forEach(row => {
4624
+ const cell = row.getChildren()[this.#currentColumn];
4625
+ if (!cell) return
4626
+ this.#setHeaderStyle(cell, newStyle, TableCellHeaderStates.COLUMN);
4627
+ });
4628
+ });
4629
+ }
4630
+
4631
+ #setHeaderStyle(cell, newStyle, headerState) {
4632
+ const tableCellNode = $getTableCellNodeFromLexicalNode(cell);
4633
+
4634
+ if (tableCellNode) {
4635
+ tableCellNode.setHeaderStyles(newStyle, headerState);
4636
+ }
4637
+ }
4638
+
4639
+ #icon(name) {
4640
+ const icons =
4641
+ {
4642
+ "add-row-above":
4643
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
4644
+ <path d="M4 7L0 10V4L4 7ZM6.5 7.5H16.5V6.5H6.5V7.5ZM18 8C18 8.55228 17.5523 9 17 9H6C5.44772 9 5 8.55228 5 8V6C5 5.44772 5.44772 5 6 5H17C17.5523 5 18 5.44772 18 6V8Z"/><path d="M2 2C2 1.44772 2.44772 1 3 1H15C15.5523 1 16 1.44772 16 2C16 2.55228 15.5523 3 15 3H3C2.44772 3 2 2.55228 2 2Z"/><path d="M2 12C2 11.4477 2.44772 11 3 11H15C15.5523 11 16 11.4477 16 12C16 12.5523 15.5523 13 15 13H3C2.44772 13 2 12.5523 2 12Z"/><path d="M2 16C2 15.4477 2.44772 15 3 15H15C15.5523 15 16 15.4477 16 16C16 16.5523 15.5523 17 15 17H3C2.44772 17 2 16.5523 2 16Z"/>
4645
+ </svg>`,
4646
+
4647
+ "add-row-below":
4648
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
4649
+ <path d="M4 11L0 8V14L4 11ZM6.5 10.5H16.5V11.5H6.5V10.5ZM18 10C18 9.44772 17.5523 9 17 9H6C5.44772 9 5 9.44772 5 10V12C5 12.5523 5.44772 13 6 13H17C17.5523 13 18 12.5523 18 12V10Z"/><path d="M2 16C2 16.5523 2.44772 17 3 17H15C15.5523 17 16 16.5523 16 16C16 15.4477 15.5523 15 15 15H3C2.44772 15 2 15.4477 2 16Z"/><path d="M2 6C2 6.55228 2.44772 7 3 7H15C15.5523 7 16 6.55228 16 6C16 5.44772 15.5523 5 15 5H3C2.44772 5 2 5.44772 2 6Z"/><path d="M2 2C2 2.55228 2.44772 3 3 3H15C15.5523 3 16 2.55228 16 2C16 1.44772 15.5523 1 15 1H3C2.44772 1 2 1.44772 2 2Z"/>
4650
+ </svg>`,
4651
+
4652
+ "remove-row":
4653
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
4654
+ <path d="M17.9951 10.1025C17.9438 10.6067 17.5177 11 17 11H12.4922L13.9922 9.5H16.5V5.5L1.5 5.5L1.5 9.5H4.00586L5.50586 11H1L0.897461 10.9951C0.427034 10.9472 0.0527828 10.573 0.00488281 10.1025L0 10L1.78814e-07 5C2.61831e-07 4.48232 0.393332 4.05621 0.897461 4.00488L1 4L17 4C17.5523 4 18 4.44772 18 5V10L17.9951 10.1025Z"/><path d="M11.2969 15.0146L8.99902 12.7168L6.7002 15.0146L5.63965 13.9541L7.93848 11.6562L5.63965 9.3584L6.7002 8.29785L8.99902 10.5957L11.2969 8.29785L12.3574 9.3584L10.0596 11.6562L12.3574 13.9541L11.2969 15.0146Z"/>
4655
+ </svg>`,
4656
+
4657
+ "toggle-row-style":
4658
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
4659
+ <path d="M1 2C1 1.44772 1.44772 1 2 1H7C7.55228 1 8 1.44772 8 2V7C8 7.55228 7.55228 8 7 8H2C1.44772 8 1 7.55228 1 7V2Z"/><path d="M2.5 15.5H6.5V11.5H2.5V15.5ZM8 16C8 16.5177 7.60667 16.9438 7.10254 16.9951L7 17H2L1.89746 16.9951C1.42703 16.9472 1.05278 16.573 1.00488 16.1025L1 16V11C1 10.4477 1.44772 10 2 10H7C7.55228 10 8 10.4477 8 11V16Z"/><path d="M10 2C10 1.44772 10.4477 1 11 1H16C16.5523 1 17 1.44772 17 2V7C17 7.55228 16.5523 8 16 8H11C10.4477 8 10 7.55228 10 7V2Z"/><path d="M11.5 15.5H15.5V11.5H11.5V15.5ZM17 16C17 16.5177 16.6067 16.9438 16.1025 16.9951L16 17H11L10.8975 16.9951C10.427 16.9472 10.0528 16.573 10.0049 16.1025L10 16V11C10 10.4477 10.4477 10 11 10H16C16.5523 10 17 10.4477 17 11V16Z"/>
4660
+ </svg>`,
4661
+
4662
+ "add-column-before":
4663
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
4664
+ <path d="M7 4L10 2.62268e-07L4 0L7 4ZM7.5 6.5L7.5 16.5H6.5L6.5 6.5H7.5ZM8 18C8.55228 18 9 17.5523 9 17V6C9 5.44772 8.55229 5 8 5H6C5.44772 5 5 5.44772 5 6L5 17C5 17.5523 5.44772 18 6 18H8Z"/><path d="M2 2C1.44772 2 1 2.44772 1 3L1 15C1 15.5523 1.44772 16 2 16C2.55228 16 3 15.5523 3 15L3 3C3 2.44772 2.55229 2 2 2Z"/><path d="M12 2C11.4477 2 11 2.44772 11 3L11 15C11 15.5523 11.4477 16 12 16C12.5523 16 13 15.5523 13 15L13 3C13 2.44772 12.5523 2 12 2Z"/><path d="M16 2C15.4477 2 15 2.44772 15 3L15 15C15 15.5523 15.4477 16 16 16C16.5523 16 17 15.5523 17 15V3C17 2.44772 16.5523 2 16 2Z"/>
4665
+ </svg>`,
4666
+
4667
+ "add-column-after":
4668
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
4669
+ <path d="M11 4L8 2.62268e-07L14 0L11 4ZM10.5 6.5V16.5H11.5V6.5H10.5ZM10 18C9.44772 18 9 17.5523 9 17V6C9 5.44772 9.44772 5 10 5H12C12.5523 5 13 5.44772 13 6V17C13 17.5523 12.5523 18 12 18H10Z"/><path d="M16 2C16.5523 2 17 2.44772 17 3L17 15C17 15.5523 16.5523 16 16 16C15.4477 16 15 15.5523 15 15V3C15 2.44772 15.4477 2 16 2Z"/><path d="M6 2C6.55228 2 7 2.44772 7 3L7 15C7 15.5523 6.55228 16 6 16C5.44772 16 5 15.5523 5 15L5 3C5 2.44772 5.44771 2 6 2Z"/><path d="M2 2C2.55228 2 3 2.44772 3 3L3 15C3 15.5523 2.55228 16 2 16C1.44772 16 1 15.5523 1 15V3C1 2.44772 1.44771 2 2 2Z"/>
4670
+ </svg>`,
4671
+
4672
+ "remove-column":
4673
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
4674
+ <path d="M10.1025 0.00488281C10.6067 0.0562145 11 0.482323 11 1V5.50781L9.5 4.00781V1.5H5.5V16.5H9.5V13.9941L11 12.4941V17L10.9951 17.1025C10.9472 17.573 10.573 17.9472 10.1025 17.9951L10 18H5C4.48232 18 4.05621 17.6067 4.00488 17.1025L4 17V1C4 0.447715 4.44772 1.61064e-08 5 0H10L10.1025 0.00488281Z"/><path d="M12.7169 8.99999L15.015 11.2981L13.9543 12.3588L11.6562 10.0607L9.35815 12.3588L8.29749 11.2981L10.5956 8.99999L8.29749 6.7019L9.35815 5.64124L11.6562 7.93933L13.9543 5.64124L15.015 6.7019L12.7169 8.99999Z"/>
4675
+ </svg>`,
4676
+
4677
+ "toggle-column-style":
4678
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
4679
+ <path d="M1 2C1 1.44772 1.44772 1 2 1H7C7.55228 1 8 1.44772 8 2V7C8 7.55228 7.55228 8 7 8H2C1.44772 8 1 7.55228 1 7V2Z"/><path d="M1 11C1 10.4477 1.44772 10 2 10H7C7.55228 10 8 10.4477 8 11V16C8 16.5523 7.55228 17 7 17H2C1.44772 17 1 16.5523 1 16V11Z"/><path d="M11.5 6.5H15.5V2.5H11.5V6.5ZM17 7C17 7.51768 16.6067 7.94379 16.1025 7.99512L16 8H11L10.8975 7.99512C10.427 7.94722 10.0528 7.57297 10.0049 7.10254L10 7V2C10 1.44772 10.4477 1 11 1H16C16.5523 1 17 1.44772 17 2V7Z"/><path d="M11.5 15.5H15.5V11.5H11.5V15.5ZM17 16C17 16.5177 16.6067 16.9438 16.1025 16.9951L16 17H11L10.8975 16.9951C10.427 16.9472 10.0528 16.573 10.0049 16.1025L10 16V11C10 10.4477 10.4477 10 11 10H16C16.5523 10 17 10.4477 17 11V16Z"/>
4680
+ </svg>`,
4681
+
4682
+ "delete-table":
4683
+ `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
4684
+ <path d="M18.2129 19.2305C18.0925 20.7933 16.7892 22 15.2217 22H7.77832C6.21084 22 4.90753 20.7933 4.78711 19.2305L4 9H19L18.2129 19.2305Z"/><path d="M13 2C14.1046 2 15 2.89543 15 4H19C19.5523 4 20 4.44772 20 5V6C20 6.55228 19.5523 7 19 7H4C3.44772 7 3 6.55228 3 6V5C3 4.44772 3.44772 4 4 4H8C8 2.89543 8.89543 2 10 2H13Z"/>
4685
+ </svg>`
4686
+ };
4687
+
4688
+ return icons[name]
4689
+ }
4690
+ }
4691
+
4692
+ customElements.define("lexxy-table-handler", TableHandler);
4693
+
4056
4694
  class BaseSource {
4057
4695
  // Template method to override
4058
4696
  async buildListItems(filter = "") {
@@ -4738,24 +5376,17 @@ function highlightAll() {
4738
5376
 
4739
5377
  function highlightElement(preElement) {
4740
5378
  const language = preElement.getAttribute("data-language");
4741
-
4742
5379
  let code = preElement.innerHTML.replace(/<br\s*\/?>/gi, "\n");
4743
5380
 
4744
- const grammar = Prism.languages[language];
5381
+ const grammar = Prism.languages?.[language];
4745
5382
  if (!grammar) return
4746
5383
 
4747
5384
  // unescape HTML entities in the code block
4748
5385
  code = new DOMParser().parseFromString(code, "text/html").body.textContent || "";
4749
5386
 
4750
5387
  const highlightedHtml = Prism.highlight(code, grammar, language);
4751
-
4752
5388
  const codeElement = createElement("code", { "data-language": language, innerHTML: highlightedHtml });
4753
5389
  preElement.replaceWith(codeElement);
4754
5390
  }
4755
5391
 
4756
- // Manual highlighting mode to prevent invocation on every page. See https://prismjs.com/docs/prism
4757
- // This must happen before importing any Prism components
4758
- window.Prism = window.Prism || {};
4759
- Prism.manual = true;
4760
-
4761
5392
  export { highlightAll };
@@ -195,6 +195,42 @@
195
195
  .token.punctuation {
196
196
  color: var(--lexxy-color-code-token-punctuation);
197
197
  }
198
+
199
+ /* Tables */
200
+ :where(.lexxy-content__table-wrapper) {
201
+ margin-block: 1ch;
202
+ overflow-x: auto;
203
+ }
204
+
205
+ table {
206
+ border-collapse: collapse;
207
+ border-spacing: 0;
208
+ inline-size: calc(100% - 0.5ch);
209
+ margin: 0.25ch;
210
+
211
+ th,
212
+ td {
213
+ border: 1px solid var(--lexxy-color-ink-lighter);
214
+ padding: 1ch;
215
+ text-align: start;
216
+ word-break: normal;
217
+
218
+ *:last-child {
219
+ margin-block-end: 0;
220
+ }
221
+
222
+ &.lexxy-content__table-cell--header {
223
+ background-color: var(--lexxy-color-ink-lightest);
224
+ font-weight: bold;
225
+ }
226
+
227
+ *:is(code, pre) {
228
+ hyphens: auto;
229
+ text-wrap: wrap;
230
+ white-space: pre-wrap;
231
+ }
232
+ }
233
+ }
198
234
  }
199
235
 
200
236
  :where([data-lexical-cursor]) {
@@ -36,6 +36,7 @@
36
36
  cursor: pointer;
37
37
  font-size: inherit;
38
38
  inline-size: auto;
39
+ padding: 0;
39
40
 
40
41
  @media(any-hover: hover) {
41
42
  &:hover:not([aria-disabled="true"]) {
@@ -52,6 +53,23 @@
52
53
  }
53
54
  }
54
55
 
56
+ table {
57
+ .table-cell--selected {
58
+ background-color: var(--lexxy-color-table-cell-selected-bg) !important;
59
+ }
60
+
61
+ .lexxy-content__table-cell--selected {
62
+ background-color: var(--lexxy-color-table-cell-selected-bg) !important;
63
+ border-color: var(--lexxy-color-table-cell-selected-border) !important;
64
+ }
65
+
66
+ &.lexxy-content__table--selection {
67
+ ::selection {
68
+ background: transparent;
69
+ }
70
+ }
71
+ }
72
+
55
73
  action-text-attachment {
56
74
  cursor: pointer;
57
75
  }
@@ -189,7 +207,7 @@
189
207
  user-select: none;
190
208
  -webkit-user-select: none;
191
209
 
192
- .lexxy-editor__toolbar-dropdown-content {
210
+ :where(.lexxy-editor__toolbar-dropdown-content) {
193
211
  --dropdown-padding: 1ch;
194
212
  --dropdown-gap: calc(var(--dropdown-padding) / 2);
195
213
 
@@ -358,6 +376,212 @@
358
376
  }
359
377
  }
360
378
 
379
+ /* Table dropdown
380
+ /* -------------------------------------------------------------------------- */
381
+
382
+ :where(lexxy-table-dropdown) {
383
+ display: flex;
384
+ flex-direction: column;
385
+ gap: 1ch;
386
+
387
+ .lexxy-editor__table-create {
388
+ display: flex;
389
+ flex-direction: column;
390
+ flex-wrap: wrap;
391
+ gap: 0;
392
+
393
+ .lexxy-editor__table-buttons {
394
+ background-color: var(--lexxy-color-ink-lighter);
395
+ display: flex;
396
+ flex-direction: column;
397
+ gap: 1px;
398
+ padding: 1px;
399
+
400
+ div {
401
+ display: flex;
402
+ flex-direction: row;
403
+ gap: 1px;
404
+ }
405
+
406
+ button {
407
+ aspect-ratio: 1.5 / 1;
408
+ border: 0;
409
+ border-radius: 0;
410
+ color: var(--lexxy-color-ink);
411
+ font-family: var(--lexxy-font-base);
412
+ font-size: var(--lexxy-text-small);
413
+ font-weight: normal;
414
+ inline-size: 4ch;
415
+ margin: 0;
416
+
417
+ &.active {
418
+ background-color: var(--lexxy-color-ink-lightest);
419
+ }
420
+ }
421
+ }
422
+
423
+ label {
424
+ align-items: center;
425
+ display: flex;
426
+ gap: 0.5ch;
427
+ padding: 0.5ch 0;
428
+ margin-block-start: 1ch;
429
+ }
430
+
431
+ &:has(input[type="checkbox"]:checked) {
432
+ .lexxy-editor__table-buttons {
433
+ div:first-child button,
434
+ button:first-child {
435
+ filter: brightness(0.95);
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ .lexxy-editor__table-edit {
442
+ display: flex;
443
+ flex-direction: column;
444
+ flex-wrap: wrap;
445
+ gap: 0;
446
+
447
+ button {
448
+
449
+ }
450
+ }
451
+ }
452
+
453
+ /* Table handle buttons
454
+ /* -------------------------------------------------------------------------- */
455
+
456
+ :where(.lexxy-table-handle-buttons) {
457
+ --button-size: 2.5lh;
458
+ color: var(--lexxy-color-ink-inverted);
459
+ display: none;
460
+ flex-direction: row;
461
+ font-size: var(--lexxy-text-small);
462
+ gap: 0.25ch;
463
+ line-height: 1;
464
+ position: absolute;
465
+ transform: translate(-50%, -120%);
466
+ z-index: 10;
467
+
468
+ .lexxy-table-control {
469
+ align-items: center;
470
+ background-color: var(--lexxy-color-ink);
471
+ border-radius: 0.75ch;
472
+ display: flex;
473
+ flex-direction: row;
474
+ gap: 1ch;
475
+ padding: 2px;
476
+ white-space: nowrap;
477
+
478
+ button {
479
+ aspect-ratio: 1 / 1;
480
+ align-items: center;
481
+ background-color: transparent;
482
+ border-radius: var(--lexxy-radius);
483
+ border: 0;
484
+ color: var(--lexxy-color-ink-inverted);
485
+ cursor: pointer;
486
+ display: flex;
487
+ font-weight: bold;
488
+ justify-content: center;
489
+ line-height: 1;
490
+ min-block-size: var(--button-size);
491
+ min-inline-size: var(--button-size);
492
+ padding: 0;
493
+
494
+ &:hover,
495
+ &:focus-visible {
496
+ background-color: var(--lexxy-color-ink-medium);
497
+ }
498
+
499
+ svg {
500
+ block-size: 1em;
501
+ inline-size: 1em;
502
+ fill: currentColor;
503
+ }
504
+
505
+ span {
506
+ display: none;
507
+ }
508
+ }
509
+ }
510
+
511
+ .lexxy-table-control__more-menu {
512
+ gap: 0;
513
+ padding: 2px;
514
+ position: relative;
515
+
516
+ summary {
517
+ aspect-ratio: 1 / 1;
518
+ align-items: center;
519
+ background: transparent;
520
+ border-radius: var(--lexxy-radius);
521
+ border: 0;
522
+ box-sizing: border-box;
523
+ display: flex;
524
+ font-size: inherit;
525
+ justify-content: center;
526
+ list-style: none;
527
+ min-block-size: var(--button-size);
528
+ min-inline-size: var(--button-size);
529
+ padding: 0;
530
+ user-select: none;
531
+ -webkit-user-select: none;
532
+
533
+ &::-webkit-details-marker {
534
+ display: none;
535
+ }
536
+
537
+ &:hover {
538
+ background: var(--lexxy-color-ink-medium);
539
+ }
540
+ }
541
+
542
+ .lexxy-table-control__more-menu-details {
543
+ display: flex;
544
+ flex-direction: column;
545
+ gap: 0.25ch;
546
+ inset-block-start: 105%;
547
+ inset-inline-start: 0;
548
+ padding: 0;
549
+ position: absolute;
550
+
551
+ .lexxy-table-control__more-menu-section {
552
+ background: var(--lexxy-color-ink);
553
+ border-radius: 0.75ch;
554
+ display: flex;
555
+ flex-direction: column;
556
+ padding: 2px;
557
+ }
558
+
559
+ button {
560
+ aspect-ratio: unset;
561
+ align-items: center;
562
+ display: flex;
563
+ flex-direction: row;
564
+ font-weight: normal;
565
+ gap: 1ch;
566
+ justify-content: flex-start;
567
+ padding: 0.5ch 2ch;
568
+ padding-inline-start: 1ch;
569
+ white-space: nowrap;
570
+
571
+ span {
572
+ display: inline-block;
573
+ }
574
+
575
+ svg {
576
+ block-size: 1.3lh;
577
+ inline-size: 1.3lh;
578
+ fill: currentColor;
579
+ }
580
+ }
581
+ }
582
+ }
583
+ }
584
+
361
585
 
362
586
  /* Language picker
363
587
  /* -------------------------------------------------------------------------- */
@@ -35,6 +35,7 @@
35
35
  --lexxy-color-selected-dark: var(--lexxy-color-blue);
36
36
  --lexxy-color-code-bg: var(--lexxy-color-ink-lightest);
37
37
 
38
+ /* Text color highlights */
38
39
  --highlight-1: rgb(136, 118, 38);
39
40
  --highlight-2: rgb(185, 94, 6);
40
41
  --highlight-3: rgb(207, 0, 0);
@@ -55,6 +56,13 @@
55
56
  --highlight-bg-8: rgba(221, 170, 123, 0.3);
56
57
  --highlight-bg-9: rgba(200, 200, 200, 0.3);
57
58
 
59
+ /* Tables */
60
+ --lexxy-color-table-header-bg: var(--lexxy-color-ink-lightest);
61
+ --lexxy-color-table-cell-border: var(--lexxy-color-ink-lighter);
62
+ --lexxy-color-table-cell-selected: var(--lexxy-color-selected);
63
+ --lexxy-color-table-cell-selected-border: highlight;
64
+ --lexxy-color-table-cell-selected-bg: highlight;
65
+
58
66
  /* Typography */
59
67
  --lexxy-font-base: system-ui, sans-serif;
60
68
  --lexxy-font-mono: ui-monospace, "Menlo", "Monaco", Consolas, monospace;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.23-beta",
3
+ "version": "0.1.25-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",
@@ -13,6 +13,8 @@
13
13
  "license": "MIT",
14
14
  "devDependencies": {
15
15
  "@eslint/js": "^9.15.0",
16
+ "@rollup/plugin-commonjs": "^29.0.0",
17
+ "@rollup/plugin-inject": "^5.0.5",
16
18
  "@rollup/plugin-node-resolve": "^16.0.1",
17
19
  "@rollup/plugin-terser": "^0.4.4",
18
20
  "eslint": "^9.15.0",