@37signals/lexxy 0.1.25-beta → 0.1.26-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
@@ -10,8 +10,8 @@ import 'prismjs/components/prism-json';
10
10
  import 'prismjs/components/prism-diff';
11
11
  import DOMPurify from 'dompurify';
12
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';
13
+ import { $isTextNode, TextNode, $isRangeSelection, SKIP_DOM_SELECTION_TAG, $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, 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, ListNode, $getListDepth, $createListNode, ListItemNode, registerList } from '@lexical/list';
15
15
  import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
16
16
  import { $isCodeNode, CodeNode, normalizeCodeLang, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
17
17
  import { $isLinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
@@ -20,6 +20,7 @@ import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
20
20
  import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
21
21
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
22
22
  import { DirectUpload } from '@rails/activestorage';
23
+ import { $getNearestNodeOfType } from '@lexical/utils';
23
24
  import { marked } from 'marked';
24
25
 
25
26
  // Configure Prism for manual highlighting mode
@@ -145,6 +146,93 @@ function hasHighlightStyles(cssOrStyles) {
145
146
  return !!(styles.color || styles["background-color"])
146
147
  }
147
148
 
149
+ function handleRollingTabIndex(elements, event) {
150
+ const previousActiveElement = document.activeElement;
151
+
152
+ if (elements.includes(previousActiveElement)) {
153
+ const finder = new NextElementFinder(elements, event.key);
154
+
155
+ if (finder.selectNext(previousActiveElement)) {
156
+ event.preventDefault();
157
+ }
158
+ }
159
+ }
160
+
161
+ class NextElementFinder {
162
+ constructor(elements, key) {
163
+ this.elements = elements;
164
+ this.key = key;
165
+ }
166
+
167
+ selectNext(fromElement) {
168
+ const nextElement = this.#findNextElement(fromElement);
169
+
170
+ if (nextElement) {
171
+ const inactiveElements = this.elements.filter(element => element !== nextElement);
172
+ this.#unsetTabIndex(inactiveElements);
173
+ this.#focusWithActiveTabIndex(nextElement);
174
+ return true
175
+ }
176
+
177
+ return false
178
+ }
179
+
180
+ #findNextElement(fromElement) {
181
+ switch (this.key) {
182
+ case "ArrowRight":
183
+ case "ArrowDown":
184
+ return this.#findNextSibling(fromElement)
185
+
186
+ case "ArrowLeft":
187
+ case "ArrowUp":
188
+ return this.#findPreviousSibling(fromElement)
189
+
190
+ case "Home":
191
+ return this.#findFirst()
192
+
193
+ case "End":
194
+ return this.#findLast()
195
+ }
196
+ }
197
+
198
+ #findFirst(elements = this.elements) {
199
+ return elements.find(isActiveAndVisible)
200
+ }
201
+
202
+ #findLast(elements = this.elements) {
203
+ return elements.findLast(isActiveAndVisible)
204
+ }
205
+
206
+ #findNextSibling(element) {
207
+ const afterElements = this.elements.slice(this.#indexOf(element) + 1);
208
+ return this.#findFirst(afterElements)
209
+ }
210
+
211
+ #findPreviousSibling(element) {
212
+ const beforeElements = this.elements.slice(0, this.#indexOf(element));
213
+ return this.#findLast(beforeElements)
214
+ }
215
+
216
+ #indexOf(element) {
217
+ return this.elements.indexOf(element)
218
+ }
219
+
220
+ #focusWithActiveTabIndex(element) {
221
+ if (isActiveAndVisible(element)) {
222
+ element.tabIndex = 0;
223
+ element.focus();
224
+ }
225
+ }
226
+
227
+ #unsetTabIndex(elements) {
228
+ elements.forEach(element => element.tabIndex = -1);
229
+ }
230
+ }
231
+
232
+ function isActiveAndVisible(element) {
233
+ return element && !element.disabled && element.checkVisibility()
234
+ }
235
+
148
236
  class LexicalToolbarElement extends HTMLElement {
149
237
  static observedAttributes = [ "connected" ]
150
238
 
@@ -156,17 +244,14 @@ class LexicalToolbarElement extends HTMLElement {
156
244
 
157
245
  connectedCallback() {
158
246
  requestAnimationFrame(() => this.#refreshToolbarOverflow());
159
-
160
- this._resizeObserver = new ResizeObserver(() => this.#refreshToolbarOverflow());
161
- this._resizeObserver.observe(this);
247
+ this.setAttribute("role", "toolbar");
248
+ this.#installResizeObserver();
162
249
  }
163
250
 
164
251
  disconnectedCallback() {
165
- if (this._resizeObserver) {
166
- this._resizeObserver.disconnect();
167
- this._resizeObserver = null;
168
- }
252
+ this.#uninstallResizeObserver();
169
253
  this.#unbindHotkeys();
254
+ this.#unbindFocusListeners();
170
255
  }
171
256
 
172
257
  attributeChangedCallback(name, oldValue, newValue) {
@@ -180,11 +265,12 @@ class LexicalToolbarElement extends HTMLElement {
180
265
  this.editor = editorElement.editor;
181
266
  this.#bindButtons();
182
267
  this.#bindHotkeys();
183
- this.#setTabIndexValues();
268
+ this.#resetTabIndexValues();
184
269
  this.#setItemPositionValues();
185
270
  this.#monitorSelectionChanges();
186
271
  this.#monitorHistoryChanges();
187
272
  this.#refreshToolbarOverflow();
273
+ this.#bindFocusListeners();
188
274
 
189
275
  this.toggleAttribute("connected", true);
190
276
  }
@@ -194,24 +280,39 @@ class LexicalToolbarElement extends HTMLElement {
194
280
  this.connectedCallback();
195
281
  }
196
282
 
283
+ #installResizeObserver() {
284
+ this.resizeObserver = new ResizeObserver(() => this.#refreshToolbarOverflow());
285
+ this.resizeObserver.observe(this);
286
+ }
287
+
288
+ #uninstallResizeObserver() {
289
+ if (this.resizeObserver) {
290
+ this.resizeObserver.disconnect();
291
+ this.resizeObserver = null;
292
+ }
293
+ }
294
+
197
295
  #bindButtons() {
198
296
  this.addEventListener("click", this.#handleButtonClicked.bind(this));
199
297
  }
200
298
 
201
- #handleButtonClicked({ target }) {
202
- this.#handleTargetClicked(target, "[data-command]", this.#dispatchButtonCommand.bind(this));
299
+ #handleButtonClicked(event) {
300
+ this.#handleTargetClicked(event, "[data-command]", this.#dispatchButtonCommand.bind(this));
203
301
  }
204
302
 
205
- #handleTargetClicked(target, selector, callback) {
206
- const button = target.closest(selector);
303
+ #handleTargetClicked(event, selector, callback) {
304
+ const button = event.target.closest(selector);
207
305
  if (button) {
208
- callback(button);
306
+ callback(event, button);
209
307
  }
210
308
  }
211
309
 
212
- #dispatchButtonCommand(button) {
213
- const { command, payload } = button.dataset;
214
- this.editor.dispatchCommand(command, payload);
310
+ #dispatchButtonCommand(event, { dataset: { command, payload } }) {
311
+ const isKeyboard = event instanceof PointerEvent && event.pointerId === -1;
312
+
313
+ this.editor.update(() => {
314
+ this.editor.dispatchCommand(command, payload);
315
+ }, { tag: isKeyboard ? SKIP_DOM_SELECTION_TAG : undefined } );
215
316
  }
216
317
 
217
318
  #bindHotkeys() {
@@ -246,9 +347,38 @@ class LexicalToolbarElement extends HTMLElement {
246
347
  return [ ...modifiers, pressedKey ].join("+")
247
348
  }
248
349
 
249
- #setTabIndexValues() {
250
- this.#buttons.forEach((button) => {
251
- button.setAttribute("tabindex", 0);
350
+ #bindFocusListeners() {
351
+ this.editorElement.addEventListener("lexxy:focus", this.#handleFocus);
352
+ this.editorElement.addEventListener("lexxy:blur", this.#handleFocusOut);
353
+ this.addEventListener("focusout", this.#handleFocusOut);
354
+ this.addEventListener("keydown", this.#handleKeydown);
355
+ }
356
+
357
+ #unbindFocusListeners() {
358
+ this.editorElement.removeEventListener("lexxy:focus", this.#handleFocus);
359
+ this.editorElement.removeEventListener("lexxy:blur", this.#handleFocusOut);
360
+ this.removeEventListener("focusout", this.#handleFocusOut);
361
+ this.removeEventListener("keydown", this.#handleKeydown);
362
+ }
363
+
364
+ #handleFocus = () => {
365
+ this.#resetTabIndexValues();
366
+ this.#focusableItems[0].tabIndex = 0;
367
+ }
368
+
369
+ #handleFocusOut = () => {
370
+ if (!this.contains(document.activeElement)) {
371
+ this.#resetTabIndexValues();
372
+ }
373
+ }
374
+
375
+ #handleKeydown = (event) => {
376
+ handleRollingTabIndex(this.#focusableItems, event);
377
+ }
378
+
379
+ #resetTabIndexValues() {
380
+ this.#focusableItems.forEach((button) => {
381
+ button.tabIndex = -1;
252
382
  });
253
383
  }
254
384
 
@@ -358,6 +488,7 @@ class LexicalToolbarElement extends HTMLElement {
358
488
 
359
489
  const isOverflowing = this.#overflowMenu.children.length > 0;
360
490
  this.toggleAttribute("overflowing", isOverflowing);
491
+ this.#overflowMenu.toggleAttribute("disabled", !isOverflowing);
361
492
  }
362
493
 
363
494
  #compactMenu() {
@@ -409,6 +540,10 @@ class LexicalToolbarElement extends HTMLElement {
409
540
  return Array.from(this.querySelectorAll(":scope > button"))
410
541
  }
411
542
 
543
+ get #focusableItems() {
544
+ return Array.from(this.querySelectorAll(":scope button, :scope > details > summary"))
545
+ }
546
+
412
547
  get #toolbarItems() {
413
548
  return Array.from(this.querySelectorAll(":scope > *:not(.lexxy-editor__toolbar-overflow)"))
414
549
  }
