@37signals/lexxy 0.1.19-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();
@@ -1095,8 +1197,10 @@ class CommandDispatcher {
1095
1197
 
1096
1198
  dispatchInsertHorizontalDivider() {
1097
1199
  this.editor.update(() => {
1098
- this.contents.insertAtCursor(new HorizontalDividerNode());
1200
+ this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
1099
1201
  });
1202
+
1203
+ this.editor.focus();
1100
1204
  }
1101
1205
 
1102
1206
  dispatchRotateHeadingFormat() {
@@ -1131,6 +1235,7 @@ class CommandDispatcher {
1131
1235
  const input = createElement("input", {
1132
1236
  type: "file",
1133
1237
  multiple: true,
1238
+ style: "display: none;",
1134
1239
  onchange: ({ target }) => {
1135
1240
  const files = Array.from(target.files);
1136
1241
  if (!files.length) return
@@ -1141,7 +1246,7 @@ class CommandDispatcher {
1141
1246
  }
1142
1247
  });
1143
1248
 
1144
- 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
1145
1250
  input.click();
1146
1251
  setTimeout(() => input.remove(), 1000);
1147
1252
  }
@@ -1272,8 +1377,10 @@ class Selection {
1272
1377
 
1273
1378
  set current(selection) {
1274
1379
  if ($isNodeSelection(selection)) {
1275
- this._current = $getSelection();
1276
- this.#syncSelectedClasses();
1380
+ this.editor.getEditorState().read(() => {
1381
+ this._current = $getSelection();
1382
+ this.#syncSelectedClasses();
1383
+ });
1277
1384
  } else {
1278
1385
  this.editor.update(() => {
1279
1386
  this.#syncSelectedClasses();
@@ -1308,6 +1415,52 @@ class Selection {
1308
1415
  });
1309
1416
  }
1310
1417
 
1418
+ selectedNodeWithOffset() {
1419
+ const selection = $getSelection();
1420
+ if (!selection) return { node: null, offset: 0 }
1421
+
1422
+ if ($isRangeSelection(selection)) {
1423
+ return {
1424
+ node: selection.anchor.getNode(),
1425
+ offset: selection.anchor.offset
1426
+ }
1427
+ } else if ($isNodeSelection(selection)) {
1428
+ const [ node ] = selection.getNodes();
1429
+ return {
1430
+ node,
1431
+ offset: 0
1432
+ }
1433
+ }
1434
+
1435
+ return { node: null, offset: 0 }
1436
+ }
1437
+
1438
+ preservingSelection(fn) {
1439
+ let selectionState = null;
1440
+
1441
+ this.editor.getEditorState().read(() => {
1442
+ const selection = $getSelection();
1443
+ if (selection && $isRangeSelection(selection)) {
1444
+ selectionState = {
1445
+ anchor: { key: selection.anchor.key, offset: selection.anchor.offset },
1446
+ focus: { key: selection.focus.key, offset: selection.focus.offset }
1447
+ };
1448
+ }
1449
+ });
1450
+
1451
+ fn();
1452
+
1453
+ if (selectionState) {
1454
+ this.editor.update(() => {
1455
+ const selection = $getSelection();
1456
+ if (selection && $isRangeSelection(selection)) {
1457
+ selection.anchor.set(selectionState.anchor.key, selectionState.anchor.offset, "text");
1458
+ selection.focus.set(selectionState.focus.key, selectionState.focus.offset, "text");
1459
+ }
1460
+ });
1461
+ }
1462
+ }
1463
+
1311
1464
  get hasSelectedWordsInSingleLine() {
1312
1465
  const selection = $getSelection();
1313
1466
  if (!$isRangeSelection(selection)) return false
@@ -1411,8 +1564,9 @@ class Selection {
1411
1564
 
1412
1565
  this._currentlySelectedKeys = new Set();
1413
1566
 
1414
- if (this.current) {
1415
- for (const node of this.current.getNodes()) {
1567
+ const selection = $getSelection();
1568
+ if (selection && $isNodeSelection(selection)) {
1569
+ for (const node of selection.getNodes()) {
1416
1570
  this._currentlySelectedKeys.add(node.getKey());
1417
1571
  }
1418
1572
  }
@@ -2265,6 +2419,11 @@ class Contents {
2265
2419
  });
2266
2420
  }
2267
2421
 
2422
+ insertAtCursorEnsuringLineBelow(node) {
2423
+ this.insertAtCursor(node);
2424
+ this.#insertLineBelowIfLastNode(node);
2425
+ }
2426
+
2268
2427
  insertNodeWrappingEachSelectedLine(newNodeFn) {
2269
2428
  this.editor.update(() => {
2270
2429
  const selection = $getSelection();
@@ -2549,6 +2708,17 @@ class Contents {
2549
2708
  return this.editorElement.selection
2550
2709
  }
2551
2710
 
2711
+ #insertLineBelowIfLastNode(node) {
2712
+ this.editor.update(() => {
2713
+ const nextSibling = node.getNextSibling();
2714
+ if (!nextSibling) {
2715
+ const newParagraph = $createParagraphNode();
2716
+ node.insertAfter(newParagraph);
2717
+ newParagraph.selectStart();
2718
+ }
2719
+ });
2720
+ }
2721
+
2552
2722
  #unwrap(node) {
2553
2723
  const children = node.getChildren();
2554
2724
 
@@ -2586,8 +2756,6 @@ class Contents {
2586
2756
  elements.forEach((element) => {
2587
2757
  wrappingNode.append(element);
2588
2758
  });
2589
-
2590
- $setSelection(null);
2591
2759
  });
2592
2760
  }
2593
2761
 
@@ -2891,7 +3059,8 @@ class Contents {
2891
3059
  lastInsertedNode.insertAfter(textNodeAfter);
2892
3060
 
2893
3061
  this.#appendLineBreakIfNeeded(textNodeAfter.getParentOrThrow());
2894
- textNodeAfter.select(0, 0);
3062
+ const cursorOffset = textAfterCursor ? 0 : 1;
3063
+ textNodeAfter.select(cursorOffset, cursorOffset);
2895
3064
  }
2896
3065
 
2897
3066
  #insertReplacementNodes(startNode, replacementNodes) {
@@ -3062,6 +3231,132 @@ class Clipboard {
3062
3231
  }
3063
3232
  }
3064
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
+
3065
3360
  class LexicalEditorElement extends HTMLElement {
3066
3361
  static formAssociated = true
3067
3362
  static debug = true
@@ -3084,6 +3379,7 @@ class LexicalEditorElement extends HTMLElement {
3084
3379
  this.contents = new Contents(this);
3085
3380
  this.selection = new Selection(this);
3086
3381
  this.clipboard = new Clipboard(this);
3382
+ this.highlighter = new Highlighter(this);
3087
3383
 
3088
3384
  CommandDispatcher.configureFor(this);
3089
3385
  this.#initialize();
@@ -3119,6 +3415,10 @@ class LexicalEditorElement extends HTMLElement {
3119
3415
  return this.internals.form
3120
3416
  }
3121
3417
 
3418
+ get name() {
3419
+ return this.getAttribute("name")
3420
+ }
3421
+
3122
3422
  get toolbarElement() {
3123
3423
  if (!this.#hasToolbar) return null
3124
3424
 
@@ -3194,6 +3494,7 @@ class LexicalEditorElement extends HTMLElement {
3194
3494
  this.#registerComponents();
3195
3495
  this.#listenForInvalidatedNodes();
3196
3496
  this.#handleEnter();
3497
+ this.#handleFocus();
3197
3498
  this.#attachDebugHooks();
3198
3499
  this.#attachToolbar();
3199
3500
  this.#loadInitialValue();
@@ -3219,6 +3520,8 @@ class LexicalEditorElement extends HTMLElement {
3219
3520
 
3220
3521
  get #lexicalNodes() {
3221
3522
  const nodes = [
3523
+ TrixTextNode,
3524
+ HighlightNode,
3222
3525
  QuoteNode,
3223
3526
  HeadingNode,
3224
3527
  ListNode,
@@ -3372,6 +3675,14 @@ class LexicalEditorElement extends HTMLElement {
3372
3675
  );
3373
3676
  }
3374
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
+
3375
3686
  #attachDebugHooks() {
3376
3687
  if (!LexicalEditorElement.debug) return
3377
3688
 
@@ -3410,7 +3721,7 @@ class LexicalEditorElement extends HTMLElement {
3410
3721
  }
3411
3722
 
3412
3723
  get #isEmpty() {
3413
- return !this.editorContentElement.textContent.trim() && !containsVisuallyRelevantChildren(this.editorContentElement)
3724
+ return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
3414
3725
  }
3415
3726
 
3416
3727
  #setValidity() {
@@ -3450,33 +3761,98 @@ class LexicalEditorElement extends HTMLElement {
3450
3761
 
3451
3762
  customElements.define("lexxy-editor", LexicalEditorElement);
3452
3763
 
3453
- class LinkDialog extends HTMLElement {
3764
+ class ToolbarDialog extends HTMLElement {
3454
3765
  connectedCallback() {
3455
3766
  this.dialog = this.querySelector("dialog");
3456
- this.input = this.querySelector("input");
3767
+ if ("closedBy" in this.dialog.constructor.prototype) {
3768
+ this.dialog.closedBy = "any";
3769
+ }
3770
+ this.#registerHandlers();
3771
+ }
3457
3772
 
3458
- this.addEventListener("submit", this.#handleSubmit.bind(this));
3459
- this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink.bind(this));
3460
- this.addEventListener("keydown", this.#handleKeyDown.bind(this));
3773
+ disconnectedCallback() {
3774
+ this.#removeClickOutsideHandler();
3461
3775
  }
3462
3776
 
3463
- show(editor) {
3464
- this.input.value = this.#selectedLinkUrl;
3777
+ updateStateCallback() { }
3778
+
3779
+ show(triggerButton) {
3780
+ if (this.preventImmediateReopen) { return }
3781
+
3782
+ this.triggerButton = triggerButton;
3783
+ this.#positionDialog();
3465
3784
  this.dialog.show();
3785
+
3786
+ this.#setupClickOutsideHandler();
3466
3787
  }
3467
3788
 
3468
3789
  close() {
3469
3790
  this.dialog.close();
3470
3791
  }
3471
3792
 
3472
- #handleSubmit(event) {
3473
- const command = event.submitter?.value;
3474
- this.#editor.dispatchCommand(command, this.input.value);
3793
+ get toolbar() {
3794
+ return this.closest("lexxy-toolbar")
3475
3795
  }
3476
3796
 
3477
- #handleUnlink(event) {
3478
- this.#editor.dispatchCommand("unlink");
3479
- 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"
3480
3856
  }
3481
3857
 
3482
3858
  #handleKeyDown(event) {
@@ -3485,11 +3861,44 @@ class LinkDialog extends HTMLElement {
3485
3861
  this.close();
3486
3862
  }
3487
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
+ }
3488
3897
 
3489
3898
  get #selectedLinkUrl() {
3490
3899
  let url = "";
3491
3900
 
3492
- this.#editor.getEditorState().read(() => {
3901
+ this.editor.getEditorState().read(() => {
3493
3902
  const selection = $getSelection();
3494
3903
  if (!$isRangeSelection(selection)) return
3495
3904
 
@@ -3505,16 +3914,105 @@ class LinkDialog extends HTMLElement {
3505
3914
 
3506
3915
  return url
3507
3916
  }
3508
-
3509
- get #editor() {
3510
- return this.closest("lexxy-toolbar").editor
3511
- }
3512
3917
  }
3513
3918
 
3514
3919
  // We should extend the native dialog and avoid the intermediary <dialog> but not
3515
3920
  // supported by Safari yet: customElements.define("lexxy-link-dialog", LinkDialog, { extends: "dialog" })
3516
3921
  customElements.define("lexxy-link-dialog", LinkDialog);
3517
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
+
3518
4016
  class BaseSource {
3519
4017
  // Template method to override
3520
4018
  async buildListItems(filter = "") {
@@ -3681,6 +4179,14 @@ class LexicalPromptElement extends HTMLElement {
3681
4179
  return this.hasAttribute("supports-space-in-searches")
3682
4180
  }
3683
4181
 
4182
+ get open() {
4183
+ return this.popoverElement?.classList?.contains("lexxy-prompt-menu--visible")
4184
+ }
4185
+
4186
+ get closed() {
4187
+ return !this.open
4188
+ }
4189
+
3684
4190
  get #doesSpaceSelect() {
3685
4191
  return !this.supportsSpaceInSearches
3686
4192
  }
@@ -3701,28 +4207,62 @@ class LexicalPromptElement extends HTMLElement {
3701
4207
  #addTriggerListener() {
3702
4208
  const unregister = this.#editor.registerUpdateListener(() => {
3703
4209
  this.#editor.read(() => {
3704
- const selection = $getSelection();
3705
- if (!selection) return
3706
- let node;
3707
- if ($isRangeSelection(selection)) {
3708
- node = selection.anchor.getNode();
3709
- } else if ($isNodeSelection(selection)) {
3710
- [ node ] = selection.getNodes();
4210
+ const { node, offset } = this.#selection.selectedNodeWithOffset();
4211
+ if (!node) return
4212
+
4213
+ if ($isTextNode(node) && offset > 0) {
4214
+ const fullText = node.getTextContent();
4215
+ const charBeforeCursor = fullText[offset - 1];
4216
+
4217
+ // Check if trigger is at the start of the text node (new line case) or preceded by space or newline
4218
+ if (charBeforeCursor === this.trigger) {
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
+ }
4228
+ }
3711
4229
  }
4230
+ });
4231
+ });
4232
+ }
4233
+
4234
+ #addCursorPositionListener() {
4235
+ this.cursorPositionListener = this.#editor.registerUpdateListener(() => {
4236
+ if (this.closed) return
3712
4237
 
3713
- if (node && $isTextNode(node)) {
3714
- const text = node.getTextContent().trim();
3715
- const lastChar = [ ...text ].pop();
4238
+ this.#editor.read(() => {
4239
+ const { node, offset } = this.#selection.selectedNodeWithOffset();
4240
+ if (!node) return
4241
+
4242
+ if ($isTextNode(node) && offset > 0) {
4243
+ const fullText = node.getTextContent();
4244
+ const textBeforeCursor = fullText.slice(0, offset);
4245
+ const lastTriggerIndex = textBeforeCursor.lastIndexOf(this.trigger);
3716
4246
 
3717
- if (lastChar === this.trigger) {
3718
- unregister();
3719
- this.#showPopover();
4247
+ // If trigger is not found, or cursor is at or before the trigger position, hide popover
4248
+ if (lastTriggerIndex === -1 || offset <= lastTriggerIndex) {
4249
+ this.#hidePopover();
3720
4250
  }
4251
+ } else {
4252
+ // Cursor is not in a text node or at offset 0, hide popover
4253
+ this.#hidePopover();
3721
4254
  }
3722
4255
  });
3723
4256
  });
3724
4257
  }
3725
4258
 
4259
+ #removeCursorPositionListener() {
4260
+ if (this.cursorPositionListener) {
4261
+ this.cursorPositionListener();
4262
+ this.cursorPositionListener = null;
4263
+ }
4264
+ }
4265
+
3726
4266
  get #editor() {
3727
4267
  return this.#editorElement.editor
3728
4268
  }
@@ -3746,6 +4286,7 @@ class LexicalPromptElement extends HTMLElement {
3746
4286
  this.#editorElement.addEventListener("lexxy:change", this.#filterOptions);
3747
4287
 
3748
4288
  this.#registerKeyListeners();
4289
+ this.#addCursorPositionListener();
3749
4290
  }
3750
4291
 
3751
4292
  #registerKeyListeners() {
@@ -3756,6 +4297,22 @@ class LexicalPromptElement extends HTMLElement {
3756
4297
  if (this.#doesSpaceSelect) {
3757
4298
  this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
3758
4299
  }
4300
+
4301
+ // Register arrow keys with HIGH priority to prevent Lexical's selection handlers from running
4302
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_HIGH));
4303
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_HIGH));
4304
+ }
4305
+
4306
+ #handleArrowUp(event) {
4307
+ this.#moveSelectionUp();
4308
+ event.preventDefault();
4309
+ return true
4310
+ }
4311
+
4312
+ #handleArrowDown(event) {
4313
+ this.#moveSelectionDown();
4314
+ event.preventDefault();
4315
+ return true
3759
4316
  }
3760
4317
 
3761
4318
  #selectFirstOption() {
@@ -3773,8 +4330,14 @@ class LexicalPromptElement extends HTMLElement {
3773
4330
  #selectOption(listItem) {
3774
4331
  this.#clearSelection();
3775
4332
  listItem.toggleAttribute("aria-selected", true);
4333
+ listItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
3776
4334
  listItem.focus();
3777
- this.#editorElement.focus();
4335
+
4336
+ // Preserve selection to prevent cursor jump
4337
+ this.#selection.preservingSelection(() => {
4338
+ this.#editorElement.focus();
4339
+ });
4340
+
3778
4341
  this.#editorContentElement.setAttribute("aria-controls", this.popoverElement.id);
