@37signals/lexxy 0.1.20-beta → 0.1.21-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.20.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,8 +1,9 @@
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, $forEachSelectedTextNode, $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
3
+ import { $isTextNode, TextNode, $getSelection, $isRangeSelection, 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';
7
8
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
8
9
  import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
@@ -11,12 +12,48 @@ import { DirectUpload } from '@rails/activestorage';
11
12
  import { marked } from 'marked';
12
13
  import 'prismjs/components/prism-ruby';
13
14
 
15
+ const ALLOWED_HTML_TAGS = [ "a", "action-text-attachment", "b", "blockquote", "br", "code", "em",
16
+ "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "ul" ];
17
+
18
+ const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
19
+ "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation",
20
+ "previewable", "sgid", "src", "style", "title", "url", "width" ];
21
+
22
+ const ALLOWED_STYLE_PROPERTIES = [ "color", "background-color" ];
23
+
24
+ function styleFilterHook(_currentNode, hookEvent) {
25
+ if (hookEvent.attrName === "style" && hookEvent.attrValue) {
26
+ const styles = { ...getStyleObjectFromCSS(hookEvent.attrValue) };
27
+ const sanitizedStyles = { };
28
+
29
+ for (const property in styles) {
30
+ if (ALLOWED_STYLE_PROPERTIES.includes(property)) {
31
+ sanitizedStyles[property] = styles[property];
32
+ }
33
+ }
34
+
35
+ if (Object.keys(sanitizedStyles).length) {
36
+ hookEvent.attrValue = getCSSFromStyleObject(sanitizedStyles);
37
+ } else {
38
+ hookEvent.keepAttr = false;
39
+ }
40
+ }
41
+ }
42
+
43
+ DOMPurify.addHook("uponSanitizeAttribute", styleFilterHook);
44
+
14
45
  DOMPurify.addHook("uponSanitizeElement", (node, data) => {
15
46
  if (data.tagName === "strong" || data.tagName === "em") {
16
47
  node.removeAttribute("class");
17
48
  }
18
49
  });
19
50
 
51
+ DOMPurify.setConfig({
52
+ ALLOWED_TAGS: ALLOWED_HTML_TAGS,
53
+ ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
54
+ SAFE_FOR_XML: false // So that it does not strip attributes that contains serialized HTML (like content)
55
+ });
56
+
20
57
  function getNonce() {
21
58
  const element = document.head.querySelector("meta[name=csp-nonce]");
22
59
  return element?.content
@@ -53,6 +90,31 @@ function isPrintableCharacter(event) {
53
90
  return event.key.length === 1
54
91
  }
55
92
 
93
+ function extendTextNodeConversion(conversionName, callback = (textNode => textNode)) {
94
+ return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
95
+ ...conversionOutput,
96
+ forChild: (lexicalNode, parentNode) => {
97
+ const originalForChild = conversionOutput?.forChild ?? (x => x);
98
+ let childNode = originalForChild(lexicalNode, parentNode);
99
+
100
+ if ($isTextNode(childNode)) childNode = callback(childNode, element) ?? childNode;
101
+ return childNode
102
+ }
103
+ }))
104
+ }
105
+
106
+ function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
107
+ return (element) => {
108
+ const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
109
+ if (!converter) return null
110
+
111
+ const conversionOutput = converter.conversion(element);
112
+ if (!conversionOutput) return conversionOutput
113
+
114
+ return callback(conversionOutput, element) ?? conversionOutput
115
+ }
116
+ }
117
+
56
118
  class LexicalToolbarElement extends HTMLElement {
57
119
  constructor() {
58
120
  super();
@@ -83,6 +145,14 @@ class LexicalToolbarElement extends HTMLElement {
83
145
  this.#monitorSelectionChanges();
84
146
  this.#monitorHistoryChanges();
85
147
  this.#refreshToolbarOverflow();
148
+
149
+ this.toggleAttribute("connected", true);
150
+ }
151
+
152
+ get #dialogs() {
153
+ const dialogButtons = this.querySelectorAll("[data-dialog-target]");
154
+ const dialogTags = Array.from(dialogButtons).map(button => `lexxy-${button.dataset.dialogTarget}`);
155
+ return Array.from(this.querySelectorAll(dialogTags))
86
156
  }
87
157
 
88
158
  #bindButtons() {
@@ -108,15 +178,25 @@ class LexicalToolbarElement extends HTMLElement {
108
178
 
109
179
  // Not using popover because of CSS anchoring still not widely available.
110
180
  #toggleDialog(button) {
111
- const dialog = this.querySelector("lexxy-link-dialog .link-dialog").parentNode;
181
+ const dialogTarget = button.dataset.dialogTarget;
182
+ const dialog = this.querySelector("lexxy-" + dialogTarget);
183
+ if (!dialog) return
112
184
 
113
185
  if (dialog.open) {
114
186
  dialog.close();
115
187
  } else {
116
- dialog.show();
188
+ this.#closeOpenDialogs();
189
+ dialog.show(button);
117
190
  }
118
191
  }
119
192
 
193
+ #closeOpenDialogs() {
194
+ const openDialogs = this.querySelectorAll("dialog[open]");
195
+ openDialogs.forEach(openDialog => {
196
+ openDialog.closest(".lexxy-dialog").close();
197
+ });
198
+ }
199
+
120
200
  #bindHotkeys() {