@@ -1386,7 +1521,7 @@ class CommandDispatcher {
1386
1521
  }
1387
1522
 
1388
1523
  #registerKeyboardCommands() {
1389
- this.editor.registerCommand(KEY_TAB_COMMAND, this.#handleListIndentation.bind(this), COMMAND_PRIORITY_NORMAL);
1524
+ this.editor.registerCommand(KEY_TAB_COMMAND, this.#handleTabKey.bind(this), COMMAND_PRIORITY_NORMAL);
1390
1525
  }
1391
1526
 
1392
1527
  #registerDragAndDropHandlers() {
@@ -1436,18 +1571,28 @@ class CommandDispatcher {
1436
1571
  this.editor.focus();
1437
1572
  }
1438
1573
 
1439
- #handleListIndentation(event) {
1574
+ #handleTabKey(event) {
1440
1575
  if (this.selection.isInsideList) {
1441
- event.preventDefault();
1442
- if (event.shiftKey) {
1443
- return this.editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
1444
- } else {
1445
- return this.editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
1446
- }
1576
+ return this.#handleTabForList(event)
1577
+ } else if (this.selection.isInsideCodeBlock) {
1578
+ return this.#handleTabForCode()
1447
1579
  }
1448
1580
  return false
1449
1581
  }
