@37signals/lexxy 0.1.20-beta → 0.1.22-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.4.beta' # Need to specify the version since it's a pre-release
26
+ gem 'lexxy', '~> 0.1.21.beta' # Need to specify the version since it's a pre-release
27
27
  ```
28
28
 
29
29
  And then execute:
@@ -355,6 +355,11 @@ Each event is dispatched on the `<lexxy-editor>` element.
355
355
  Fired when the `<lexxy-editor>` element is attached to the DOM and ready for use.
356
356
  This is useful for one-time setup.
357
357
 
358
+ ### lexxy:focus and lexxy:blur
359
+
360
+ Fired whenever the editor element gains or loses focus.
361
+ Useful to show or hide accessory UI state.
362
+
358
363
  ### `lexxy:change`
359
364
 
360
365
  Fired whenever the editor content changes.
package/dist/lexxy.esm.js CHANGED
@@ -1,15 +1,51 @@
1
1
  import DOMPurify from 'dompurify';
2
- import { $getSelection, $isRangeSelection, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, $isNodeSelection, $getRoot, $isLineBreakNode, $isTextNode, $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, COMMAND_PRIORITY_NORMAL, KEY_TAB_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
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, $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, COMMAND_PRIORITY_NORMAL, BLUR_COMMAND, FOCUS_COMMAND, KEY_TAB_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
3
4
  import { $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, ListNode, ListItemNode, registerList } from '@lexical/list';
4
5
  import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
5
- import { $isCodeNode, CodeNode, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP, normalizeCodeLang } from '@lexical/code';
6
+ import { $isCodeNode, CodeNode, normalizeCodeLang, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
6
7
  import { $isLinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
8
+ import 'prismjs/components/prism-ruby';
9
+ import 'prismjs/components/prism-php';
10
+ import 'prismjs/components/prism-go';
11
+ import 'prismjs/components/prism-bash';
12
+ import 'prismjs/components/prism-json';
13
+ import 'prismjs/components/prism-diff';
7
14
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
8
15
  import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
9
16
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
10
17
  import { DirectUpload } from '@rails/activestorage';
11
18
  import { marked } from 'marked';
12
- import 'prismjs/components/prism-ruby';
19
+
20
+ 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" ];
22
+
23
+ const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
24
+ "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation",
25
+ "previewable", "sgid", "src", "style", "title", "url", "width" ];
26
+
27
+ const ALLOWED_STYLE_PROPERTIES = [ "color", "background-color" ];
28
+
29
+ function styleFilterHook(_currentNode, hookEvent) {
30
+ if (hookEvent.attrName === "style" && hookEvent.attrValue) {
31
+ const styles = { ...getStyleObjectFromCSS(hookEvent.attrValue) };
32
+ const sanitizedStyles = { };
33
+
34
+ for (const property in styles) {
35
+ if (ALLOWED_STYLE_PROPERTIES.includes(property)) {
36
+ sanitizedStyles[property] = styles[property];
37
+ }
38
+ }
39
+
40
+ if (Object.keys(sanitizedStyles).length) {
41
+ hookEvent.attrValue = getCSSFromStyleObject(sanitizedStyles);
42
+ } else {
43
+ hookEvent.keepAttr = false;
44
+ }
45
+ }
46
+ }
47
+
48
+ DOMPurify.addHook("uponSanitizeAttribute", styleFilterHook);
13
49
 
14
50
  DOMPurify.addHook("uponSanitizeElement", (node, data) => {
15
51
  if (data.tagName === "strong" || data.tagName === "em") {
@@ -17,6 +53,12 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => {
17
53
  }
18
54
  });
19
55
 
56
+ DOMPurify.setConfig({
57
+ ALLOWED_TAGS: ALLOWED_HTML_TAGS,
58
+ ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
59
+ SAFE_FOR_XML: false // So that it does not strip attributes that contains serialized HTML (like content)
60
+ });
61
+
20
62
  function getNonce() {
21
63
  const element = document.head.querySelector("meta[name=csp-nonce]");
22
64
  return element?.content
@@ -53,6 +95,46 @@ function isPrintableCharacter(event) {
53
95
  return event.key.length === 1
54
96
  }
55
97
 
98
+ function extendTextNodeConversion(conversionName, callback = (textNode => textNode)) {
99
+ return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
100
+ ...conversionOutput,
101
+ forChild: (lexicalNode, parentNode) => {
102
+ const originalForChild = conversionOutput?.forChild ?? (x => x);
103
+ let childNode = originalForChild(lexicalNode, parentNode);
104
+
105
+ if ($isTextNode(childNode)) childNode = callback(childNode, element) ?? childNode;
106
+ return childNode
107
+ }
108
+ }))
109
+ }
110
+
111
+ function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
112
+ return (element) => {
113
+ const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
114
+ if (!converter) return null
115
+
116
+ const conversionOutput = converter.conversion(element);
117
+ if (!conversionOutput) return conversionOutput
118
+
119
+ return callback(conversionOutput, element) ?? conversionOutput
120
+ }
121
+ }
122
+
123
+ function isSelectionHighlighted(selection) {
124
+ if (!$isRangeSelection(selection)) return false
125
+
126
+ if (selection.isCollapsed()) {
127
+ return hasHighlightStyles(selection.style)
128
+ } else {
129
+ return selection.hasFormat("highlight")
130
+ }
131
+ }
132
+
133
+ function hasHighlightStyles(cssOrStyles) {
134
+ const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
135
+ return !!(styles.color || styles["background-color"])
136
+ }
137
+
56
138
  class LexicalToolbarElement extends HTMLElement {
57
139
  constructor() {
58
140
  super();
@@ -83,6 +165,14 @@ class LexicalToolbarElement extends HTMLElement {
83
165
  this.#monitorSelectionChanges();
84
166
  this.#monitorHistoryChanges();
85
167
  this.#refreshToolbarOverflow();
168
+
169
+ this.toggleAttribute("connected", true);
170
+ }
171
+
172
+ get #dialogs() {
173
+ const dialogButtons = this.querySelectorAll("[data-dialog-target]");
174
+ const dialogTags = Array.from(dialogButtons).map(button => `lexxy-${button.dataset.dialogTarget}`);
175
+ return Array.from(this.querySelectorAll(dialogTags))
86
176
  }
87
177
 
88
178
  #bindButtons() {
@@ -108,15 +198,25 @@ class LexicalToolbarElement extends HTMLElement {
108
198
 
109
199
  // Not using popover because of CSS anchoring still not widely available.
110
200
  #toggleDialog(button) {
111
- const dialog = this.querySelector("lexxy-link-dialog .link-dialog").parentNode;
201
+ const dialogTarget = button.dataset.dialogTarget;
202
+ const dialog = this.querySelector("lexxy-" + dialogTarget);
203
+ if (!dialog) return
112
204
 
113
205
  if (dialog.open) {
114
206
  dialog.close();
115
207
  } else {
116
- dialog.show();
208
+ this.#closeOpenDialogs();
209
+ dialog.show(button);
117
210
  }
118
211
  }
119
212
 
213
+ #closeOpenDialogs() {
214
+ const openDialogs = this.querySelectorAll("dialog[open]");
215
+ openDialogs.forEach(openDialog => {
216
+ openDialog.closest(".lexxy-dialog").close();
217
+ });
218
+ }
219
+
120
220
  #bindHotkeys() {
121
221
  this.editorElement.addEventListener("keydown", (event) => {
122
222
  const buttons = this.querySelectorAll("[data-hotkey]");
@@ -154,6 +254,7 @@ class LexicalToolbarElement extends HTMLElement {
154
254
  this.editor.registerUpdateListener(() => {
155
255
  this.editor.getEditorState().read(() => {
156
256
  this.#updateButtonStates();
257
+ this.#updateDialogStates();
157
258
  });
158
259
  });
159
260
  }
@@ -174,14 +275,6 @@ class LexicalToolbarElement extends HTMLElement {
174
275
  });
175
276
  }
176
277
 
177
- #setButtonDisabled(name, isDisabled) {
178
- const button = this.querySelector(`[name="${name}"]`);
179
- if (button) {
180
- button.disabled = isDisabled;
181
- button.setAttribute("aria-disabled", isDisabled.toString());
182
- }
183
- }
184
-
185
278
  #updateButtonStates() {
186
279
  const selection = $getSelection();
187
280
  if (!$isRangeSelection(selection)) return
@@ -194,26 +287,32 @@ class LexicalToolbarElement extends HTMLElement {
194
287
  const isBold = selection.hasFormat("bold");
195
288
  const isItalic = selection.hasFormat("italic");
196
289
  const isStrikethrough = selection.hasFormat("strikethrough");
290
+ const isHighlight = isSelectionHighlighted(selection);
291
+ const isInLink = this.#isInLink(anchorNode);
292
+ const isInQuote = $isQuoteNode(topLevelElement);
293
+ const isInHeading = $isHeadingNode(topLevelElement);
197
294
  const isInCode = $isCodeNode(topLevelElement) || selection.hasFormat("code");
198
295
  const isInList = this.#isInList(anchorNode);
199
296
  const listType = getListType(anchorNode);
200
- const isInQuote = $isQuoteNode(topLevelElement);
201
- const isInHeading = $isHeadingNode(topLevelElement);
202
- const isInLink = this.#isInLink(anchorNode);
203
297
 
204
298
  this.#setButtonPressed("bold", isBold);
205
299
  this.#setButtonPressed("italic", isItalic);
206
300
  this.#setButtonPressed("strikethrough", isStrikethrough);
301
+ this.#setButtonPressed("highlight", isHighlight);
302
+ this.#setButtonPressed("link", isInLink);
303
+ this.#setButtonPressed("quote", isInQuote);
304
+ this.#setButtonPressed("heading", isInHeading);
207
305
  this.#setButtonPressed("code", isInCode);
208
306
  this.#setButtonPressed("unordered-list", isInList && listType === "bullet");
209
307
  this.#setButtonPressed("ordered-list", isInList && listType === "number");
210
- this.#setButtonPressed("quote", isInQuote);
211
- this.#setButtonPressed("heading", isInHeading);
212
- this.#setButtonPressed("link", isInLink);
213
308
 
214
309
  this.#updateUndoRedoButtonStates();
215
310
  }
216
311
 
312
+ #updateDialogStates() {
313
+ this.#dialogs.forEach(dialog => dialog.updateStateCallback());
314
+ }
315
+
217
316
  #isInList(node) {
218
317
  let current = node;
219
318
  while (current) {
@@ -239,6 +338,14 @@ class LexicalToolbarElement extends HTMLElement {
239
338
  }
240
339
  }
241
340
 
341
+ #setButtonDisabled(name, isDisabled) {
342
+ const button = this.querySelector(`[name="${name}"]`);
343
+ if (button) {
344
+ button.disabled = isDisabled;
345
+ button.setAttribute("aria-disabled", isDisabled.toString());
346
+ }
347
+ }
348
+
242
349
  #toolbarIsOverflowing() {
243
350
  return this.scrollWidth > this.clientWidth
244
351
  }
@@ -249,6 +356,9 @@ class LexicalToolbarElement extends HTMLElement {
249
356
 
250
357
  this.#overflow.style.display = this.#overflowMenu.children.length ? "block" : "none";
251
358
  this.#overflow.setAttribute("nonce", getNonce());
359
+
360
+ const isOverflowing = this.#overflowMenu.children.length > 0;
361
+ this.toggleAttribute("overflowing", isOverflowing);
252
362
  }
253
363
 
254
364
  get #overflow() {
@@ -266,7 +376,7 @@ class LexicalToolbarElement extends HTMLElement {
266
376
  }
267
377
 
268
378
  #compactMenu() {
269
- const buttons = this.#buttons.reverse();
379
+ const buttons = this.#buttonsWithSeparator.reverse();
270
380
  let movedToOverflow = false;
271
381
 
272
382
  for (const button of buttons) {
@@ -281,6 +391,10 @@ class LexicalToolbarElement extends HTMLElement {
281
391
  }
282
392
 
283
393
  get #buttons() {
394
+ return Array.from(this.querySelectorAll(":scope > button"))
395
+ }
396
+
397
+ get #buttonsWithSeparator() {
284
398
  return Array.from(this.querySelectorAll(":scope > button, :scope > [role=separator]"))
285
399
  }
286
400
 
@@ -300,12 +414,22 @@ class LexicalToolbarElement extends HTMLElement {
300
414
  </svg>
301
415
  </button>
302
416
 
303
- <button class="lexxy-editor__toolbar-button" type="button" name="link" title="Link" data-dialog-target="link-dialog" data-hotkey="cmd+k ctrl+k">
304
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.111 9.546a1.5 1.5 0 012.121 0 5.5 5.5 0 010 7.778l-2.828 2.828a5.5 5.5 0 01-7.778 0 5.498 5.498 0 010-7.777l2.828-2.83a1.5 1.5 0 01.355-.262 6.52 6.52 0 00.351 3.799l-1.413 1.414a2.499 2.499 0 000 3.535 2.499 2.499 0 003.535 0l2.83-2.828a2.5 2.5 0 000-3.536 1.5 1.5 0 010-2.121z"/><path d="M12.111 3.89a5.5 5.5 0 117.778 7.777l-2.828 2.829a1.496 1.496 0 01-.355.262 6.522 6.522 0 00-.351-3.8l1.413-1.412a2.5 2.5 0 10-3.536-3.535l-2.828 2.828a2.5 2.5 0 000 3.536 1.5 1.5 0 01-2.122 2.12 5.5 5.5 0 010-7.777l2.83-2.829z"/></svg>
417
+ <lexxy-highlight-dialog class="lexxy-dialog lexxy-highlight-dialog">
418
+ <dialog class="highlight-dialog">
419
+ <div class="lexxy-highlight-dialog-content">
420
+ <div data-button-group="color" data-values="var(--highlight-1); var(--highlight-2); var(--highlight-3); var(--highlight-4); var(--highlight-5); var(--highlight-6); var(--highlight-7); var(--highlight-8); var(--highlight-9)"></div>
421
+ <div data-button-group="background-color" data-values="var(--highlight-bg-1); var(--highlight-bg-2); var(--highlight-bg-3); var(--highlight-bg-4); var(--highlight-bg-5); var(--highlight-bg-6); var(--highlight-bg-7); var(--highlight-bg-8); var(--highlight-bg-9)"></div>
422
+ <button data-command="removeHighlight" class="lexxy-highlight-dialog-reset">Remove all coloring</button>
423
+ </div>
424
+ </dialog>
425
+ </lexxy-highlight-dialog>
426
+
427
+ <button class="lexxy-editor__toolbar-button" type="button" name="highlight" title="Color highlight" data-dialog-target="highlight-dialog">
428
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.65422 0.711575C7.1856 0.242951 6.42579 0.242951 5.95717 0.711575C5.48853 1.18021 5.48853 1.94 5.95717 2.40864L8.70864 5.16011L2.85422 11.0145C1.44834 12.4204 1.44833 14.6998 2.85422 16.1057L7.86011 21.1115C9.26599 22.5174 11.5454 22.5174 12.9513 21.1115L19.6542 14.4087C20.1228 13.94 20.1228 13.1802 19.6542 12.7115L11.8544 4.91171L11.2542 4.31158L7.65422 0.711575ZM4.55127 12.7115L10.4057 6.85716L17.1087 13.56H4.19981C4.19981 13.253 4.31696 12.9459 4.55127 12.7115ZM23.6057 20.76C23.6057 22.0856 22.5311 23.16 21.2057 23.16C19.8802 23.16 18.8057 22.0856 18.8057 20.76C18.8057 19.5408 19.8212 18.5339 20.918 17.4462C21.0135 17.3516 21.1096 17.2563 21.2057 17.16C21.3018 17.2563 21.398 17.3516 21.4935 17.4462C22.5903 18.5339 23.6057 19.5408 23.6057 20.76Z"/></svg>
305
429
  </button>
306
430
 
307
- <lexxy-link-dialog class="lexxy-link-dialog">
308
- <dialog class="link-dialog" closedby="any">
431
+ <lexxy-link-dialog class="lexxy-dialog lexxy-link-dialog">
432
+ <dialog class="link-dialog">
309
433
  <form method="dialog">
310
434
  <input type="url" placeholder="Enter a URL…" class="input" required>
311
435
  <div class="lexxy-dialog-actions">
@@ -316,6 +440,10 @@ class LexicalToolbarElement extends HTMLElement {
316
440
  </dialog>
317
441
  </lexxy-link-dialog>
318
442
 
443
+ <button class="lexxy-editor__toolbar-button" type="button" name="link" title="Link" data-dialog-target="link-dialog" data-hotkey="cmd+k ctrl+k">
444
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.111 9.546a1.5 1.5 0 012.121 0 5.5 5.5 0 010 7.778l-2.828 2.828a5.5 5.5 0 01-7.778 0 5.498 5.498 0 010-7.777l2.828-2.83a1.5 1.5 0 01.355-.262 6.52 6.52 0 00.351 3.799l-1.413 1.414a2.499 2.499 0 000 3.535 2.499 2.499 0 003.535 0l2.83-2.828a2.5 2.5 0 000-3.536 1.5 1.5 0 010-2.121z"/><path d="M12.111 3.89a5.5 5.5 0 117.778 7.777l-2.828 2.829a1.496 1.496 0 01-.355.262 6.522 6.522 0 00-.351-3.8l1.413-1.412a2.5 2.5 0 10-3.536-3.535l-2.828 2.828a2.5 2.5 0 000 3.536 1.5 1.5 0 01-2.122 2.12 5.5 5.5 0 010-7.777l2.83-2.829z"/></svg>
445
+ </button>
446
+
319
447
  <button class="lexxy-editor__toolbar-button" type="button" name="quote" data-command="insertQuoteBlock" title="Quote">
320
448
  <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.5 5C8.985 5 11 7.09 11 9.667c0 2.694-.962 5.005-2.187 6.644-.613.82-1.3 1.481-1.978 1.943-.668.454-1.375.746-2.022.746a.563.563 0 01-.52-.36.602.602 0 01.067-.57l.055-.066.009-.009.041-.048a4.25 4.25 0 00.168-.21c.143-.188.336-.47.53-.84a6.743 6.743 0 00.75-2.605C3.705 13.994 2 12.038 2 9.667 2 7.089 4.015 5 6.5 5zM17.5 5C19.985 5 22 7.09 22 9.667c0 2.694-.962 5.005-2.187 6.644-.613.82-1.3 1.481-1.978 1.943-.668.454-1.375.746-2.023.746a.563.563 0 01-.52-.36.602.602 0 01.068-.57l.055-.066.009-.009.041-.048c.039-.045.097-.115.168-.21a6.16 6.16 0 00.53-.84 6.745 6.745 0 00.75-2.605C14.705 13.994 13 12.038 13 9.667 13 7.089 15.015 5 17.5 5z"/></svg>
321
449
  </button>
@@ -343,9 +471,9 @@ class LexicalToolbarElement extends HTMLElement {
343
471
  <button class="lexxy-editor__toolbar-button" type="button" name="divider" data-command="insertHorizontalDivider" title="Insert a divider">
344
472
  <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>
345
473
  </button>
346
-
474
+
347
475
  <div class="lexxy-editor__toolbar-spacer" role="separator"></div>
348
-
476
+
349
477
  <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo" data-hotkey="cmd+z ctrl+z">
350
478
  <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5.64648 8.26531C7.93911 6.56386 10.7827 5.77629 13.624 6.05535C16.4655 6.33452 19.1018 7.66079 21.0195 9.77605C22.5839 11.5016 23.5799 13.6516 23.8936 15.9352C24.0115 16.7939 23.2974 17.4997 22.4307 17.4997C21.5641 17.4997 20.8766 16.7915 20.7148 15.9401C20.4295 14.4379 19.7348 13.0321 18.6943 11.8844C17.3 10.3464 15.3835 9.38139 13.3174 9.17839C11.2514 8.97546 9.18359 9.54856 7.5166 10.7858C6.38259 11.6275 5.48981 12.7361 4.90723 13.9997H8.5C9.3283 13.9997 9.99979 14.6714 10 15.4997C10 16.3281 9.32843 16.9997 8.5 16.9997H1.5C0.671573 16.9997 0 16.3281 0 15.4997V8.49968C0.000213656 7.67144 0.671705 6.99968 1.5 6.99968C2.3283 6.99968 2.99979 7.67144 3 8.49968V11.0212C3.7166 9.9704 4.60793 9.03613 5.64648 8.26531Z"/></svg>
351
479
  </button>
@@ -370,8 +498,15 @@ var theme = {
370
498
  italic: "lexxy-content__italic",
371
499
  strikethrough: "lexxy-content__strikethrough",
372
500
  underline: "lexxy-content__underline",
501
+ highlight: "lexxy-content__highlight"
502
+ },
503
+ list: {
504
+ nested: {
505
+ listitem: "lexxy-nested-listitem",
506
+ }
373
507
  },
374
508
  codeHighlight: {
509
+ addition: "code-token__selector",
375
510
  atrule: "code-token__attr",
376
511
  attr: "code-token__attr",
377
512
  "attr-name": "code-token__attr",
@@ -386,24 +521,30 @@ var theme = {
386
521
  color: "code-token__property",
387
522
  comment: "code-token__comment",
388
523
  constant: "code-token__property",
389
- coord: "code-token__property",
524
+ coord: "code-token__comment",
390
525
  decorator: "code-token__function",
391
- deleted: "code-token__property",
526
+ deleted: "code-token__operator",
527
+ deletion: "code-token__operator",
528
+ directive: "code-token__attr",
529
+ "directive-hash": "code-token__property",
392
530
  doctype: "code-token__comment",
393
531
  entity: "code-token__operator",
394
532
  function: "code-token__function",
395
533
  hexcode: "code-token__property",
396
- important: "code-token__variable",
534
+ important: "code-token__function",
397
535
  inserted: "code-token__selector",
398
536
  italic: "code-token__comment",
399
537
  keyword: "code-token__attr",
538
+ line: "code-token__selector",
400
539
  namespace: "code-token__variable",
401
540
  number: "code-token__property",
541
+ macro: "code-token__function",
402
542
  operator: "code-token__operator",
403
543
  parameter: "code-token__variable",
404
544
  prolog: "code-token__comment",
405
545
  property: "code-token__property",
406
546
  punctuation: "code-token__punctuation",
547
+ "raw-string": "code-token__operator",
407
548
  regex: "code-token__variable",
408
549
  script: "code-token__function",
409
550
  selector: "code-token__selector",
@@ -412,29 +553,18 @@ var theme = {
412
553
  symbol: "code-token__property",
413
554
  tag: "code-token__property",
414
555
  title: "code-token__function",
556
+ "type-definition": "code-token__function",
415
557
  url: "code-token__operator",
416
558
  variable: "code-token__variable",
417
559
  }
418
560
  };
419
561
 
420
- const VISUALLY_RELEVANT_ELEMENTS_SELECTOR = [
421
- "img", "video", "audio", "iframe", "embed", "object", "picture", "source", "canvas", "svg", "math",
422
- "form", "input", "textarea", "select", "button", "code", "blockquote", "hr"
423
- ].join(",");
424
-
425
- const ALLOWED_HTML_TAGS = [ "a", "action-text-attachment", "b", "blockquote", "br", "code", "em",
426
- "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "q", "s", "strong", "ul" ];
427
-
428
- const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
429
- "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation",
430
- "previewable", "sgid", "src", "title", "url", "width" ];
431
-
432
562
  function createElement(name, properties) {
433
563
  const element = document.createElement(name);
434
564
  for (const [ key, value ] of Object.entries(properties || {})) {
435
565
  if (key in element) {
436
566
  element[key] = value;
437
- } else if (value !== null && value !== undefined ) {
567
+ } else if (value !== null && value !== undefined) {
438
568
  element.setAttribute(key, value);
439
569
  }
440
570
  }
@@ -466,18 +596,8 @@ function dispatchCustomEvent(element, name, detail) {
466
596
  element.dispatchEvent(event);
467
597
  }
468
598
 
469
- function containsVisuallyRelevantChildren(element) {
470
- return element.querySelector(VISUALLY_RELEVANT_ELEMENTS_SELECTOR)
471
- }
472
-
473
599
  function sanitize(html) {
474
- const sanitizedHtml = DOMPurify.sanitize(html, {
475
- ALLOWED_TAGS: ALLOWED_HTML_TAGS,
476
- ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
477
- SAFE_FOR_XML: false // So that it does not stripe attributes that contains serialized HTML (like content)
478
- });
479
-
480
- return sanitizedHtml
600
+ return DOMPurify.sanitize(html)
481
601
  }
482
602
 
483
603
  function dispatch(element, eventName, detail = null, cancelable = false) {
@@ -694,11 +814,10 @@ class ActionTextAttachmentNode extends DecoratorNode {
694
814
 
695
815
  #createEditableCaption() {
696
816
  const caption = createElement("figcaption", { className: "attachment__caption" });
697
- const input = createElement("input", {
698
- type: "text",
699
- class: "input",
817
+ const input = createElement("textarea", {
700
818
  value: this.caption,
701
- placeholder: this.fileName
819
+ placeholder: this.fileName,
820
+ rows: "1"
702
821
  });
703
822
 
704
823
  input.addEventListener("focusin", () => input.placeholder = "Add caption...");
@@ -990,6 +1109,8 @@ const COMMANDS = [
990
1109
  "strikethrough",
991
1110
  "link",
992
1111
  "unlink",
1112
+ "toggleHighlight",
1113
+ "removeHighlight",
993
1114
  "rotateHeadingFormat",
994
1115
  "insertUnorderedList",
995
1116
  "insertOrderedList",
@@ -1012,6 +1133,7 @@ class CommandDispatcher {
1012
1133
  this.selection = editorElement.selection;
1013
1134
  this.contents = editorElement.contents;
1014
1135
  this.clipboard = editorElement.clipboard;
1136
+ this.highlighter = editorElement.highlighter;
1015
1137
 
1016
1138
  this.#registerCommands();
1017
1139
  this.#registerDragAndDropHandlers();
@@ -1033,6 +1155,14 @@ class CommandDispatcher {
1033
1155
  this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
1034
1156
  }
1035
1157
 
1158
+ dispatchToggleHighlight(styles) {
1159
+ this.highlighter.toggle(styles);
1160
+ }
1161
+
1162
+ dispatchRemoveHighlight() {
1163
+ this.highlighter.remove();
1164
+ }
1165
+
1036
1166
  dispatchLink(url) {
1037
1167
  this.editor.update(() => {
1038
1168
  const selection = $getSelection();
@@ -1133,6 +1263,7 @@ class CommandDispatcher {
1133
1263
  const input = createElement("input", {
1134
1264
  type: "file",
1135
1265
  multiple: true,
1266
+ style: "display: none;",
1136
1267
  onchange: ({ target }) => {
1137
1268
  const files = Array.from(target.files);
1138
1269
  if (!files.length) return
@@ -1143,7 +1274,7 @@ class CommandDispatcher {
1143
1274
  }
1144
1275
  });
1145
1276
 
1146
- document.body.appendChild(input); // Append and remove just for the sake of making it testeable
1277
+ this.editorElement.appendChild(input); // Append and remove just for the sake of making it testable
1147
1278
  input.click();
1148
1279
  setTimeout(() => input.remove(), 1000);
1149
1280
  }
@@ -1274,8 +1405,10 @@ class Selection {
1274
1405
 
1275
1406
  set current(selection) {
1276
1407
  if ($isNodeSelection(selection)) {
1277
- this._current = $getSelection();
1278
- this.#syncSelectedClasses();
1408
+ this.editor.getEditorState().read(() => {
1409
+ this._current = $getSelection();
1410
+ this.#syncSelectedClasses();
1411
+ });
1279
1412
  } else {
1280
1413
  this.editor.update(() => {
1281
1414
  this.#syncSelectedClasses();
@@ -1459,8 +1592,9 @@ class Selection {
1459
1592
 
1460
1593
  this._currentlySelectedKeys = new Set();
1461
1594
 
1462
- if (this.current) {
1463
- for (const node of this.current.getNodes()) {
1595
+ const selection = $getSelection();
1596
+ if (selection && $isNodeSelection(selection)) {
1597
+ for (const node of selection.getNodes()) {
1464
1598
  this._currentlySelectedKeys.add(node.getKey());
1465
1599
  }
1466
1600
  }
@@ -2650,8 +2784,6 @@ class Contents {
2650
2784
  elements.forEach((element) => {
2651
2785
  wrappingNode.append(element);
2652
2786
  });
2653
-
2654
- $setSelection(null);
2655
2787
  });
2656
2788
  }
2657
2789
 
@@ -3127,6 +3259,137 @@ class Clipboard {
3127
3259
  }
3128
3260
  }
3129
3261
 
3262
+ class Highlighter {
3263
+ constructor(editorElement) {
3264
+ this.editor = editorElement.editor;
3265
+
3266
+ this.#registerHighlightTransform();
3267
+ }
3268
+
3269
+ toggle(styles) {
3270
+ this.editor.update(() => {
3271
+ this.#toggleSelectionStyles(styles);
3272
+ });
3273
+ }
3274
+
3275
+ remove() {
3276
+ this.toggle({ "color": null, "background-color": null });
3277
+ }
3278
+
3279
+ #registerHighlightTransform() {
3280
+ return this.editor.registerNodeTransform(TextNode, (textNode) => {
3281
+ this.#syncHighlightWithStyle(textNode);
3282
+ })
3283
+ }
3284
+
3285
+ #toggleSelectionStyles(styles) {
3286
+ const selection = $getSelection();
3287
+ if (!$isRangeSelection(selection)) return
3288
+
3289
+ const patch = {};
3290
+ for (const property in styles) {
3291
+ const oldValue = $getSelectionStyleValueForProperty(selection, property);
3292
+ patch[property] = this.#toggleOrReplace(oldValue, styles[property]);
3293
+ }
3294
+
3295
+ $patchStyleText(selection, patch);
3296
+ }
3297
+
3298
+ #toggleOrReplace(oldValue, newValue) {
3299
+ return oldValue === newValue ? null : newValue
3300
+ }
3301
+
3302
+ #syncHighlightWithStyle(node) {
3303
+ if (hasHighlightStyles(node.getStyle()) !== node.hasFormat("highlight")) {
3304
+ node.toggleFormat("highlight");
3305
+ }
3306
+ }
3307
+ }
3308
+
3309
+ class HighlightNode extends TextNode {
3310
+ $config() {
3311
+ return this.config("highlight", { extends: TextNode })
3312
+ }
3313
+
3314
+ static importDOM() {
3315
+ return {
3316
+ mark: () => ({
3317
+ conversion: extendTextNodeConversion("mark", applyHighlightStyle),
3318
+ priority: 1
3319
+ })
3320
+ }
3321
+ }
3322
+ }
3323
+
3324
+ function applyHighlightStyle(textNode, element) {
3325
+ const textColor = element.style?.color;
3326
+ const backgroundColor = element.style?.backgroundColor;
3327
+
3328
+ let highlightStyle = "";
3329
+ if (textColor && textColor !== "") highlightStyle += `color: ${textColor};`;
3330
+ if (backgroundColor && backgroundColor !== "") highlightStyle += `background-color: ${backgroundColor};`;
3331
+
3332
+ if (highlightStyle.length) {
3333
+ if (!textNode.hasFormat("highlight")) textNode.toggleFormat("highlight");
3334
+ return textNode.setStyle(textNode.getStyle() + highlightStyle)
3335
+ }
3336
+ }
3337
+
3338
+ const TRIX_LANGUAGE_ATTR = "language";
3339
+
3340
+ class TrixTextNode extends TextNode {
3341
+ $config() {
3342
+ return this.config("trix-text", { extends: TextNode })
3343
+ }
3344
+
3345
+ static importDOM() {
3346
+ return {
3347
+ // em, span, and strong elements are directly styled in trix
3348
+ em: (element) => onlyStyledElements(element, {
3349
+ conversion: extendTextNodeConversion("i", applyHighlightStyle),
3350
+ priority: 1
3351
+ }),
3352
+ span: (element) => onlyStyledElements(element, {
3353
+ conversion: extendTextNodeConversion("mark", applyHighlightStyle),
3354
+ priority: 1
3355
+ }),
3356
+ strong: (element) => onlyStyledElements(element, {
3357
+ conversion: extendTextNodeConversion("b", applyHighlightStyle),
3358
+ priority: 1
3359
+ }),
3360
+ // del => s
3361
+ del: () => ({
3362
+ conversion: extendTextNodeConversion("s", applyStrikethrough),
3363
+ priority: 1
3364
+ }),
3365
+ // read "language" attribute and normalize
3366
+ pre: (element) => onlyPreLanguageElements(element, {
3367
+ conversion: extendConversion(CodeNode, "pre", applyLanguage),
3368
+ priority: 1
3369
+ })
3370
+ }
3371
+ }
3372
+ }
3373
+
3374
+ function onlyStyledElements(element, conversion) {
3375
+ const elementHighlighted = element.style.color !== "" || element.style.backgroundColor !== "";
3376
+ return elementHighlighted ? conversion : null
3377
+ }
3378
+
3379
+ function applyStrikethrough(textNode, element) {
3380
+ if (!textNode.hasFormat("strikethrough")) textNode.toggleFormat("strikethrough");
3381
+ return applyHighlightStyle(textNode, element)
3382
+ }
3383
+
3384
+ function onlyPreLanguageElements(element, conversion) {
3385
+ return element.hasAttribute(TRIX_LANGUAGE_ATTR) ? conversion : null
3386
+ }
3387
+
3388
+ function applyLanguage(conversionOutput, element) {
3389
+ const language = normalizeCodeLang(element.getAttribute(TRIX_LANGUAGE_ATTR));
3390
+ conversionOutput.node.setLanguage(language);
3391
+ }
3392
+
3130
3393
  class LexicalEditorElement extends HTMLElement {
3131
3394
  static formAssociated = true
3132
3395
  static debug = true
@@ -3149,6 +3412,7 @@ class LexicalEditorElement extends HTMLElement {
3149
3412
  this.contents = new Contents(this);
3150
3413
  this.selection = new Selection(this);
3151
3414
  this.clipboard = new Clipboard(this);
3415
+ this.highlighter = new Highlighter(this);
3152
3416
 
3153
3417
  CommandDispatcher.configureFor(this);
3154
3418
  this.#initialize();
@@ -3184,6 +3448,10 @@ class LexicalEditorElement extends HTMLElement {
3184
3448
  return this.internals.form
3185
3449
  }
3186
3450
 
3451
+ get name() {
3452
+ return this.getAttribute("name")
3453
+ }
3454
+
3187
3455
  get toolbarElement() {
3188
3456
  if (!this.#hasToolbar) return null
3189
3457
 
@@ -3259,6 +3527,7 @@ class LexicalEditorElement extends HTMLElement {
3259
3527
  this.#registerComponents();
3260
3528
  this.#listenForInvalidatedNodes();
3261
3529
  this.#handleEnter();
3530
+ this.#handleFocus();
3262
3531
  this.#attachDebugHooks();
3263
3532
  this.#attachToolbar();
3264
3533
  this.#loadInitialValue();
@@ -3284,6 +3553,8 @@ class LexicalEditorElement extends HTMLElement {
3284
3553
 
3285
3554
  get #lexicalNodes() {
3286
3555
  const nodes = [
3556
+ TrixTextNode,
3557
+ HighlightNode,
3287
3558
  QuoteNode,
3288
3559
  HeadingNode,
3289
3560
  ListNode,
@@ -3437,6 +3708,14 @@ class LexicalEditorElement extends HTMLElement {
3437
3708
  );
3438
3709
  }
3439
3710
 
3711
+ #handleFocus() {
3712
+ // Lexxy handles focus and blur as commands
3713
+ // see https://github.com/facebook/lexical/blob/d1a8e84fe9063a4f817655b346b6ff373aa107f0/packages/lexical/src/LexicalEvents.ts#L35
3714
+ // and https://stackoverflow.com/a/72212077
3715
+ this.editor.registerCommand(BLUR_COMMAND, () => { dispatch(this, "lexxy:blur"); }, COMMAND_PRIORITY_NORMAL);
3716
+ this.editor.registerCommand(FOCUS_COMMAND, () => { dispatch(this, "lexxy:focus"); }, COMMAND_PRIORITY_NORMAL);
3717
+ }
3718
+
3440
3719
  #attachDebugHooks() {
3441
3720
  if (!LexicalEditorElement.debug) return
3442
3721
 
@@ -3475,7 +3754,7 @@ class LexicalEditorElement extends HTMLElement {
3475
3754
  }
3476
3755
 
3477
3756
  get #isEmpty() {
3478
- return !this.editorContentElement.textContent.trim() && !containsVisuallyRelevantChildren(this.editorContentElement)
3757
+ return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
3479
3758
  }
3480
3759
 
3481
3760
  #setValidity() {
@@ -3515,33 +3794,98 @@ class LexicalEditorElement extends HTMLElement {
3515
3794
 
3516
3795
  customElements.define("lexxy-editor", LexicalEditorElement);
3517
3796
 
3518
- class LinkDialog extends HTMLElement {
3797
+ class ToolbarDialog extends HTMLElement {
3519
3798
  connectedCallback() {
3520
3799
  this.dialog = this.querySelector("dialog");
3521
- this.input = this.querySelector("input");
3800
+ if ("closedBy" in this.dialog.constructor.prototype) {
3801
+ this.dialog.closedBy = "any";
3802
+ }
3803
+ this.#registerHandlers();
3804
+ }
3522
3805
 
3523
- this.addEventListener("submit", this.#handleSubmit.bind(this));
3524
- this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink.bind(this));
3525
- this.addEventListener("keydown", this.#handleKeyDown.bind(this));
3806
+ disconnectedCallback() {
3807
+ this.#removeClickOutsideHandler();
3526
3808
  }
3527
3809
 
3528
- show(editor) {
3529
- this.input.value = this.#selectedLinkUrl;
3810
+ updateStateCallback() { }
3811
+
3812
+ show(triggerButton) {
3813
+ if (this.preventImmediateReopen) { return }
3814
+
3815
+ this.triggerButton = triggerButton;
3816
+ this.#positionDialog();
3530
3817
  this.dialog.show();
3818
+
3819
+ this.#setupClickOutsideHandler();
3531
3820
  }
3532
3821
 
3533
3822
  close() {
3534
3823
  this.dialog.close();
3535
3824
  }
3536
3825
 
3537
- #handleSubmit(event) {
3538
- const command = event.submitter?.value;
3539
- this.#editor.dispatchCommand(command, this.input.value);
3826
+ get toolbar() {
3827
+ return this.closest("lexxy-toolbar")
3540
3828
  }
3541
3829
 
3542
- #handleUnlink(event) {
3543
- this.#editor.dispatchCommand("unlink");
3544
- this.close();
3830
+ get editor() {
3831
+ return this.toolbar.editor
3832
+ }
3833
+
3834
+ get open() { return this.dialog.open }
3835
+
3836
+ #registerHandlers() {
3837
+ this.#setupKeydownHandler();
3838
+ this.dialog.addEventListener("cancel", this.#handleCancel.bind(this));
3839
+ this.dialog.addEventListener("close", this.#handleClose.bind(this));
3840
+ }
3841
+
3842
+ #handleClose() {
3843
+ this.#removeClickOutsideHandler();
3844
+ this.triggerButton = null;
3845
+ this.editor.focus();
3846
+ }
3847
+
3848
+ #handleCancel() {
3849
+ this.preventImmediateReopen = true;
3850
+ requestAnimationFrame(() => this.preventImmediateReopen = undefined);
3851
+ }
3852
+
3853
+ #positionDialog() {
3854
+ const left = this.triggerButton.offsetLeft;
3855
+ this.dialog.style.insetInlineStart = `${left}px`;
3856
+ }
3857
+
3858
+ #setupClickOutsideHandler() {
3859
+ if (this.#browserHandlesClose || this.clickOutsideHandler) return
3860
+
3861
+ this.clickOutsideHandler = this.#handleClickOutside.bind(this);
3862
+ document.addEventListener("click", this.clickOutsideHandler, true);
3863
+ }
3864
+
3865
+ #removeClickOutsideHandler() {
3866
+ if (!this.clickOutsideHandler) return
3867
+
3868
+ document.removeEventListener("click", this.clickOutsideHandler, true);
3869
+ this.clickOutsideHandler = null;
3870
+ }
3871
+
3872
+ #handleClickOutside({ target }) {
3873
+ if (!this.dialog.open) return
3874
+
3875
+ const isClickInsideDialog = this.dialog.contains(target);
3876
+ const isClickOnTrigger = this.triggerButton.contains(target);
3877
+
3878
+ if (!isClickInsideDialog && !isClickOnTrigger) {
3879
+ this.close();
3880
+ }
3881
+ }
3882
+
3883
+ #setupKeydownHandler() {
3884
+ if (!this.#browserHandlesClose) { this.addEventListener("keydown", this.#handleKeyDown.bind(this)); }
3885
+ }
3886
+
3887
+ get #browserHandlesClose() {
3888
+ return this.dialog.closedBy === "any"
3545
3889
  }
3546
3890
 
3547
3891
  #handleKeyDown(event) {
@@ -3550,11 +3894,44 @@ class LinkDialog extends HTMLElement {
3550
3894
  this.close();
3551
3895
  }
3552
3896
  }
3897
+ }
3898
+
3899
+ class LinkDialog extends ToolbarDialog {
3900
+ connectedCallback() {
3901
+ super.connectedCallback();
3902
+ this.input = this.querySelector("input");
3903
+
3904
+ this.#registerHandlers();
3905
+ }
3906
+
3907
+ updateStateCallback() {
3908
+ this.input.value = this.#selectedLinkUrl;
3909
+ }
3910
+
3911
+ #registerHandlers() {
3912
+ this.dialog.addEventListener("beforetoggle", this.#handleBeforeToggle.bind(this));
3913
+ this.dialog.addEventListener("submit", this.#handleSubmit.bind(this));
3914
+ this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink.bind(this));
3915
+ }
3916
+
3917
+ #handleBeforeToggle({ newState }) {
3918
+ this.input.required = newState === "open";
3919
+ }
3920
+
3921
+ #handleSubmit(event) {
3922
+ const command = event.submitter?.value;
3923
+ this.editor.dispatchCommand(command, this.input.value);
3924
+ }
3925
+
3926
+ #handleUnlink() {
3927
+ this.editor.dispatchCommand("unlink");
3928
+ this.close();
3929
+ }
3553
3930
 
3554
3931
  get #selectedLinkUrl() {
3555
3932
  let url = "";
3556
3933
 
3557
- this.#editor.getEditorState().read(() => {
3934
+ this.editor.getEditorState().read(() => {
3558
3935
  const selection = $getSelection();
3559
3936
  if (!$isRangeSelection(selection)) return
3560
3937
 
@@ -3570,16 +3947,110 @@ class LinkDialog extends HTMLElement {
3570
3947
 
3571
3948
  return url
3572
3949
  }
3573
-
3574
- get #editor() {
3575
- return this.closest("lexxy-toolbar").editor
3576
- }
3577
3950
  }
3578
3951
 
3579
3952
  // We should extend the native dialog and avoid the intermediary <dialog> but not
3580
3953
  // supported by Safari yet: customElements.define("lexxy-link-dialog", LinkDialog, { extends: "dialog" })
3581
3954
  customElements.define("lexxy-link-dialog", LinkDialog);
3582
3955
 
3956
+ const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
3957
+ const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
3958
+
3959
+ // Use Symbol instead of null since $getSelectionStyleValueForProperty
3960
+ // responds differently for backward selections if null is the default
3961
+ // see https://github.com/facebook/lexical/issues/8013
3962
+ const NO_STYLE = Symbol("no_style");
3963
+
3964
+ class HighlightDialog extends ToolbarDialog {
3965
+ connectedCallback() {
3966
+ super.connectedCallback();
3967
+
3968
+ this.#setUpButtons();
3969
+ this.#registerHandlers();
3970
+ }
3971
+
3972
+ updateStateCallback() {
3973
+ this.#updateColorButtonStates($getSelection());
3974
+ }
3975
+
3976
+ #registerHandlers() {
3977
+ this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick.bind(this));
3978
+ this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick.bind(this)));
3979
+ }
3980
+
3981
+ #setUpButtons() {
3982
+ this.#buttonGroups.forEach(buttonGroup => {
3983
+ this.#populateButtonGroup(buttonGroup);
3984
+ });
3985
+ }
3986
+
3987
+ #populateButtonGroup(buttonGroup) {
3988
+ const values = buttonGroup.dataset.values?.split("; ") || [];
3989
+ const attribute = buttonGroup.dataset.buttonGroup;
3990
+ values.forEach((value, index) => {
3991
+ buttonGroup.appendChild(this.#createButton(attribute, value, index));
3992
+ });
3993
+ }
3994
+
3995
+ #createButton(attribute, value, index) {
3996
+ const button = document.createElement("button");
3997
+ button.dataset.style = attribute;
3998
+ button.style.setProperty(attribute, value);
3999
+ button.dataset.value = value;
4000
+ button.classList.add("lexxy-highlight-button");
4001
+ button.name = attribute + "-" + index;
4002
+ return button
4003
+ }
4004
+
4005
+ #handleColorButtonClick(event) {
4006
+ event.preventDefault();
4007
+
4008
+ const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR);
4009
+ if (!button) return
4010
+
4011
+ const attribute = button.dataset.style;
4012
+ const value = button.dataset.value;
4013
+
4014
+ this.editor.dispatchCommand("toggleHighlight", { [attribute]: value });
4015
+ this.close();
4016
+ }
4017
+
4018
+ #handleRemoveHighlightClick(event) {
4019
+ event.preventDefault();
4020
+
4021
+ this.editor.dispatchCommand("removeHighlight");
4022
+ this.close();
4023
+ }
4024
+
4025
+ #updateColorButtonStates(selection) {
4026
+ if (!$isRangeSelection(selection)) { return }
4027
+
4028
+ // Use non-"" default, so "" indicates mixed highlighting
4029
+ const textColor = $getSelectionStyleValueForProperty(selection, "color", NO_STYLE);
4030
+ const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE);
4031
+
4032
+ this.#colorButtons.forEach(button => {
4033
+ const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor;
4034
+ button.setAttribute("aria-pressed", matchesSelection);
4035
+ });
4036
+
4037
+ const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE;
4038
+ this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).disabled = !hasHighlight;
4039
+ }
4040
+
4041
+ get #buttonGroups() {
4042
+ return this.querySelectorAll("[data-button-group]")
4043
+ }
4044
+
4045
+ get #colorButtons() {
4046
+ return Array.from(this.querySelectorAll(APPLY_HIGHLIGHT_SELECTOR))
4047
+ }
4048
+ }
4049
+
4050
+ // We should extend the native dialog and avoid the intermediary <dialog> but not
4051
+ // supported by Safari yet: customElements.define("lexxy-hightlight-dialog", HighlightDialog, { extends: "dialog" })
4052
+ customElements.define("lexxy-highlight-dialog", HighlightDialog);
4053
+
3583
4054
  class BaseSource {
3584
4055
  // Template method to override
3585
4056
  async buildListItems(filter = "") {
@@ -3781,9 +4252,17 @@ class LexicalPromptElement extends HTMLElement {
3781
4252
  const fullText = node.getTextContent();
3782
4253
  const charBeforeCursor = fullText[offset - 1];
3783
4254
 
4255
+ // Check if trigger is at the start of the text node (new line case) or preceded by space or newline
3784
4256
  if (charBeforeCursor === this.trigger) {
3785
- unregister();
3786
- this.#showPopover();
4257
+ const isAtStart = offset === 1;
4258
+
4259
+ const charBeforeTrigger = offset > 1 ? fullText[offset - 2] : null;
4260
+ const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n";
4261
+
4262
+ if (isAtStart || isPrecededBySpaceOrNewline) {
4263
+ unregister();
4264
+ this.#showPopover();
4265
+ }
3787
4266
  }
3788
4267
  }
3789
4268
  });
@@ -4028,7 +4507,7 @@ class LexicalPromptElement extends HTMLElement {
4028
4507
  }
4029
4508
 
4030
4509
  #handleSelectedOption(event) {
4031
- if (event.key !== " ") event.preventDefault();
4510
+ event.preventDefault();
4032
4511
  event.stopPropagation();
4033
4512
  this.#optionWasSelected();
4034
4513
  return true
@@ -4137,6 +4616,11 @@ class CodeLanguagePicker extends HTMLElement {
4137
4616
  const languages = { ...CODE_LANGUAGE_FRIENDLY_NAME_MAP };
4138
4617
 
4139
4618
  if (!languages.ruby) languages.ruby = "Ruby";
4619
+ if (!languages.php) languages.php = "PHP";
4620
+ if (!languages.go) languages.go = "Go";
4621
+ if (!languages.bash) languages.bash = "Bash";
4622
+ if (!languages.json) languages.json = "JSON";
4623
+ if (!languages.diff) languages.diff = "Diff";
4140
4624
 
4141
4625
  const sortedEntries = Object.entries(languages)
4142
4626
  .sort(([ , a ], [ , b ]) => a.localeCompare(b));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.20-beta",
3
+ "version": "0.1.22-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",