3779
4342
  this.#editorContentElement.setAttribute("aria-activedescendant", listItem.id);
3780
4343
  this.#editorContentElement.setAttribute("aria-haspopup", "listbox");
@@ -3823,6 +4386,7 @@ class LexicalPromptElement extends HTMLElement {
3823
4386
  this.#editorElement.removeEventListener("keydown", this.#handleKeydownOnPopover);
3824
4387
 
3825
4388
  this.#unregisterKeyListeners();
4389
+ this.#removeCursorPositionListener();
3826
4390
 
3827
4391
  await nextFrame();
3828
4392
  this.#addTriggerListener();
@@ -3841,6 +4405,7 @@ class LexicalPromptElement extends HTMLElement {
3841
4405
 
3842
4406
  if (this.#editorContents.containsTextBackUntil(this.trigger)) {
3843
4407
  await this.#showFilteredOptions();
4408
+ await nextFrame();
3844
4409
  this.#positionPopover();
3845
4410
  } else {
3846
4411
  this.#hidePopover();
@@ -3881,15 +4446,8 @@ class LexicalPromptElement extends HTMLElement {
3881
4446
  this.#hidePopover();
3882
4447
  this.#editorElement.focus();
3883
4448
  event.stopPropagation();
3884
- } else if (event.key === "ArrowDown") {
3885
- this.#moveSelectionDown();
3886
- event.preventDefault();
3887
- event.stopPropagation();
3888
- } else if (event.key === "ArrowUp") {
3889
- this.#moveSelectionUp();
3890
- event.preventDefault();
3891
- event.stopPropagation();
3892
4449
  }
4450
+ // Arrow keys are now handled via Lexical commands with HIGH priority
3893
4451
  }
3894
4452
 
3895
4453
  #moveSelectionDown() {
@@ -3911,7 +4469,7 @@ class LexicalPromptElement extends HTMLElement {
3911
4469
  }
3912
4470
 
3913
4471
  #handleSelectedOption(event) {
3914
- if (event.key !== " ") event.preventDefault();
4472
+ event.preventDefault();
3915
4473
  event.stopPropagation();
3916
4474
  this.#optionWasSelected();
3917
4475
  return true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.19-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",