1450
1582
 
1583
+ #handleTabForList(event) {
1584
+ if (event.shiftKey && !this.selection.isIndentedList) return false
1585
+
1586
+ event.preventDefault();
1587
+ const command = event.shiftKey? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND;
1588
+ return this.editor.dispatchCommand(command)
1589
+ }
1590
+
1591
+ #handleTabForCode() {
1592
+ const selection = $getSelection();
1593
+ return $isRangeSelection(selection) && selection.isCollapsed()
1594
+ }
1595
+
1451
1596
  // Not using TOGGLE_LINK_COMMAND because it's not handled unless you use React/LinkPlugin
1452
1597
  #toggleLink(url) {
1453
1598
  this.editor.update(() => {
@@ -1624,6 +1769,29 @@ class Selection {
1624
1769
  return getNearestListItemNode(anchorNode) !== null
1625
1770
  }
1626
1771
 
1772
+ get isIndentedList() {
1773
+ const selection = $getSelection();
1774
+ if (!$isRangeSelection(selection)) return false
1775
+
1776
+ const nodes = selection.getNodes();
1777
+ for (const node of nodes) {
1778
+ const closestListNode = $getNearestNodeOfType(node, ListNode);
1779
+ if (closestListNode && $getListDepth(closestListNode) > 1) {
1780
+ return true
1781
+ }
1782
+ }
1783
+
1784
+ return false
1785
+ }
1786
+
1787
+ get isInsideCodeBlock() {
1788
+ const selection = $getSelection();
1789
+ if (!$isRangeSelection(selection)) return false
1790
+
1791
+ const anchorNode = selection.anchor.getNode();
1792
+ return $getNearestNodeOfType(anchorNode, CodeNode) !== null
1793
+ }
1794
+
1627
1795
  get nodeAfterCursor() {
1628
1796
  const { anchorNode, offset } = this.#getCollapsedSelectionData();
1629
1797
  if (!anchorNode) return null
@@ -3971,8 +4139,6 @@ class ToolbarDropdown extends HTMLElement {
3971
4139
 
3972
4140
  this.container.addEventListener("toggle", this.#handleToggle.bind(this));
3973
4141
  this.container.addEventListener("keydown", this.#handleKeyDown.bind(this));
3974
-
3975
- this.#setTabIndexValues();
3976
4142
  }
3977
4143
 
3978
4144
  disconnectedCallback() {
@@ -4000,14 +4166,14 @@ class ToolbarDropdown extends HTMLElement {
4000
4166
  }
4001
4167
  }
4002
4168
 
4003
- #handleOpen(trigger) {
4004
- this.trigger = trigger;
4169
+ #handleOpen() {
4005
4170
  this.#interactiveElements[0].focus();
4006
4171
  this.#setupClickOutsideHandler();
4172
+
4173
+ this.#resetTabIndexValues();
4007
4174
  }
4008
4175
 
4009
4176
  #handleClose() {
4010
- this.trigger = null;
4011
4177
  this.#removeClickOutsideHandler();
4012
4178
  this.editor.focus();
4013
4179
  }
@@ -4037,16 +4203,20 @@ class ToolbarDropdown extends HTMLElement {
4037
4203
  }
4038
4204
  }
4039
4205
 
4040
- async #setTabIndexValues() {
4206
+ async #resetTabIndexValues() {
4041
4207
  await nextFrame();
4042
- this.#interactiveElements.forEach((element) => {
4043
- element.setAttribute("tabindex", 0);
4208
+ this.#buttons.forEach((element, index) => {
4209
+ element.setAttribute("tabindex", index === 0 ? 0 : "-1");
4044
4210
  });