121
201
  this.editorElement.addEventListener("keydown", (event) => {
122
202
  const buttons = this.querySelectorAll("[data-hotkey]");
@@ -154,6 +234,7 @@ class LexicalToolbarElement extends HTMLElement {
154
234
  this.editor.registerUpdateListener(() => {
155
235
  this.editor.getEditorState().read(() => {
156
236
  this.#updateButtonStates();
237
+ this.#updateDialogStates();
157
238
  });
158
239
  });
159
240
  }
@@ -174,14 +255,6 @@ class LexicalToolbarElement extends HTMLElement {
174
255
  });
175
256
  }
176
257
 
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
258
  #updateButtonStates() {
186
259
  const selection = $getSelection();
187
260
  if (!$isRangeSelection(selection)) return
@@ -194,26 +267,32 @@ class LexicalToolbarElement extends HTMLElement {
194
267
  const isBold = selection.hasFormat("bold");
195
268
  const isItalic = selection.hasFormat("italic");
196
269
  const isStrikethrough = selection.hasFormat("strikethrough");
270
+ const isHighlight = selection.hasFormat("highlight");
271
+ const isInLink = this.#isInLink(anchorNode);
272
+ const isInQuote = $isQuoteNode(topLevelElement);
273
+ const isInHeading = $isHeadingNode(topLevelElement);
197
274
  const isInCode = $isCodeNode(topLevelElement) || selection.hasFormat("code");
198
275
  const isInList = this.#isInList(anchorNode);
199
276
  const listType = getListType(anchorNode);
200
- const isInQuote = $isQuoteNode(topLevelElement);
201
- const isInHeading = $isHeadingNode(topLevelElement);
202
- const isInLink = this.#isInLink(anchorNode);
203
277
 
204
278
  this.#setButtonPressed("bold", isBold);
205
279
  this.#setButtonPressed("italic", isItalic);
206
280
  this.#setButtonPressed("strikethrough", isStrikethrough);
281
+ this.#setButtonPressed("highlight", isHighlight);
282
+ this.#setButtonPressed("link", isInLink);
283
+ this.#setButtonPressed("quote", isInQuote);
284
+ this.#setButtonPressed("heading", isInHeading);
207
285
  this.#setButtonPressed("code", isInCode);
208
286
  this.#setButtonPressed("unordered-list", isInList && listType === "bullet");
209
287
  this.#setButtonPressed("ordered-list", isInList && listType === "number");
210
- this.#setButtonPressed("quote", isInQuote);
211
- this.#setButtonPressed("heading", isInHeading);
212
- this.#setButtonPressed("link", isInLink);
213
288
 
214
289
  this.#updateUndoRedoButtonStates();
215
290
  }
216
291
 
292
+ #updateDialogStates() {
293
+ this.#dialogs.forEach(dialog => dialog.updateStateCallback());
294
+ }
295
+
217
296
  #isInList(node) {
218
297
  let current = node;
219
298
  while (current) {
@@ -239,6 +318,14 @@ class LexicalToolbarElement extends HTMLElement {
239
318
  }
240
319
  }
241
320
 
321
+ #setButtonDisabled(name, isDisabled) {
322
+ const button = this.querySelector(`[name="${name}"]`);
323
+ if (button) {
324
+ button.disabled = isDisabled;
325
+ button.setAttribute("aria-disabled", isDisabled.toString());
326
+ }
327
+ }
328
+
242
329
  #toolbarIsOverflowing() {
243
330
  return this.scrollWidth > this.clientWidth
244
331
  }
@@ -249,6 +336,9 @@ class LexicalToolbarElement extends HTMLElement {
249
336
 
250
337
  this.#overflow.style.display = this.#overflowMenu.children.length ? "block" : "none";
251
338
  this.#overflow.setAttribute("nonce", getNonce());
339
+
340
+ const isOverflowing = this.#overflowMenu.children.length > 0;
341
+ this.toggleAttribute("overflowing", isOverflowing);
252
342
  }
253
343
 
254
344
  get #overflow() {
@@ -266,7 +356,7 @@ class LexicalToolbarElement extends HTMLElement {
266
356
  }
267
357
 
268
358
  #compactMenu() {