4045
4211
  }
4046
4212
 
4047
4213
  get #interactiveElements() {
4048
4214
  return Array.from(this.querySelectorAll("button, input"))
4049
4215
  }
4216
+
4217
+ get #buttons() {
4218
+ return Array.from(this.querySelectorAll("button"))
4219
+ }
4050
4220
  }
4051
4221
 
4052
4222
  class LinkDropdown extends ToolbarDropdown {
@@ -4242,15 +4412,19 @@ class TableHandler extends HTMLElement {
4242
4412
  return $getTableColumnIndexFromTableCellNode(currentCell)
4243
4413
  }
4244
4414
 
4415
+ get #tableHandlerButtons() {
4416
+ return Array.from(this.buttonsContainer.querySelectorAll("button, details > summary"))
4417
+ }
4418
+
4245
4419
  #registerKeyboardShortcuts() {
4246
- this.unregisterKeyboardShortcuts = this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleKeyDown.bind(this), COMMAND_PRIORITY_HIGH);
4420
+ this.unregisterKeyboardShortcuts = this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleKeyDown, COMMAND_PRIORITY_HIGH);
4247
4421
  }
4248
4422
 
4249
4423
  #unregisterKeyboardShortcuts() {
4250
4424
  this.unregisterKeyboardShortcuts();
4251
4425
  }
4252
4426
 
4253
- #handleKeyDown(event) {
4427
+ #handleKeyDown = (event) => {
4254
4428
  if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "F10") {
4255
4429
  const firstButton = this.buttonsContainer?.querySelector("button, [tabindex]:not([tabindex='-1'])");
4256
4430
  this.#setFocusStateOnSelectedCell();
@@ -4268,6 +4442,14 @@ class TableHandler extends HTMLElement {
4268
4442
  }
4269
4443
  }
4270
4444
 
4445
+ #handleTableHandlerKeydown = (event) => {
4446
+ if (event.key === "Escape") {
4447
+ this.#editor.focus();
4448
+ } else {
4449
+ handleRollingTabIndex(this.#tableHandlerButtons, event);
4450
+ }
4451
+ }
4452
+
4271
4453
  #setUpButtons() {
4272
4454
  this.buttonsContainer = createElement("div", {
4273
4455
  className: "lexxy-table-handle-buttons"
@@ -4278,6 +4460,7 @@ class TableHandler extends HTMLElement {
4278
4460
 
4279
4461
  this.moreMenu = this.#createMoreMenu();
4280
4462
  this.buttonsContainer.appendChild(this.moreMenu);
4463
+ this.buttonsContainer.addEventListener("keydown", this.#handleTableHandlerKeydown);
4281
4464
 
4282
4465
  this.#editorElement.appendChild(this.buttonsContainer);
4283
4466
  }
@@ -437,17 +437,6 @@
437
437
  }
438
438
  }
439
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
440
  }
452
441
 
453
442
  /* Table handle buttons
@@ -455,6 +444,7 @@
455
444
 
456
445
  :where(.lexxy-table-handle-buttons) {
457
446
  --button-size: 2.5lh;
447
+
458
448
  color: var(--lexxy-color-ink-inverted);
459
449
  display: none;
460
450
  flex-direction: row;
@@ -491,8 +481,7 @@
491
481
  min-inline-size: var(--button-size);
492
482
  padding: 0;
493
483
 
494
- &:hover,
495
- &:focus-visible {
484
+ &:hover {
496
485
  background-color: var(--lexxy-color-ink-medium);
497
486
  }
498
487
 
@@ -506,6 +495,16 @@
506
495
  display: none;
507
496
  }
508
497
  }
498
+
499
+ button,
500
+ summary {
501
+ &:focus,
502
+ &:focus-visible {
503
+ background-color: var(--lexxy-color-ink-medium);
504
+ outline: var(--lexxy-focus-ring-size) solid var(--lexxy-focus-ring-color);
505
+ outline-offset: var(--lexxy-focus-ring-offset);
506
+ }
507
+ }
509
508
  }
510
509
 
511
510
  .lexxy-table-control__more-menu {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.25-beta",
3
+ "version": "0.1.26-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",
@@ -25,7 +25,7 @@
25
25
  "scripts": {
26
26
  "build": "rollup -c",
27
27
  "build:npm": "rollup -c rollup.config.npm.mjs",
28
- "watch": "NODE_ENV=development rollup -wc --watch.onEnd=\"rails restart\"",
28
+ "watch": "rollup -wc --watch.onEnd=\"rails restart\"",
29
29
  "lint": "eslint",
30
30
  "prerelease": "yarn build:npm",
31
31
  "release": "yarn build:npm && yarn publish"