269
- const buttons = this.#buttons.reverse();
359
+ const buttons = this.#buttonsWithSeparator.reverse();
270
360
  let movedToOverflow = false;
271
361
 
272
362
  for (const button of buttons) {
@@ -281,6 +371,10 @@ class LexicalToolbarElement extends HTMLElement {
281
371
  }
282
372
 
283
373
  get #buttons() {
374
+ return Array.from(this.querySelectorAll(":scope > button"))
375
+ }
376
+
377
+ get #buttonsWithSeparator() {
284
378
  return Array.from(this.querySelectorAll(":scope > button, :scope > [role=separator]"))
285
379
  }
286
380
 
@@ -300,12 +394,22 @@ class LexicalToolbarElement extends HTMLElement {
300
394
  </svg>
301
395
  </button>
302
396
 
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>
397
+ <lexxy-highlight-dialog class="lexxy-dialog lexxy-highlight-dialog">
398
+ <dialog class="highlight-dialog">
399
+ <div class="lexxy-highlight-dialog-content">
400
+ <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>
401
+ <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>
402
+ <button data-command="removeHighlight" class="lexxy-highlight-dialog-reset">Remove all coloring</button>
403
+ </div>
404
+ </dialog>
405
+ </lexxy-highlight-dialog>
406
+
407
+ <button class="lexxy-editor__toolbar-button" type="button" name="highlight" title="Color highlight" data-dialog-target="highlight-dialog">
408
+ <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
409
  </button>
306
410
 
307
- <lexxy-link-dialog class="lexxy-link-dialog">
308
- <dialog class="link-dialog" closedby="any">
411
+ <lexxy-link-dialog class="lexxy-dialog lexxy-link-dialog">
412
+ <dialog class="link-dialog">
309
413
  <form method="dialog">
310
414
  <input type="url" placeholder="Enter a URL…" class="input" required>
311
415
  <div class="lexxy-dialog-actions">
@@ -316,6 +420,10 @@ class LexicalToolbarElement extends HTMLElement {
316
420
  </dialog>
317
421
  </lexxy-link-dialog>
318
422
 
423
+ <button class="lexxy-editor__toolbar-button" type="button" name="link" title="Link" data-dialog-target="link-dialog" data-hotkey="cmd+k ctrl+k">
424
+ <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>
425
+ </button>
426
+
319
427
  <button class="lexxy-editor__toolbar-button" type="button" name="quote" data-command="insertQuoteBlock" title="Quote">
320
428
  <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
429
  </button>
@@ -343,9 +451,9 @@ class LexicalToolbarElement extends HTMLElement {
343
451
  <button class="lexxy-editor__toolbar-button" type="button" name="divider" data-command="insertHorizontalDivider" title="Insert a divider">
344
452
  <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
453
  </button>
346
-
454
+
347
455
  <div class="lexxy-editor__toolbar-spacer" role="separator"></div>
348
-
456
+
349
457
  <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo" data-hotkey="cmd+z ctrl+z">
350
458
  <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
459
  </button>
@@ -370,6 +478,12 @@ var theme = {
370
478
  italic: "lexxy-content__italic",
371
479
  strikethrough: "lexxy-content__strikethrough",
372
480
  underline: "lexxy-content__underline",
481
+ highlight: "lexxy-content__highlight"
482
+ },
483
+ list: {
484
+ nested: {
485
+ listitem: "lexxy-nested-listitem",
486
+ }
373
487
  },
374
488
  codeHighlight: {
375
489
  atrule: "code-token__attr",
@@ -417,24 +531,12 @@ var theme = {
417
531
  }
418
532
  };
419
533
 
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
534
  function createElement(name, properties) {
433
535
  const element = document.createElement(name);
434
536
  for (const [ key, value ] of Object.entries(properties || {})) {
435
537
  if (key in element) {
436
538
  element[key] = value;
437
- } else if (value !== null && value !== undefined ) {
539
+ } else if (value !== null && value !== undefined) {
438
540
  element.setAttribute(key, value);
439
541
  }
440
542
  }
@@ -466,18 +568,8 @@ function dispatchCustomEvent(element, name, detail) {
466
568
  element.dispatchEvent(event);
467
569
  }
468
570
 
469
- function containsVisuallyRelevantChildren(element) {
470
- return element.querySelector(VISUALLY_RELEVANT_ELEMENTS_SELECTOR)
471
- }
472
-
473
571
  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
572
+ return DOMPurify.sanitize(html)
481
573
  }
482
574
 
483
575
  function dispatch(element, eventName, detail = null, cancelable = false) {
@@ -694,11 +786,10 @@ class ActionTextAttachmentNode extends DecoratorNode {
694
786
 
695
787
  #createEditableCaption() {
696
788
  const caption = createElement("figcaption", { className: "attachment__caption" });
697
- const input = createElement("input", {
698
- type: "text",
699
- class: "input",
789
+ const input = createElement("textarea", {
700
790
  value: this.caption,
701
- placeholder: this.fileName
791
+ placeholder: this.fileName,
792
+ rows: "1"
702
793
  });
703
794
 
704
795
  input.addEventListener("focusin", () => input.placeholder = "Add caption...");
@@ -990,6 +1081,8 @@ const COMMANDS = [
990
1081
  "strikethrough",
991
1082
  "link",
992
1083
  "unlink",
1084
+ "toggleHighlight",
1085
+ "removeHighlight",
993
1086
  "rotateHeadingFormat",
994
1087
  "insertUnorderedList",
995
1088
  "insertOrderedList",
@@ -1012,6 +1105,7 @@ class CommandDispatcher {
1012
1105
  this.selection = editorElement.selection;
1013
1106
  this.contents = editorElement.contents;
1014
1107
  this.clipboard = editorElement.clipboard;
1108
+ this.highlighter = editorElement.highlighter;
1015
1109
 
1016
1110
  this.#registerCommands();
1017
1111
  this.#registerDragAndDropHandlers();
@@ -1033,6 +1127,14 @@ class CommandDispatcher {
1033
1127
  this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
1034
1128
  }
1035
1129
 
1130
+ dispatchToggleHighlight(styles) {
1131
+ this.highlighter.toggle(styles);
1132
+ }
1133
+
1134
+ dispatchRemoveHighlight() {
1135
+ this.highlighter.remove();
1136
+ }
1137
+
1036
1138
  dispatchLink(url) {
1037
1139
  this.editor.update(() => {
1038
1140
  const selection = $getSelection();
@@ -1133,6 +1235,7 @@ class CommandDispatcher {
1133
1235
  const input = createElement("input", {
1134
1236
  type: "file",
1135
1237
  multiple: true,
1238
+ style: "display: none;",
1136
1239
  onchange: ({ target }) => {
1137
1240
  const files = Array.from(target.files);
1138
1241
  if (!files.length) return
@@ -1143,7 +1246,7 @@ class CommandDispatcher {
1143
1246
  }
1144
1247
  });
1145
1248
 
1146
- document.body.appendChild(input); // Append and remove just for the sake of making it testeable
1249
+ this.editorElement.appendChild(input); // Append and remove just for the sake of making it testable
1147
1250
  input.click();
1148
1251
  setTimeout(() => input.remove(), 1000);
1149
1252
  }
@@ -1274,8 +1377,10 @@ class Selection {
1274
1377
 
1275
1378
  set current(selection) {
1276
1379
  if ($isNodeSelection(selection)) {
1277
- this._current = $getSelection();
1278
- this.#syncSelectedClasses();
1380
+ this.editor.getEditorState().read(() => {
1381
+ this._current = $getSelection();
1382
+ this.#syncSelectedClasses();
1383
+ });
1279
1384
  } else {
1280
1385
  this.editor.update(() => {
1281
1386
  this.#syncSelectedClasses();
@@ -1459,8 +1564,9 @@ class Selection {
1459
1564
 
1460
1565
  this._currentlySelectedKeys = new Set();
1461
1566
 
1462
- if (this.current) {
1463
- for (const node of this.current.getNodes()) {
1567
+ const selection = $getSelection();
1568
+ if (selection && $isNodeSelection(selection)) {
1569
+ for (const node of selection.getNodes()) {
1464
1570
  this._currentlySelectedKeys.add(node.getKey());
1465
1571
  }
1466
1572
  }
@@ -2650,8 +2756,6 @@ class Contents {
2650
2756
  elements.forEach((element) => {
2651
2757
  wrappingNode.append(element);
2652
2758
  });
2653
-
2654
- $setSelection(null);
2655
2759
  });
2656
2760
  }
2657
2761
 
@@ -3127,6 +3231,132 @@ class Clipboard {
3127
3231
  }
3128
3232
  }
3129
3233
 
3234
+ class Highlighter {
3235
+ constructor(editorElement) {
3236
+ this.editor = editorElement.editor;
3237
+ }
3238
+
3239
+ toggle(styles) {
3240
+ this.editor.update(() => {
3241
+ this.#toggleSelectionStyles(styles);
3242
+ $forEachSelectedTextNode(node => this.#syncHighlightWithStyle(node));
3243
+ });
3244
+ }
3245
+
3246
+ remove() {
3247
+ this.toggle({ "color": undefined, "background-color": undefined });
3248
+ }
3249
+
3250
+ #toggleSelectionStyles(styles) {
3251
+ const selection = $getSelection();
3252
+
3253
+ for (const property in styles) {
3254
+ const oldValue = $getSelectionStyleValueForProperty(selection, property);
3255
+ const patch = { [property]: this.#toggleOrReplace(oldValue, styles[property]) };
3256
+ $patchStyleText(selection, patch);
3257
+ }
3258
+ }
3259
+
3260
+ #toggleOrReplace(oldValue, newValue) {
3261
+ return oldValue === newValue ? null : newValue
3262
+ }
3263
+
3264
+ #syncHighlightWithStyle(node) {
3265
+ if (this.#hasHighlightStyles(node) !== node.hasFormat("highlight")) {
3266
+ node.toggleFormat("highlight");
3267
+ }
3268
+ }
3269
+
3270
+ #hasHighlightStyles(node) {
3271
+ const styles = getStyleObjectFromCSS(node.getStyle());
3272
+ return !!(styles.color || styles["background-color"])
3273
+ }
3274
+ }
3275
+
3276
+ class HighlightNode extends TextNode {
3277
+ $config() {
3278
+ return this.config("highlight", { extends: TextNode })
3279
+ }
3280
+
3281
+ static importDOM() {
3282
+ return {
3283
+ mark: () => ({
3284
+ conversion: extendTextNodeConversion("mark", applyHighlightStyle),
3285
+ priority: 1
3286
+ })
3287
+ }
3288
+ }
3289
+ }
3290
+
3291
+ function applyHighlightStyle(textNode, element) {
3292
+ const textColor = element.style?.color;
3293
+ const backgroundColor = element.style?.backgroundColor;
3294
+
3295
+ let highlightStyle = "";
3296
+ if (textColor && textColor !== "") highlightStyle += `color: ${textColor};`;
3297
+ if (backgroundColor && backgroundColor !== "") highlightStyle += `background-color: ${backgroundColor};`;
3298
+
3299
+ if (highlightStyle.length) {
3300
+ if (!textNode.hasFormat("highlight")) textNode.toggleFormat("highlight");
3301
+ return textNode.setStyle(textNode.getStyle() + highlightStyle)
3302
+ }
3303
+ }
3304
+
3305
+ const TRIX_LANGUAGE_ATTR = "language";
3306
+
3307
+ class TrixTextNode extends TextNode {
3308
+ $config() {
3309
+ return this.config("trix-text", { extends: TextNode })
3310
+ }
3311
+
3312
+ static importDOM() {
3313
+ return {
3314
+ // em, span, and strong elements are directly styled in trix
3315
+ em: (element) => onlyStyledElements(element, {
3316
+ conversion: extendTextNodeConversion("i", applyHighlightStyle),
3317
+ priority: 1
3318
+ }),
3319
+ span: (element) => onlyStyledElements(element, {
3320
+ conversion: extendTextNodeConversion("mark", applyHighlightStyle),
3321
+ priority: 1
3322
+ }),
3323
+ strong: (element) => onlyStyledElements(element, {
3324
+ conversion: extendTextNodeConversion("b", applyHighlightStyle),
3325
+ priority: 1
3326
+ }),
3327
+ // del => s
3328
+ del: () => ({
3329
+ conversion: extendTextNodeConversion("s", applyStrikethrough),
3330
+ priority: 1
3331
+ }),
3332
+ // read "language" attribute and normalize
3333
+ pre: (element) => onlyPreLanguageElements(element, {
3334
+ conversion: extendConversion(CodeNode, "pre", applyLanguage),
3335
+ priority: 1
3336
+ })
3337
+ }
3338
+ }
3339
+ }
3340
+
3341
+ function onlyStyledElements(element, conversion) {
3342
+ const elementHighlighted = element.style.color !== "" || element.style.backgroundColor !== "";
3343
+ return elementHighlighted ? conversion : null
3344
+ }
3345
+
3346
+ function applyStrikethrough(textNode, element) {
3347
+ if (!textNode.hasFormat("strikethrough")) textNode.toggleFormat("strikethrough");
3348
+ return applyHighlightStyle(textNode, element)
3349
+ }
3350
+
3351
+ function onlyPreLanguageElements(element, conversion) {
3352
+ return element.hasAttribute(TRIX_LANGUAGE_ATTR) ? conversion : null
3353
+ }
3354
+
3355
+ function applyLanguage(conversionOutput, element) {
3356
+ const language = normalizeCodeLang(element.getAttribute(TRIX_LANGUAGE_ATTR));
3357
+ conversionOutput.node.setLanguage(language);
3358
+ }
3359
+
3130
3360
  class LexicalEditorElement extends HTMLElement {
3131
3361
  static formAssociated = true
3132
3362
  static debug = true
@@ -3149,6 +3379,7 @@ class LexicalEditorElement extends HTMLElement {
3149
3379
  this.contents = new Contents(this);
3150
3380
  this.selection = new Selection(this);
3151
3381
  this.clipboard = new Clipboard(this);
3382
+ this.highlighter = new Highlighter(this);
3152
3383
 
3153
3384
  CommandDispatcher.configureFor(this);
3154
3385
  this.#initialize();
@@ -3184,6 +3415,10 @@ class LexicalEditorElement extends HTMLElement {
3184
3415
  return this.internals.form
3185
3416
  }
3186
3417
 
3418
+ get name() {
3419
+ return this.getAttribute("name")
3420
+ }
3421
+
3187
3422
  get toolbarElement() {
3188
3423
  if (!this.#hasToolbar) return null
3189
3424
 
@@ -3259,6 +3494,7 @@ class LexicalEditorElement extends HTMLElement {
3259
3494
  this.#registerComponents();
3260
3495
  this.#listenForInvalidatedNodes();
3261
3496
  this.#handleEnter();
3497
+ this.#handleFocus();
3262
3498
  this.#attachDebugHooks();
3263
3499
  this.#attachToolbar();
3264
3500
  this.#loadInitialValue();
@@ -3284,6 +3520,8 @@ class LexicalEditorElement extends HTMLElement {
3284
3520
 
3285
3521
  get #lexicalNodes() {
3286
3522
  const nodes = [
3523
+ TrixTextNode,
3524
+ HighlightNode,
3287
3525
  QuoteNode,
3288
3526
  HeadingNode,
3289
3527
  ListNode,
@@ -3437,6 +3675,14 @@ class LexicalEditorElement extends HTMLElement {
3437
3675
  );
3438
3676
  }
3439
3677
 
3678
+ #handleFocus() {
3679
+ // Lexxy handles focus and blur as commands
3680
+ // see https://github.com/facebook/lexical/blob/d1a8e84fe9063a4f817655b346b6ff373aa107f0/packages/lexical/src/LexicalEvents.ts#L35
3681
+ // and https://stackoverflow.com/a/72212077
3682
+ this.editor.registerCommand(BLUR_COMMAND, () => { dispatch(this, "lexxy:blur"); }, COMMAND_PRIORITY_NORMAL);
3683
+ this.editor.registerCommand(FOCUS_COMMAND, () => { dispatch(this, "lexxy:focus"); }, COMMAND_PRIORITY_NORMAL);
3684
+ }
3685
+
3440
3686
  #attachDebugHooks() {
3441
3687
  if (!LexicalEditorElement.debug) return
3442
3688
 
@@ -3475,7 +3721,7 @@ class LexicalEditorElement extends HTMLElement {
3475
3721
  }
3476
3722
 
3477
3723
  get #isEmpty() {
3478
- return !this.editorContentElement.textContent.trim() && !containsVisuallyRelevantChildren(this.editorContentElement)
3724
+ return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
3479
3725
  }
3480
3726
 
3481
3727
  #setValidity() {
@@ -3515,33 +3761,98 @@ class LexicalEditorElement extends HTMLElement {
3515
3761
 
3516
3762
  customElements.define("lexxy-editor", LexicalEditorElement);
3517
3763
 
3518
- class LinkDialog extends HTMLElement {
3764
+ class ToolbarDialog extends HTMLElement {
3519
3765
  connectedCallback() {
3520
3766
  this.dialog = this.querySelector("dialog");
3521
- this.input = this.querySelector("input");
3767
+ if ("closedBy" in this.dialog.constructor.prototype) {
3768
+ this.dialog.closedBy = "any";
3769
+ }
3770
+ this.#registerHandlers();
3771
+ }
3522
3772
 
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));
3773
+ disconnectedCallback() {
3774
+ this.#removeClickOutsideHandler();
3526
3775
  }
3527
3776
 
3528
- show(editor) {
3529
- this.input.value = this.#selectedLinkUrl;
3777
+ updateStateCallback() { }
3778
+
3779
+ show(triggerButton) {
3780
+ if (this.preventImmediateReopen) { return }
3781
+
3782
+ this.triggerButton = triggerButton;
3783
+ this.#positionDialog();
3530
3784
  this.dialog.show();
3785
+
3786
+ this.#setupClickOutsideHandler();
3531
3787
  }
3532
3788
 
3533
3789
  close() {
3534
3790
  this.dialog.close();
3535
3791
  }
3536
3792
 
3537
- #handleSubmit(event) {
3538
- const command = event.submitter?.value;
3539
- this.#editor.dispatchCommand(command, this.input.value);
3793
+ get toolbar() {
3794
+ return this.closest("lexxy-toolbar")
3540
3795
  }
3541
3796
 
3542
- #handleUnlink(event) {
3543
- this.#editor.dispatchCommand("unlink");
3544
- this.close();
3797
+ get editor() {
3798
+ return this.toolbar.editor
3799
+ }
3800
+
3801
+ get open() { return this.dialog.open }
3802
+
3803
+ #registerHandlers() {
3804
+ this.#setupKeydownHandler();
3805
+ this.dialog.addEventListener("cancel", this.#handleCancel.bind(this));
3806
+ this.dialog.addEventListener("close", this.#handleClose.bind(this));
3807
+ }
3808
+
3809
+ #handleClose() {
3810
+ this.#removeClickOutsideHandler();
3811
+ this.triggerButton = null;
3812
+ this.editor.focus();
3813
+ }
3814
+
3815
+ #handleCancel() {
3816
+ this.preventImmediateReopen = true;
3817
+ requestAnimationFrame(() => this.preventImmediateReopen = undefined);
3818
+ }
3819
+
3820
+ #positionDialog() {
3821
+ const left = this.triggerButton.offsetLeft;
3822
+ this.dialog.style.insetInlineStart = `${left}px`;
3823
+ }
3824
+
3825
+ #setupClickOutsideHandler() {
3826
+ if (this.#browserHandlesClose || this.clickOutsideHandler) return
3827
+
3828
+ this.clickOutsideHandler = this.#handleClickOutside.bind(this);
3829
+ document.addEventListener("click", this.clickOutsideHandler, true);
3830
+ }
3831
+
3832
+ #removeClickOutsideHandler() {
3833
+ if (!this.clickOutsideHandler) return
3834
+
3835
+ document.removeEventListener("click", this.clickOutsideHandler, true);
3836
+ this.clickOutsideHandler = null;
3837
+ }
3838
+
3839
+ #handleClickOutside({ target }) {
3840
+ if (!this.dialog.open) return
3841
+
3842
+ const isClickInsideDialog = this.dialog.contains(target);
3843
+ const isClickOnTrigger = this.triggerButton.contains(target);
3844
+
3845
+ if (!isClickInsideDialog && !isClickOnTrigger) {
3846
+ this.close();
3847
+ }
3848
+ }
3849
+
3850
+ #setupKeydownHandler() {
3851
+ if (!this.#browserHandlesClose) { this.addEventListener("keydown", this.#handleKeyDown.bind(this)); }
3852
+ }
3853
+
3854
+ get #browserHandlesClose() {
3855
+ return this.dialog.closedBy === "any"
3545
3856
  }
3546
3857
 
3547
3858
  #handleKeyDown(event) {
@@ -3550,11 +3861,44 @@ class LinkDialog extends HTMLElement {
3550
3861
  this.close();
3551
3862
  }
3552
3863
  }
3864
+ }
3865
+
3866
+ class LinkDialog extends ToolbarDialog {
3867
+ connectedCallback() {
3868
+ super.connectedCallback();
3869
+ this.input = this.querySelector("input");
3870
+
3871
+ this.#registerHandlers();
3872
+ }
3873
+
3874
+ updateStateCallback() {
3875
+ this.input.value = this.#selectedLinkUrl;
3876
+ }
3877
+
3878
+ #registerHandlers() {
3879
+ this.dialog.addEventListener("beforetoggle", this.#handleBeforeToggle.bind(this));
3880
+ this.dialog.addEventListener("submit", this.#handleSubmit.bind(this));
3881
+ this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink.bind(this));
3882
+ }
3883
+
3884
+ #handleBeforeToggle({ newState }) {
3885
+ this.input.required = newState === "open";
3886
+ }
3887
+
3888
+ #handleSubmit(event) {
3889
+ const command = event.submitter?.value;
3890
+ this.editor.dispatchCommand(command, this.input.value);
3891
+ }
3892
+
3893
+ #handleUnlink() {
3894
+ this.editor.dispatchCommand("unlink");
3895
+ this.close();
3896
+ }
3553
3897
 
3554
3898
  get #selectedLinkUrl() {
3555
3899
  let url = "";
3556
3900
 
3557
- this.#editor.getEditorState().read(() => {
3901
+ this.editor.getEditorState().read(() => {
3558
3902
  const selection = $getSelection();
3559
3903
  if (!$isRangeSelection(selection)) return
3560
3904
 
@@ -3570,16 +3914,105 @@ class LinkDialog extends HTMLElement {
3570
3914
 
3571
3915
  return url
3572
3916
  }
3573
-
3574
- get #editor() {
3575
- return this.closest("lexxy-toolbar").editor
3576
- }
3577
3917
  }
3578
3918
 
3579
3919
  // We should extend the native dialog and avoid the intermediary <dialog> but not
3580
3920
  // supported by Safari yet: customElements.define("lexxy-link-dialog", LinkDialog, { extends: "dialog" })
3581
3921
  customElements.define("lexxy-link-dialog", LinkDialog);
3582
3922
 
3923
+ const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
3924
+ const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
3925
+
3926
+ class HighlightDialog extends ToolbarDialog {
3927
+ connectedCallback() {
3928
+ super.connectedCallback();
3929
+
3930
+ this.#setUpButtons();
3931
+ this.#registerHandlers();
3932
+ }
3933
+
3934
+ updateStateCallback() {
3935
+ this.#updateColorButtonStates($getSelection());
3936
+ }
3937
+
3938
+ #registerHandlers() {
3939
+ this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick.bind(this));
3940
+ this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick.bind(this)));
3941
+ }
3942
+
3943
+ #setUpButtons() {
3944
+ this.#buttonGroups.forEach(buttonGroup => {
3945
+ this.#populateButtonGroup(buttonGroup);
3946
+ });
3947
+ }
3948
+
3949
+ #populateButtonGroup(buttonGroup) {
3950
+ const values = buttonGroup.dataset.values?.split("; ") || [];
3951
+ const attribute = buttonGroup.dataset.buttonGroup;
3952
+ values.forEach((value, index) => {
3953
+ buttonGroup.appendChild(this.#createButton(attribute, value, index));
3954
+ });
3955
+ }
3956
+
3957
+ #createButton(attribute, value, index) {
3958
+ const button = document.createElement("button");
3959
+ button.dataset.style = attribute;
3960
+ button.style.setProperty(attribute, value);
3961
+ button.dataset.value = value;
3962
+ button.classList.add("lexxy-highlight-button");
3963
+ button.name = attribute + "-" + index;
3964
+ return button
3965
+ }
3966
+
3967
+ #handleColorButtonClick(event) {
3968
+ event.preventDefault();
3969
+
3970
+ const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR);
3971
+ if (!button) return
3972
+
3973
+ const attribute = button.dataset.style;
3974
+ const value = button.dataset.value;
3975
+
3976
+ this.editor.dispatchCommand("toggleHighlight", { [attribute]: value });
3977
+ this.close();
3978
+ }
3979
+
3980
+ #handleRemoveHighlightClick(event) {
3981
+ event.preventDefault();
3982
+
3983
+ this.editor.dispatchCommand("removeHighlight");
3984
+ this.close();
3985
+ }
3986
+
3987
+ #updateColorButtonStates(selection) {
3988
+ if (!$isRangeSelection(selection)) { return }
3989
+
3990
+ // Use null default, so "" indicates mixed highlighting
3991
+ const textColor = $getSelectionStyleValueForProperty(selection, "color", null);
3992
+ const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", null);
3993
+
3994
+ this.#colorButtons.forEach(button => {
3995
+ const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor;
3996
+ button.setAttribute("aria-pressed", matchesSelection);
3997
+ });
3998
+
3999
+ const hasHighlight = textColor !== null || backgroundColor !== null;
4000
+ this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).disabled = !hasHighlight;
4001
+ }
4002
+
4003
+ get #buttonGroups() {
4004
+ return this.querySelectorAll("[data-button-group]")
4005
+ }
4006
+
4007
+ get #colorButtons() {
4008
+ return Array.from(this.querySelectorAll(APPLY_HIGHLIGHT_SELECTOR))
4009
+ }
4010
+ }
4011
+
4012
+ // We should extend the native dialog and avoid the intermediary <dialog> but not
4013
+ // supported by Safari yet: customElements.define("lexxy-hightlight-dialog", HighlightDialog, { extends: "dialog" })
4014
+ customElements.define("lexxy-highlight-dialog", HighlightDialog);
4015
+
3583
4016
  class BaseSource {
3584
4017
  // Template method to override
3585
4018
  async buildListItems(filter = "") {
@@ -3781,9 +4214,17 @@ class LexicalPromptElement extends HTMLElement {
3781
4214
  const fullText = node.getTextContent();
3782
4215
  const charBeforeCursor = fullText[offset - 1];
3783
4216
 
4217
+ // Check if trigger is at the start of the text node (new line case) or preceded by space or newline
3784
4218
  if (charBeforeCursor === this.trigger) {
3785
- unregister();
3786
- this.#showPopover();
4219
+ const isAtStart = offset === 1;
4220
+
4221
+ const charBeforeTrigger = offset > 1 ? fullText[offset - 2] : null;
4222
+ const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n";
4223
+
4224
+ if (isAtStart || isPrecededBySpaceOrNewline) {
4225
+ unregister();
4226
+ this.#showPopover();
4227
+ }
3787
4228
  }
3788
4229
  }
3789
4230
  });
@@ -4028,7 +4469,7 @@ class LexicalPromptElement extends HTMLElement {
4028
4469
  }
4029
4470
 
4030
4471
  #handleSelectedOption(event) {
4031
- if (event.key !== " ") event.preventDefault();
4472
+ event.preventDefault();
4032
4473
  event.stopPropagation();
4033
4474
  this.#optionWasSelected();
4034
4475
  return true
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.21-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",