@37signals/lexxy 0.7.6-beta → 0.8.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lexxy.esm.js CHANGED
@@ -9,8 +9,8 @@ import 'prismjs/components/prism-bash';
9
9
  import 'prismjs/components/prism-json';
10
10
  import 'prismjs/components/prism-diff';
11
11
  import DOMPurify from 'dompurify';
12
- import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
13
- import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, DecoratorNode, $getEditor, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createNodeSelection, $isTextNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isDecoratorNode, $createParagraphNode, $setSelection, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, $insertNodes, $getNodeByKey, $createLineBreakNode, ParagraphNode, RootNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
12
+ import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
13
+ import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, DecoratorNode, $createNodeSelection, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $isTextNode, $createParagraphNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isDecoratorNode, $setSelection, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, ElementNode, $splitNode, $getNodeByKey, $createLineBreakNode, ParagraphNode, RootNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
14
14
  import { buildEditorFromExtensions } from '@lexical/extension';
15
15
  import { ListNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListItemNode, $getListDepth, $isListItemNode, $isListNode, $createListNode, registerList } from '@lexical/list';
16
16
  import { $createAutoLinkNode, $toggleLink, LinkNode, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
@@ -20,10 +20,10 @@ import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
20
20
  import { $isCodeNode, CodeNode, normalizeCodeLang, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
21
21
  import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
22
22
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
23
- import { createElement, createAttachmentFigure, isPreviewableImage, parseHtml, dispatch, generateDomId } from './lexxy_helpers.esm.js';
23
+ import { createElement, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
24
24
  export { highlightCode as highlightAll, highlightCode } from './lexxy_helpers.esm.js';
25
- import { $getNearestNodeOfType, mergeRegister, $insertFirst, $firstToLastIterator, $descendantsMatching } from '@lexical/utils';
26
25
  import { INSERT_TABLE_COMMAND, $getTableCellNodeFromLexicalNode, TableCellNode, TableNode, TableRowNode, registerTablePlugin, registerTableSelectionObserver, setScrollableTablesActive, TableCellHeaderStates, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $findCellNode, $getElementForTableNode } from '@lexical/table';
26
+ import { $getNearestNodeOfType, $wrapNodeInElement, mergeRegister, $descendantsMatching, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator } from '@lexical/utils';
27
27
  import { marked } from 'marked';
28
28
  import { $insertDataTransferForRichText } from '@lexical/clipboard';
29
29
 
@@ -106,7 +106,7 @@ var Lexxy = {
106
106
  }
107
107
  };
108
108
 
109
- const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "em",
109
+ const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "div", "em",
110
110
  "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "ul", "table", "tbody", "tr", "th", "td" ];
111
111
 
112
112
  const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
@@ -243,6 +243,94 @@ function isActiveAndVisible(element) {
243
243
  return element && !element.disabled && element.checkVisibility()
244
244
  }
245
245
 
246
+ var ToolbarIcons = {
247
+ "bold":
248
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
249
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M9.05273 1.88232C10.6866 1.88237 12.0033 2.20353 12.9529 2.89673L13.1272 3.0293C13.974 3.70864 14.4008 4.63245 14.4009 5.76562C14.4008 6.49354 14.2316 7.15281 13.8845 7.73145C13.6683 8.09188 13.3997 8.40162 13.0818 8.66016C13.5902 8.92606 14.0196 9.28599 14.3635 9.74121C14.8586 10.3834 15.0945 11.1743 15.0945 12.0879C15.0944 13.3698 14.5922 14.3931 13.5879 15.1106L13.5857 15.1128C12.5967 15.805 11.196 16.125 9.43799 16.125H3.10547V1.88232L9.05273 1.88232ZM6.36108 13.4084H9.28418C10.224 13.4084 10.8634 13.2491 11.2581 12.9851C11.6259 12.7389 11.8198 12.3768 11.8198 11.8367C11.8197 11.2968 11.6259 10.9351 11.2581 10.689C10.8634 10.425 10.2241 10.2649 9.28418 10.2649H6.36108V13.4084ZM6.36108 7.56812H8.78247C9.5163 7.56809 10.0547 7.45371 10.429 7.25757L10.5791 7.16895C10.9438 6.92178 11.1255 6.57934 11.1255 6.09302C11.1254 5.59017 10.9414 5.25227 10.5835 5.02002L10.5784 5.01636L10.5732 5.01343C10.1994 4.75387 9.61878 4.59818 8.78247 4.59814H6.36108V7.56812Z"/>
250
+ </svg>`,
251
+
252
+ "italic":
253
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
254
+ <path d="M14.1379 3.91187L14.1086 4.06421H11.4668L9.49805 13.9431H12.0981L11.7473 15.7852L11.7188 15.9375H4.16675L4.51758 14.0955L4.54614 13.9431H7.18799L9.17505 4.06421H6.55664L6.90747 2.22217L6.93677 2.06982H14.4888L14.1379 3.91187Z"/>
255
+ </svg>`,
256
+
257
+ "strikethrough":
258
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
259
+ <path d="M14.3723 11.8015C14.3771 11.8858 14.3811 11.9756 14.3811 12.0681C14.3811 12.811 14.1777 13.4959 13.7725 14.1174L13.7717 14.1189C13.3624 14.7329 12.7463 15.2162 11.9377 15.5742L11.9348 15.5757C11.1214 15.9223 10.1306 16.092 8.96997 16.092C7.9356 16.092 6.93308 15.9348 5.96338 15.6204L5.96045 15.6189C5.00593 15.292 4.24112 14.8699 3.67676 14.3459L3.57568 14.2522L3.63501 14.1277L4.45605 12.397L4.64282 12.5654C5.13492 13.0083 5.76733 13.3759 6.54492 13.6648C7.33475 13.9406 8.14322 14.0786 8.96997 14.0786C10.0731 14.0786 10.8638 13.8932 11.3708 13.5513C11.8757 13.1982 12.1172 12.7464 12.1172 12.1838C12.1172 12.0662 12.1049 11.9556 12.0828 11.8513L12.0344 11.625H14.3621L14.3723 11.8015Z"/>
260
+ <path d="M9.2981 1.91602C10.111 1.91604 10.9109 2.02122 11.6975 2.23096C12.4855 2.44111 13.1683 2.74431 13.7417 3.14429L13.8655 3.23071L13.8083 3.36987L13.1726 4.91235L13.0869 5.1189L12.8987 4.99878C12.3487 4.64881 11.761 4.38633 11.1365 4.21143L11.1328 4.20996C10.585 4.04564 10.0484 3.95419 9.52295 3.93384L9.2981 3.92944C8.22329 3.92944 7.44693 4.12611 6.94043 4.49121C6.44619 4.85665 6.20874 5.31616 6.20874 5.88135L6.21533 6.03296C6.24495 6.37662 6.37751 6.65526 6.61011 6.87964L6.72144 6.97632C6.98746 7.19529 7.30625 7.37584 7.68018 7.51538L8.05151 7.63184C8.45325 7.75061 8.94669 7.87679 9.53247 8.01123L9.53467 8.01196C10.1213 8.15305 10.6426 8.29569 11.0991 8.4375H15C15.5178 8.4375 15.9375 8.85723 15.9375 9.375C15.9375 9.89277 15.5178 10.3125 15 10.3125H3C2.48223 10.3125 2.0625 9.89277 2.0625 9.375C2.0625 8.85723 2.48223 8.4375 3 8.4375H4.93726C4.83783 8.34526 4.74036 8.24896 4.64795 8.146L4.64502 8.14233C4.1721 7.58596 3.94482 6.85113 3.94482 5.95825C3.94483 5.20441 4.14059 4.51965 4.53369 3.90967L4.53516 3.90747C4.94397 3.29427 5.55262 2.81114 6.34863 2.45288C7.15081 2.0919 8.13683 1.91602 9.2981 1.91602Z"/>
261
+ </svg>`,
262
+
263
+ "heading":
264
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
265
+ <path d="M11.5 2C12.0523 2 12.5 2.44772 12.5 3V3.5C12.5 4.05228 12.0523 4.5 11.5 4.5H8V15C8 15.5523 7.55228 16 7 16H6.5C5.94772 16 5.5 15.5523 5.5 15V4.5H2C1.44772 4.5 1 4.05228 1 3.5V3C1 2.44772 1.44772 2 2 2H11.5ZM16 7C16.5523 7 17 7.44772 17 8V8.5C17 9.05228 16.5523 9.5 16 9.5H15V15C15 15.5523 14.5523 16 14 16H13.5C12.9477 16 12.5 15.5523 12.5 15V9.5H11.5C10.9477 9.5 10.5 9.05228 10.5 8.5V8C10.5 7.44772 10.9477 7 11.5 7H16Z"/>
266
+ </svg>`,
267
+
268
+ "highlight":
269
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
270
+ <path d="M16.4564 14.4272C17.1356 15.5592 16.3204 17.0002 15.0003 17.0004C13.68 17.0004 12.864 15.5593 13.5433 14.4272L15.0003 12.0004L16.4564 14.4272ZM5.1214 1.70746C5.51192 1.31693 6.14494 1.31693 6.53546 1.70746L9.7171 4.8891L13.2532 8.42426C14.2295 9.40056 14.2295 10.9841 13.2532 11.9604L9.7171 15.4955C8.74078 16.4718 7.15822 16.4718 6.18195 15.4955L2.64679 11.9604C1.67048 10.9841 1.67048 9.40057 2.64679 8.42426L6.18195 4.8891C6.30299 4.76805 6.43323 4.66177 6.57062 4.57074L5.1214 3.12152C4.73091 2.73104 4.73099 2.09799 5.1214 1.70746ZM8.30304 6.30316C8.10776 6.10815 7.79119 6.10799 7.59601 6.30316L4.06085 9.83929L3.9964 9.91742C3.88661 10.0838 3.88645 10.3019 3.9964 10.4682L4.02277 10.5004H11.8763C12.0312 10.3043 12.02 10.0205 11.8392 9.83929L8.30304 6.30316Z"/>
271
+ </svg>`,
272
+
273
+ "link":
274
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
275
+ <path d="M12.8885 7.23091L13.9479 6.17155C14.5337 5.58576 14.5337 4.63602 13.9479 4.05023C13.3621 3.46444 12.4124 3.46444 11.8266 4.05023L8.29235 7.58446C7.9263 7.95051 7.90312 8.52994 8.2233 8.92271L8.36141 9.07463C8.68158 9.4674 8.65841 10.0468 8.29235 10.4129C7.90183 10.8034 7.26866 10.8034 6.87814 10.4129C5.70657 9.24131 5.70657 7.34182 6.87814 6.17025L10.4124 2.63602C11.7792 1.26918 13.9953 1.26918 15.3621 2.63602C16.729 4.00285 16.729 6.21893 15.3621 7.58576L14.3028 8.64512C13.9122 9.03564 13.2791 9.03564 12.8885 8.64512C12.498 8.2546 12.498 7.62143 12.8885 7.23091Z"/>
276
+ <path d="M5.11038 10.7664L4.04843 11.8284C3.46264 12.4142 3.46264 13.3639 4.04842 13.9497C4.63421 14.5355 5.58396 14.5355 6.16975 13.9497L9.70657 10.4129C10.0726 10.0468 10.0958 9.46741 9.77563 9.07464L9.63752 8.92272C9.31734 8.52995 9.34052 7.95052 9.70657 7.58446C10.0971 7.19394 10.7303 7.19394 11.1208 7.58446C12.2924 8.75604 12.2924 10.6555 11.1208 11.8271L7.58396 15.3639C6.21712 16.7308 4.00105 16.7308 2.63421 15.3639C1.26738 13.9971 1.26738 11.781 2.63421 10.4142L3.69617 9.35223C4.08669 8.96171 4.71986 8.96171 5.11038 9.35223C5.5009 9.74275 5.5009 10.3759 5.11038 10.7664Z"/>
277
+ </svg>`,
278
+
279
+ "quote":
280
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
281
+ <path d="M4.96387 4.23438C6.8769 4.23438 8.42767 5.78522 8.42773 7.69824C8.42773 8.32925 8.25769 8.92015 7.96289 9.42969L7.96387 9.43066L5.11816 14.3584C4.77659 14.95 4.02038 15.153 3.42871 14.8115C2.83701 14.4699 2.63397 13.7128 2.97559 13.1211L4.16113 11.0674C2.63532 10.7052 1.5 9.33485 1.5 7.69824C1.50006 5.78524 3.05086 4.2344 4.96387 4.23438ZM13.0361 4.23438C14.9491 4.23449 16.4999 5.7853 16.5 7.69824C16.5 8.32921 16.3299 8.92017 16.0352 9.42969L16.0361 9.43066L13.1904 14.3584C12.8488 14.9501 12.0917 15.1531 11.5 14.8115C10.9085 14.4698 10.7063 13.7127 11.0479 13.1211L12.2324 11.0674C10.7069 10.7049 9.57227 9.33461 9.57227 7.69824C9.57233 5.78522 11.1231 4.23438 13.0361 4.23438Z"/>
282
+ </svg>`,
283
+
284
+ "code":
285
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
286
+ <path d="M6.29289 3.79295C6.68342 3.40243 7.31643 3.40243 7.70696 3.79295C8.09748 4.18348 8.09748 4.81649 7.70696 5.20702L3.91399 8.99999L7.70696 12.793C8.09748 13.1835 8.09748 13.8165 7.70696 14.207C7.31643 14.5975 6.68342 14.5975 6.29289 14.207L1.79289 9.70702C1.40237 9.31649 1.40237 8.68348 1.79289 8.29295L6.29289 3.79295Z"/>
287
+ <path d="M11.707 3.79295C11.3164 3.40243 10.6834 3.40243 10.2929 3.79295C9.90237 4.18348 9.90237 4.81649 10.2929 5.20702L14.0859 8.99999L10.2929 12.793C9.90237 13.1835 9.90237 13.8165 10.2929 14.207C10.6834 14.5975 11.3164 14.5975 11.707 14.207L16.207 9.70702C16.5975 9.31649 16.5975 8.68348 16.207 8.29295L11.707 3.79295Z"/>
288
+ </svg>`,
289
+
290
+ "ul":
291
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
292
+ <path d="M3 12.5C3.82843 12.5 4.5 13.1716 4.5 14C4.5 14.8284 3.82843 15.5 3 15.5C2.17157 15.5 1.5 14.8284 1.5 14C1.5 13.1716 2.17157 12.5 3 12.5ZM15.5 13C16.0523 13 16.5 13.4477 16.5 14C16.5 14.5523 16.0523 15 15.5 15H7C6.44772 15 6 14.5523 6 14C6 13.4477 6.44772 13 7 13H15.5ZM3 7.5C3.82843 7.5 4.5 8.17157 4.5 9C4.5 9.82843 3.82843 10.5 3 10.5C2.17157 10.5 1.5 9.82843 1.5 9C1.5 8.17157 2.17157 7.5 3 7.5ZM15.5 8C16.0523 8 16.5 8.44772 16.5 9C16.5 9.55228 16.0523 10 15.5 10H7C6.44772 10 6 9.55228 6 9C6 8.44772 6.44772 8 7 8H15.5ZM3 2.5C3.82843 2.5 4.5 3.17157 4.5 4C4.5 4.82843 3.82843 5.5 3 5.5C2.17157 5.5 1.5 4.82843 1.5 4C1.5 3.17157 2.17157 2.5 3 2.5ZM15.5 3C16.0523 3 16.5 3.44772 16.5 4C16.5 4.55228 16.0523 5 15.5 5H7C6.44772 5 6 4.55228 6 4C6 3.44772 6.44772 3 7 3H15.5Z"/>
293
+ </svg>`,
294
+
295
+ "ol":
296
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
297
+ <path d="M15.5 13C16.0523 13 16.5 13.4477 16.5 14C16.5 14.5523 16.0523 15 15.5 15H7C6.44772 15 6 14.5523 6 14C6 13.4477 6.44772 13 7 13H15.5ZM15.5 8C16.0523 8 16.5 8.44772 16.5 9C16.5 9.55228 16.0523 10 15.5 10H7C6.44772 10 6 9.55228 6 9C6 8.44772 6.44772 8 7 8H15.5ZM15.5 3C16.0523 3 16.5 3.44772 16.5 4C16.5 4.55228 16.0523 5 15.5 5H7C6.44772 5 6 4.55228 6 4C6 3.44772 6.44772 3 7 3H15.5Z"/>
298
+ <path d="M2.98657 16.0967C2.68042 16.0967 2.41187 16.0465 2.18091 15.9463C1.95174 15.846 1.77002 15.7046 1.63574 15.522C1.50146 15.3376 1.42448 15.1227 1.40479 14.8774L1.4021 14.8452H2.34204L2.34741 14.8748C2.35815 14.9589 2.39038 15.035 2.44409 15.103C2.49959 15.1711 2.5721 15.2248 2.66162 15.2642C2.75293 15.3035 2.86035 15.3232 2.98389 15.3232C3.10563 15.3232 3.21037 15.3027 3.2981 15.2615C3.38761 15.2185 3.45654 15.1603 3.50488 15.0869C3.55322 15.0135 3.57739 14.9294 3.57739 14.8345V14.8291C3.57739 14.6715 3.51921 14.5516 3.40283 14.4692C3.28646 14.3869 3.12085 14.3457 2.90601 14.3457H2.48706V13.677H2.90063C3.02775 13.677 3.13607 13.6582 3.22559 13.6206C3.31689 13.583 3.38672 13.5302 3.43506 13.4622C3.48519 13.3941 3.51025 13.3153 3.51025 13.2258V13.2205C3.51025 13.1256 3.48877 13.0441 3.4458 12.9761C3.40462 12.9062 3.34375 12.8534 3.26318 12.8176C3.18441 12.78 3.08952 12.7612 2.97852 12.7612C2.86572 12.7612 2.76636 12.7809 2.68042 12.8203C2.59627 12.8579 2.52913 12.9125 2.479 12.9841C2.43066 13.054 2.40112 13.1363 2.39038 13.2312L2.3877 13.2581H1.49341L1.49609 13.2205C1.514 12.977 1.58561 12.7666 1.71094 12.5894C1.83805 12.4103 2.00903 12.2725 2.22388 12.1758C2.44051 12.0773 2.69206 12.0281 2.97852 12.0281C3.27393 12.0281 3.52995 12.0728 3.74658 12.1624C3.96322 12.2501 4.13062 12.3727 4.24878 12.5303C4.36694 12.6878 4.42603 12.8722 4.42603 13.0835V13.0889C4.42603 13.2518 4.38932 13.3941 4.31592 13.5159C4.2443 13.6358 4.14762 13.7343 4.02588 13.8113C3.90592 13.8883 3.77254 13.942 3.62573 13.9724V13.9912C3.91756 14.0199 4.14941 14.1121 4.32129 14.2678C4.49316 14.4236 4.5791 14.6295 4.5791 14.8855V14.8909C4.5791 15.1344 4.51375 15.3474 4.38306 15.53C4.25236 15.7109 4.06795 15.8505 3.82983 15.949C3.59172 16.0474 3.31063 16.0967 2.98657 16.0967Z"/>
299
+ <path d="M1.54443 11V10.342L2.76099 9.20874C2.95076 9.03507 3.09757 8.89274 3.20142 8.78174C3.30705 8.66895 3.37956 8.57316 3.41895 8.49438C3.46012 8.41382 3.48071 8.33415 3.48071 8.25537V8.24463C3.48071 8.14795 3.46012 8.0638 3.41895 7.99219C3.37777 7.92057 3.31779 7.86507 3.23901 7.82568C3.16024 7.7863 3.06714 7.7666 2.95972 7.7666C2.84692 7.7666 2.74756 7.78988 2.66162 7.83643C2.57747 7.88298 2.51123 7.94743 2.46289 8.02979C2.41455 8.11035 2.39038 8.20345 2.39038 8.30908V8.33057L1.48804 8.32788V8.31177C1.48804 8.05396 1.5507 7.82837 1.67603 7.63501C1.80314 7.44165 1.97949 7.29126 2.20508 7.18384C2.43245 7.07463 2.69653 7.02002 2.99731 7.02002C3.28556 7.02002 3.53711 7.06836 3.75195 7.16504C3.96859 7.25993 4.13688 7.39331 4.25684 7.56519C4.37858 7.73706 4.43945 7.93758 4.43945 8.16675V8.18018C4.43945 8.3252 4.40902 8.46932 4.34814 8.61255C4.28727 8.75578 4.18701 8.90885 4.04736 9.07178C3.90771 9.23291 3.71883 9.41642 3.48071 9.62231L2.58374 10.4092L2.85498 9.98486V10.4092L2.58374 10.2319H4.49048V11H1.54443Z"/>
300
+ <path d="M2.84155 6V3.01367H2.79053L1.85596 3.64478V2.79614L2.84155 2.12476H3.82715V6H2.84155Z"/>
301
+ </svg>`,
302
+
303
+ "attachment":
304
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
305
+ <path d="M13 13.5V6C13 4.067 11.433 2.5 9.5 2.5C7.567 2.5 6 4.067 6 6V13.5C6 14.6046 6.89543 15.5 8 15.5H8.23047C9.20759 15.5 10 14.7076 10 13.7305V7C10 6.72386 9.77614 6.5 9.5 6.5C9.22386 6.5 9 6.72386 9 7V12.5C9 13.0523 8.55228 13.5 8 13.5C7.44772 13.5 7 13.0523 7 12.5V7C7 5.61929 8.11929 4.5 9.5 4.5C10.8807 4.5 12 5.61929 12 7V13.7305C12 15.8122 10.3122 17.5 8.23047 17.5H8C5.79086 17.5 4 15.7091 4 13.5V6C4 2.96243 6.46243 0.5 9.5 0.5C12.5376 0.5 15 2.96243 15 6V13.5C15 14.0523 14.5523 14.5 14 14.5C13.4477 14.5 13 14.0523 13 13.5Z"/>
306
+ </svg>`,
307
+
308
+ "table":
309
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
310
+ <path d="M15 1C16.1046 1 17 1.89543 17 3V15C17 16.1046 16.1046 17 15 17H3C1.89543 17 1 16.1046 1 15V3C1 1.89543 1.89543 1 3 1H15ZM3 15H8V10H3V15ZM10 10V15H15V10H10ZM10 8H15V3H10V8ZM3 8H8V3H3V8Z"/>
311
+ </svg>`,
312
+
313
+ "hr":
314
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
315
+ <path d="M12.75 12C13.1642 12 13.5 12.3358 13.5 12.75V14.25C13.5 14.6642 13.1642 15 12.75 15H5.25C4.83579 15 4.5 14.6642 4.5 14.25V12.75C4.5 12.3358 4.83579 12 5.25 12H12.75ZM15.4863 8C16.0461 8 16.5 8.44771 16.5 9C16.5 9.55229 16.0461 10 15.4863 10H2.51367C1.95392 10 1.5 9.55229 1.5 9C1.5 8.44771 1.95392 8 2.51367 8H15.4863ZM12.75 3C13.1642 3 13.5 3.33579 13.5 3.75V5.25C13.5 5.66421 13.1642 6 12.75 6H5.25C4.83579 6 4.5 5.66421 4.5 5.25V3.75C4.5 3.33579 4.83579 3 5.25 3H12.75Z"/>
316
+ </svg>`,
317
+
318
+ "undo":
319
+ `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
320
+ <path d="M8.36612 5.36612C8.85427 4.87796 9.64554 4.87796 10.1337 5.36612C10.6218 5.85428 10.6218 6.64557 10.1337 7.13369L7.26748 9.9999H15.2499C18.1494 9.99996 20.4999 12.3504 20.4999 15.2499V19.2499C20.4999 19.9402 19.9402 20.4999 19.2499 20.4999C18.5596 20.4999 18 19.9402 17.9999 19.2499V15.2499C17.9999 13.7312 16.7686 12.5 15.2499 12.4999H7.26748L10.1337 15.3661C10.6218 15.8543 10.6218 16.6456 10.1337 17.1337C9.64557 17.6218 8.85428 17.6218 8.36612 17.1337L3.36612 12.1337C2.87796 11.6455 2.87796 10.8543 3.36612 10.3661L8.36612 5.36612Z"/>
321
+ </svg>`,
322
+
323
+ "redo":
324
+ `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
325
+ <path d="M15.6338 5.1163C15.1456 4.62814 14.3543 4.62814 13.8662 5.1163C13.3781 5.60446 13.3781 6.39575 13.8662 6.88388L16.7324 9.75009H8.74997C5.85052 9.75014 3.49997 12.1006 3.49997 15.0001V19.0001C3.50002 19.6904 4.05969 20.25 4.74997 20.2501C5.4403 20.2501 5.99992 19.6904 5.99997 19.0001V15.0001C5.99997 13.4813 7.23123 12.2501 8.74997 12.2501H16.7324L13.8662 15.1163C13.3781 15.6045 13.3781 16.3958 13.8662 16.8839C14.3543 17.372 15.1456 17.3719 15.6338 16.8839L20.6338 11.8839C21.1219 11.3957 21.1219 10.6045 20.6338 10.1163L15.6338 5.1163Z" />
326
+ </svg>`,
327
+
328
+ "overflow":
329
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
330
+ <path d="M3 6.75C4.24264 6.75 5.25 7.75736 5.25 9C5.25 10.2426 4.24264 11.25 3 11.25C1.75736 11.25 0.75 10.2426 0.75 9C0.75 7.75736 1.75736 6.75 3 6.75ZM9 6.75C10.2426 6.75 11.25 7.75736 11.25 9C11.25 10.2426 10.2426 11.25 9 11.25C7.75736 11.25 6.75 10.2426 6.75 9C6.75 7.75736 7.75736 6.75 9 6.75ZM15 6.75C16.2426 6.75 17.25 7.75736 17.25 9C17.25 10.2426 16.2426 11.25 15 11.25C13.7574 11.25 12.75 10.2426 12.75 9C12.75 7.75736 13.7574 6.75 15 6.75Z"/>
331
+ </svg>`
332
+ };
333
+
246
334
  class LexicalToolbarElement extends HTMLElement {
247
335
  static observedAttributes = [ "connected" ]
248
336
 
@@ -556,22 +644,24 @@ class LexicalToolbarElement extends HTMLElement {
556
644
  static get defaultTemplate() {
557
645
  return `
558
646
  <button class="lexxy-editor__toolbar-button" type="button" name="bold" data-command="bold" title="Bold">
559
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5 22V2h8.183c1.764 0 3.174.435 4.228 1.304 1.055.87 1.582 2.076 1.582 3.62 0 .8-.148 1.503-.445 2.109a3.94 3.94 0 01-1.194 1.465 4.866 4.866 0 01-1.726.806v.176c.786.078 1.51.312 2.172.703a4.293 4.293 0 011.596 1.627c.403.693.604 1.543.604 2.549 0 1.192-.292 2.207-.877 3.048-.585.84-1.39 1.484-2.416 1.934-1.026.44-2.206.659-3.538.659H5zM8.854 4.974v5.348h2.56c.873 0 1.582-.107 2.129-.322.556-.215.963-.523 1.222-.923.269-.41.403-.904.403-1.48 0-.82-.254-1.46-.762-1.92-.499-.468-1.204-.703-2.115-.703H8.854zm0 8.103v5.949h2.877c1.534 0 2.636-.245 3.307-.733.671-.498 1.007-1.221 1.007-2.168 0-.635-.134-1.178-.403-1.627-.268-.459-.666-.81-1.193-1.055-.518-.244-1.156-.366-1.913-.366H8.854z"/></svg>
647
+ ${ToolbarIcons.bold}
560
648
  </button>
561
649
 
562
650
  <button class="lexxy-editor__toolbar-button" type="button" name="italic" data-command="italic" title="Italic">
563
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.1 4h-1.5l-3.2 16h1.5l-.4 2h-7l.4-2h1.5l3.2-16h-1.5l.4-2h7l-.4 2z"/></svg>
651
+ ${ToolbarIcons.italic}
652
+ </button>
653
+
654
+ <button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough">
655
+ ${ToolbarIcons.strikethrough}
564
656
  </button>
565
657
 
566
- <button class="lexxy-editor__toolbar-button" type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough">
567
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
568
- <path fill-rule="evenodd" clip-rule="evenodd" d="M4.70588 16.1591C4.81459 19.7901 7.48035 22 11.6668 22C15.9854 22 18.724 19.6296 18.724 15.8779C18.724 15.5007 18.6993 15.1427 18.6474 14.8066H14.3721C14.8637 15.2085 15.0799 15.7037 15.0799 16.3471C15.0799 17.7668 13.7532 18.7984 11.8113 18.7984C9.88053 18.7984 8.38582 17.7531 8.21659 16.1591H4.70588ZM5.23953 9.31962H9.88794C9.10723 8.88889 8.75888 8.33882 8.75888 7.57339C8.75888 6.13992 9.96576 5.18793 11.7631 5.18793C13.5852 5.18793 14.8761 6.1797 14.9959 7.81344H18.4102C18.3485 4.31824 15.8038 2 11.752 2C7.867 2 5.09129 4.35802 5.09129 7.92044C5.09129 8.41838 5.14071 8.88477 5.23953 9.31962ZM2.23529 10.6914C1.90767 10.6914 1.59347 10.8359 1.36181 11.0931C1.13015 11.3504 1 11.6993 1 12.0631C1 12.4269 1.13015 12.7758 1.36181 13.0331C1.59347 13.2903 1.90767 13.4348 2.23529 13.4348H20.7647C21.0923 13.4348 21.4065 13.2903 21.6382 13.0331C21.8699 12.7758 22 12.4269 22 12.0631C22 11.6993 21.8699 11.3504 21.6382 11.0931C21.4065 10.8359 21.0923 10.6914 20.7647 10.6914H2.23529Z"/>
569
- </svg>
658
+ <button class="lexxy-editor__toolbar-button" type="button" name="heading" data-command="rotateHeadingFormat" title="Heading">
659
+ ${ToolbarIcons.heading}
570
660
  </button>
571
661
 
572
662
  <details class="lexxy-editor__toolbar-dropdown" name="lexxy-dropdown">
573
663
  <summary class="lexxy-editor__toolbar-button" name="highlight" title="Color highlight">
574
- <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>
664
+ ${ToolbarIcons.highlight}
575
665
  </summary>
576
666
  <lexxy-highlight-dropdown class="lexxy-editor__toolbar-dropdown-content">
577
667
  <div class="lexxy-highlight-colors"></div>
@@ -581,7 +671,7 @@ class LexicalToolbarElement extends HTMLElement {
581
671
 
582
672
  <details class="lexxy-editor__toolbar-dropdown" name="lexxy-dropdown">
583
673
  <summary class="lexxy-editor__toolbar-button" name="link" title="Link" data-hotkey="cmd+k ctrl+k">
584
- <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>
674
+ ${ToolbarIcons.link}
585
675
  </summary>
586
676
  <lexxy-link-dropdown class="lexxy-editor__toolbar-dropdown-content">
587
677
  <form method="dialog">
@@ -595,49 +685,45 @@ class LexicalToolbarElement extends HTMLElement {
595
685
  </details>
596
686
 
597
687
  <button class="lexxy-editor__toolbar-button" type="button" name="quote" data-command="insertQuoteBlock" title="Quote">
598
- <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>
599
- </button>
600
-
601
- <button class="lexxy-editor__toolbar-button" type="button" name="heading" data-command="rotateHeadingFormat" title="Heading">
602
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M15.322 5.315H9.64V22H5.684V5.315H0v-3.31h15.322v3.31z"/><path d="M23.957 11.79H19.92V22h-3.402V11.79H12.48V9.137h11.477v2.653z"/></svg>
688
+ ${ToolbarIcons.quote}
603
689
  </button>
604
690
 
605
- <button class="lexxy-editor__toolbar-button" type="button" name="code" data-command="insertCodeBlock" title="Code">
606
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M10.121 6l-6 6 6 6-2.12 2.121-7.061-7.06a1.5 1.5 0 010-2.121L8 3.879 10.121 6zM23.06 10.94a1.5 1.5 0 010 2.12L16 20.121 13.88 18l6-6-6-6L16 3.879l7.06 7.06z"/></svg>
691
+ <button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" type="button" name="code" data-command="insertCodeBlock" title="Code">
692
+ ${ToolbarIcons.code}
607
693
  </button>
608
694
 
609
695
  <button class="lexxy-editor__toolbar-button" type="button" name="unordered-list" data-command="insertUnorderedList" title="Bullet list">
610
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5 5a2 2 0 11-4 0 2 2 0 014 0zM5 12a2 2 0 11-4 0 2 2 0 014 0zM5 19a2 2 0 11-4 0 2 2 0 014 0zM7 5.25C7 4.56 7.56 4 8.25 4h13.5a1.25 1.25 0 110 2.5H8.25C7.56 6.5 7 5.94 7 5.25zM7 12.25c0-.69.56-1.25 1.25-1.25h13.5a1.25 1.25 0 110 2.5H8.25c-.69 0-1.25-.56-1.25-1.25zM7 19.25c0-.69.56-1.25 1.25-1.25h13.5a1.25 1.25 0 110 2.5H8.25c-.69 0-1.25-.56-1.25-1.25z"/></svg>
696
+ ${ToolbarIcons.ul}
611
697
  </button>
612
698
 
613
- <button class="lexxy-editor__toolbar-button" type="button" name="ordered-list" data-command="insertOrderedList" title="Numbered list">
614
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7 5.25C7 4.56 7.56 4 8.25 4h13.5a1.25 1.25 0 110 2.5H8.25C7.56 6.5 7 5.94 7 5.25zM7 12.25c0-.69.56-1.25 1.25-1.25h13.5a1.25 1.25 0 110 2.5H8.25c-.69 0-1.25-.56-1.25-1.25zM7 19.25c0-.69.56-1.25 1.25-1.25h13.5a1.25 1.25 0 110 2.5H8.25c-.69 0-1.25-.56-1.25-1.25zM4.438 8H3.39V3.684H3.34c-.133.093-.267.188-.402.285l-.407.289a129.5 129.5 0 00-.402.285v-.969l.633-.453c.21-.15.42-.302.629-.453h1.046V8zM2.672 11.258h-1v-.051c0-.206.036-.405.11-.598.075-.195.188-.37.34-.527.15-.156.339-.281.566-.375.229-.094.498-.14.808-.14.367 0 .688.065.961.195s.484.308.633.535c.15.224.226.478.226.762 0 .244-.046.463-.14.656-.091.19-.209.368-.352.535-.14.164-.289.332-.445.504L3.168 14.09v.05h2.238V15H1.723v-.656l1.949-2.102c.096-.101.19-.207.281-.316.091-.112.167-.232.227-.36a.953.953 0 00.09-.41.712.712 0 00-.387-.648.845.845 0 00-.41-.098.81.81 0 00-.43.11.75.75 0 00-.277.293.824.824 0 00-.094.386V11.258zM2.852 19.66v-.812h.562a.917.917 0 00.43-.098.742.742 0 00.293-.266.673.673 0 00.101-.379.654.654 0 00-.234-.523.87.87 0 00-.59-.2.987.987 0 00-.336.055.837.837 0 00-.258.149.712.712 0 00-.172.215.66.66 0 00-.066.25h-.98c.007-.209.053-.403.136-.582.084-.18.203-.336.36-.469.156-.135.346-.24.57-.316.227-.076.486-.115.777-.118a2.33 2.33 0 01.965.176c.271.12.48.285.63.496.15.209.227.448.23.719a1.11 1.11 0 01-.16.637 1.28 1.28 0 01-.825.586v.054c.162.016.33.07.504.164.177.094.328.232.453.415.125.18.189.411.192.695a1.37 1.37 0 01-.157.676c-.104.197-.25.365-.437.503-.188.136-.404.24-.649.313-.242.07-.5.105-.777.105-.401 0-.743-.067-1.027-.203a1.608 1.608 0 01-.649-.547 1.46 1.46 0 01-.238-.75h.969c.01.128.057.243.14.344a.885.885 0 00.332.238c.141.058.3.088.477.09.195 0 .366-.034.512-.101a.798.798 0 00.336-.29.744.744 0 00.117-.425.74.74 0 00-.446-.695 1.082 1.082 0 00-.496-.106h-.59z"/></svg>
699
+ <button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" type="button" name="ordered-list" data-command="insertOrderedList" title="Numbered list">
700
+ ${ToolbarIcons.ol}
615
701
  </button>
616
702
 
617
703
  <button class="lexxy-editor__toolbar-button" type="button" name="upload" data-command="uploadAttachments" title="Upload file">
618
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16 8a2 2 0 110 4 2 2 0 010-4z""/><path d="M22 2a1 1 0 011 1v18a1 1 0 01-1 1H2a1 1 0 01-1-1V3a1 1 0 011-1h20zM3 18.714L9 11l5.25 6.75L17 15l4 4V4H3v14.714z"/></svg>
704
+ ${ToolbarIcons.attachment}
619
705
  </button>
620
706
 
621
707
  <button class="lexxy-editor__toolbar-button" type="button" name="table" data-command="insertTable" title="Insert a table">
622
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.2041 2.01074C21.2128 2.113 22 2.96435 22 4V20L21.9893 20.2041C21.8938 21.1457 21.1457 21.8938 20.2041 21.9893L20 22H4C2.96435 22 2.113 21.2128 2.01074 20.2041L2 20V4C2 2.89543 2.89543 2 4 2H20L20.2041 2.01074ZM4 13V20H11V13H4ZM13 13V20H20V13H13ZM4 11H11V4H4V11ZM13 11H20V4H13V11Z"/></svg>
708
+ ${ToolbarIcons.table}
623
709
  </button>
624
710
 
625
711
  <button class="lexxy-editor__toolbar-button" type="button" name="divider" data-command="insertHorizontalDivider" title="Insert a divider">
626
- <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>
712
+ ${ToolbarIcons.hr}
627
713
  </button>
628
714
 
629
715
  <div class="lexxy-editor__toolbar-spacer" role="separator"></div>
630
716
 
631
717
  <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo">
632
- <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>
718
+ ${ToolbarIcons.undo}
633
719
  </button>
634
720
 
635
721
  <button class="lexxy-editor__toolbar-button" type="button" name="redo" data-command="redo" title="Redo">
636
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.2599 8.26531C15.9672 6.56386 13.1237 5.77629 10.2823 6.05535C7.4408 6.33452 4.80455 7.66079 2.88681 9.77605C1.32245 11.5016 0.326407 13.6516 0.0127834 15.9352C-0.105117 16.7939 0.608975 17.4997 1.47567 17.4997C2.34228 17.4997 3.02969 16.7915 3.19149 15.9401C3.47682 14.4379 4.17156 13.0321 5.212 11.8844C6.60637 10.3464 8.52287 9.38139 10.589 9.17839C12.655 8.97546 14.7227 9.54856 16.3897 10.7858C17.5237 11.6275 18.4165 12.7361 18.9991 13.9997H15.4063C14.578 13.9997 13.9066 14.6714 13.9063 15.4997C13.9063 16.3281 14.5779 16.9997 15.4063 16.9997H22.4063C23.2348 16.9997 23.9063 16.3281 23.9063 15.4997V8.49968C23.9061 7.67144 23.2346 6.99968 22.4063 6.99968C21.578 6.99968 20.9066 7.67144 20.9063 8.49968V11.0212C20.1897 9.9704 19.2984 9.03613 18.2599 8.26531Z"/></svg>
722
+ ${ToolbarIcons.redo}
637
723
  </button>
638
724
 
639
725
  <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-overflow" name="lexxy-dropdown">
640
- <summary class="lexxy-editor__toolbar-button" aria-label="Show more toolbar buttons">•••</summary>
726
+ <summary class="lexxy-editor__toolbar-button" aria-label="Show more toolbar buttons">${ToolbarIcons.overflow}</summary>
641
727
  <div class="lexxy-editor__toolbar-dropdown-content lexxy-editor__toolbar-overflow-menu" aria-label="More toolbar buttons"></div>
642
728
  </details>
643
729
  `
@@ -717,127 +803,54 @@ var theme = {
717
803
  }
718
804
  };
719
805
 
720
- function bytesToHumanSize(bytes) {
721
- if (bytes === 0) return "0 B"
722
- const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
723
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
724
- const value = bytes / Math.pow(1024, i);
725
- return `${ value.toFixed(2) } ${ sizes[i] }`
726
- }
727
-
728
- class ActionTextAttachmentNode extends DecoratorNode {
806
+ class HorizontalDividerNode extends DecoratorNode {
729
807
  static getType() {
730
- return "action_text_attachment"
808
+ return "horizontal_divider"
731
809
  }
732
810
 
733
811
  static clone(node) {
734
- return new ActionTextAttachmentNode({ ...node }, node.__key)
812
+ return new HorizontalDividerNode(node.__key)
735
813
  }
736
814
 
737
815
  static importJSON(serializedNode) {
738
- return new ActionTextAttachmentNode({ ...serializedNode })
816
+ return new HorizontalDividerNode()
739
817
  }
740
818
 
741
819
  static importDOM() {
742
820
  return {
743
- [this.TAG_NAME]: () => {
744
- return {
745
- conversion: (attachment) => ({
746
- node: new ActionTextAttachmentNode({
747
- sgid: attachment.getAttribute("sgid"),
748
- src: attachment.getAttribute("url"),
749
- previewable: attachment.getAttribute("previewable"),
750
- altText: attachment.getAttribute("alt"),
751
- caption: attachment.getAttribute("caption"),
752
- contentType: attachment.getAttribute("content-type"),
753
- fileName: attachment.getAttribute("filename"),
754
- fileSize: attachment.getAttribute("filesize"),
755
- width: attachment.getAttribute("width"),
756
- height: attachment.getAttribute("height")
757
- })
758
- }), priority: 1
759
- }
760
- },
761
- "img": () => {
762
- return {
763
- conversion: (img) => ({
764
- node: new ActionTextAttachmentNode({
765
- src: img.getAttribute("src"),
766
- caption: img.getAttribute("alt") || "",
767
- contentType: "image/*",
768
- width: img.getAttribute("width"),
769
- height: img.getAttribute("height")
770
- })
771
- }), priority: 1
772
- }
773
- },
774
- "video": () => {
821
+ "hr": (hr) => {
775
822
  return {
776
- conversion: (video) => {
777
- const videoSource = video.getAttribute("src") || video.querySelector("source")?.src;
778
- const fileName = videoSource?.split("/")?.pop();
779
- const contentType = video.querySelector("source")?.getAttribute("content-type") || "video/*";
780
-
781
- return {
782
- node: new ActionTextAttachmentNode({
783
- src: videoSource,
784
- fileName: fileName,
785
- contentType: contentType
786
- })
787
- }
788
- }, priority: 1
823
+ conversion: () => ({
824
+ node: new HorizontalDividerNode()
825
+ }),
826
+ priority: 1
789
827
  }
790
828
  }
791
829
  }
792
830
  }
793
831
 
794
- static get TAG_NAME() {
795
- return Lexxy.global.get("attachmentTagName")
796
- }
797
-
798
- constructor({ tagName, sgid, src, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) {
832
+ constructor(key) {
799
833
  super(key);
800
-
801
- this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
802
- this.sgid = sgid;
803
- this.src = src;
804
- this.previewable = previewable;
805
- this.altText = altText || "";
806
- this.caption = caption || "";
807
- this.contentType = contentType || "";
808
- this.fileName = fileName || "";
809
- this.fileSize = fileSize;
810
- this.width = width;
811
- this.height = height;
812
-
813
- this.editor = $getEditor();
814
834
  }
815
835
 
816
836
  createDOM() {
817
- const figure = this.createAttachmentFigure();
837
+ const figure = createElement("figure", { className: "horizontal-divider" });
838
+ const hr = createElement("hr");
818
839
 
819
- if (this.isPreviewableAttachment) {
820
- figure.appendChild(this.#createDOMForImage());
821
- figure.appendChild(this.#createEditableCaption());
822
- } else {
823
- figure.appendChild(this.#createDOMForFile());
824
- figure.appendChild(this.#createDOMForNotImage());
825
- }
840
+ figure.appendChild(hr);
841
+
842
+ const deleteButton = createElement("lexxy-node-delete-button");
843
+ figure.appendChild(deleteButton);
826
844
 
827
845
  return figure
828
846
  }
829
847
 
830
- updateDOM(_prevNode, dom) {
831
- const caption = dom.querySelector("figcaption textarea");
832
- if (caption && this.caption) {
833
- caption.value = this.caption;
834
- }
835
-
836
- return false
848
+ updateDOM() {
849
+ return true
837
850
  }
838
851
 
839
852
  getTextContent() {
840
- return `[${this.caption || this.fileName}]\n\n`
853
+ return "┄\n\n"
841
854
  }
842
855
 
843
856
  isInline() {
@@ -845,135 +858,23 @@ class ActionTextAttachmentNode extends DecoratorNode {
845
858
  }
846
859
 
847
860
  exportDOM() {
848
- const attachment = createElement(this.tagName, {
849
- sgid: this.sgid,
850
- previewable: this.previewable || null,
851
- url: this.src,
852
- alt: this.altText,
853
- caption: this.caption,
854
- "content-type": this.contentType,
855
- filename: this.fileName,
856
- filesize: this.fileSize,
857
- width: this.width,
858
- height: this.height,
859
- presentation: "gallery"
860
- });
861
-
862
- return { element: attachment }
861
+ const hr = createElement("hr");
862
+ return { element: hr }
863
863
  }
864
864
 
865
865
  exportJSON() {
866
866
  return {
867
- type: "action_text_attachment",
868
- version: 1,
869
- tagName: this.tagName,
870
- sgid: this.sgid,
871
- src: this.src,
872
- previewable: this.previewable,
873
- altText: this.altText,
874
- caption: this.caption,
875
- contentType: this.contentType,
876
- fileName: this.fileName,
877
- fileSize: this.fileSize,
878
- width: this.width,
879
- height: this.height
867
+ type: "horizontal_divider",
868
+ version: 1
880
869
  }
881
870
  }
882
871
 
883
872
  decorate() {
884
873
  return null
885
874
  }
886
-
887
- createAttachmentFigure() {
888
- return createAttachmentFigure(this.contentType, this.isPreviewableAttachment, this.fileName)
889
- }
890
-
891
- get #isPreviewableImage() {
892
- return isPreviewableImage(this.contentType)
893
- }
894
-
895
- get isPreviewableAttachment() {
896
- return this.#isPreviewableImage || this.previewable
897
- }
898
-
899
- #createDOMForImage() {
900
- return createElement("img", { src: this.src, alt: this.altText, ...this.#imageDimensions })
901
- }
902
-
903
- get #imageDimensions() {
904
- if (this.width && this.height) {
905
- return { width: this.width, height: this.height }
906
- } else {
907
- return {}
908
- }
909
- }
910
-
911
- #createDOMForFile() {
912
- const extension = this.fileName ? this.fileName.split(".").pop().toLowerCase() : "unknown";
913
- return createElement("span", { className: "attachment__icon", textContent: `${extension}` })
914
- }
915
-
916
- #createDOMForNotImage() {
917
- const figcaption = createElement("figcaption", { className: "attachment__caption" });
918
-
919
- const nameTag = createElement("strong", { className: "attachment__name", textContent: this.caption || this.fileName });
920
-
921
- figcaption.appendChild(nameTag);
922
-
923
- if (this.fileSize) {
924
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.fileSize) });
925
- figcaption.appendChild(sizeSpan);
926
- }
927
-
928
- return figcaption
929
- }
930
-
931
- #createEditableCaption() {
932
- const caption = createElement("figcaption", { className: "attachment__caption" });
933
- const input = createElement("textarea", {
934
- value: this.caption,
935
- placeholder: this.fileName,
936
- rows: "1"
937
- });
938
-
939
- input.addEventListener("focusin", () => input.placeholder = "Add caption...");
940
- input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
941
- input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
942
-
943
- caption.appendChild(input);
944
-
945
- return caption
946
- }
947
-
948
- #handleCaptionInputBlurred(event) {
949
- this.#updateCaptionValueFromInput(event.target);
950
- }
951
-
952
- #updateCaptionValueFromInput(input) {
953
- input.placeholder = this.fileName;
954
- this.editor.update(() => {
955
- this.getWritable().caption = input.value;
956
- });
957
- }
958
-
959
- #handleCaptionInputKeydown(event) {
960
- if (event.key === "Enter") {
961
- event.preventDefault();
962
- event.stopPropagation();
963
- event.target.blur();
964
-
965
- this.editor.update(() => {
966
- // Place the cursor after the current image
967
- this.selectNext(0, 0);
968
- }, {
969
- tag: HISTORY_MERGE_TAG
970
- });
971
- }
972
-
973
- }
974
875
  }
975
876
 
976
- const SILENT_UPDATE_TAGS = [ HISTORY_MERGE_TAG, SKIP_DOM_SELECTION_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
877
+ const SILENT_UPDATE_TAGS = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
977
878
 
978
879
  function $createNodeSelectionWith(...nodes) {
979
880
  const selection = $createNodeSelection();
@@ -981,11 +882,34 @@ function $createNodeSelectionWith(...nodes) {
981
882
  return selection
982
883
  }
983
884
 
885
+ function $makeSafeForRoot(node) {
886
+ if ($isTextNode(node)) {
887
+ return $wrapNodeInElement(node, $createParagraphNode)
888
+ } else if (node.isParentRequired()) {
889
+ const parent = node.createRequiredParent();
890
+ return $wrapNodeInElement(node, parent)
891
+ } else {
892
+ return node
893
+ }
894
+ }
895
+
984
896
  function getListType(node) {
985
897
  const list = $getNearestNodeOfType(node, ListNode);
986
898
  return list?.getListType() ?? null
987
899
  }
988
900
 
901
+ function $isAtNodeEdge(point, atStart = null) {
902
+ if (atStart === null) {
903
+ return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
904
+ } else {
905
+ return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
906
+ }
907
+ }
908
+
909
+ function $isAtNodeStart(point) {
910
+ return point.offset === 0
911
+ }
912
+
989
913
  function extendTextNodeConversion(conversionName, ...callbacks) {
990
914
  return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
991
915
  ...conversionOutput,
@@ -1017,1928 +941,2452 @@ function extendConversion(nodeKlass, conversionName, callback = (output => outpu
1017
941
  }
1018
942
  }
1019
943
 
1020
- async function loadFileIntoImage(file, image) {
1021
- return new Promise((resolve) => {
1022
- const reader = new FileReader();
1023
-
1024
- image.addEventListener("load", () => {
1025
- resolve(image);
1026
- });
944
+ function isSelectionHighlighted(selection) {
945
+ if (!$isRangeSelection(selection)) return false
1027
946
 
1028
- reader.onload = (event) => {
1029
- image.src = event.target.result || null;
1030
- };
947
+ if (selection.isCollapsed()) {
948
+ return hasHighlightStyles(selection.style)
949
+ } else {
950
+ return selection.hasFormat("highlight")
951
+ }
952
+ }
1031
953
 
1032
- reader.readAsDataURL(file);
1033
- })
954
+ function hasHighlightStyles(cssOrStyles) {
955
+ const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
956
+ return !!(styles.color || styles["background-color"])
1034
957
  }
1035
958
 
1036
- class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
1037
- static getType() {
1038
- return "action_text_attachment_upload"
1039
- }
959
+ function applyCanonicalizers(styles, canonicalizers = []) {
960
+ return canonicalizers.reduce((css, canonicalizer) => {
961
+ return canonicalizer.applyCanonicalization(css)
962
+ }, styles)
963
+ }
1040
964
 
1041
- static clone(node) {
1042
- return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
965
+ class StyleCanonicalizer {
966
+ constructor(property, allowedValues= []) {
967
+ this._property = property;
968
+ this._allowedValues = allowedValues;
969
+ this._canonicalValues = this.#allowedValuesIdentityObject;
1043
970
  }
1044
971
 
1045
- static importJSON(serializedNode) {
1046
- return new ActionTextAttachmentUploadNode({ ...serializedNode })
1047
- }
972
+ applyCanonicalization(css) {
973
+ const styles = { ...getStyleObjectFromCSS(css) };
1048
974
 
1049
- // Should never run since this is a transient node. Defined to remove console warning.
1050
- static importDOM() {
1051
- return null
1052
- }
975
+ styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
976
+ if (!styles[this._property]) {
977
+ delete styles[this._property];
978
+ }
1053
979
 
1054
- constructor(node, key) {
1055
- const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node;
1056
- super({ ...node, contentType: file.type }, key);
1057
- this.file = file;
1058
- this.uploadUrl = uploadUrl;
1059
- this.blobUrlTemplate = blobUrlTemplate;
1060
- this.progress = progress ?? null;
1061
- this.width = width;
1062
- this.height = height;
1063
- this.uploadError = uploadError;
980
+ return getCSSFromStyleObject(styles)
1064
981
  }
1065
982
 
1066
- createDOM() {
1067
- if (this.uploadError) return this.#createDOMForError()
983
+ getCanonicalAllowedValue(value) {
984
+ return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
985
+ }
1068
986
 
1069
- // This side-effect is trigged on DOM load to fire only once and avoid multiple
1070
- // uploads through cloning. The upload is guarded from restarting in case the
1071
- // node is reloaded from saved state such as from history.
1072
- this.#startUploadIfNeeded();
987
+ // Private
1073
988
 
1074
- const figure = this.createAttachmentFigure();
989
+ get #allowedValuesIdentityObject() {
990
+ return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
991
+ }
1075
992
 
1076
- if (this.isPreviewableAttachment) {
1077
- const img = figure.appendChild(this.#createDOMForImage());
993
+ #resolveCannonicalValue(value) {
994
+ let index = this.#computedAllowedValues.indexOf(value);
995
+ index ||= this.#computedAllowedValues.indexOf(getComputedStyleForProperty(this._property, value));
996
+ return index === -1 ? null : this._allowedValues[index]
997
+ }
1078
998
 
1079
- // load file locally to set dimensions and prevent vertical shifting
1080
- loadFileIntoImage(this.file, img).then(img => this.#setDimensionsFromImage(img));
1081
- } else {
1082
- figure.appendChild(this.#createDOMForFile());
1083
- }
999
+ get #computedAllowedValues() {
1000
+ return this._computedAllowedValues ||= this._allowedValues.map(
1001
+ value => getComputedStyleForProperty(this._property, value)
1002
+ )
1003
+ }
1004
+ }
1084
1005
 
1085
- figure.appendChild(this.#createCaption());
1086
- figure.appendChild(this.#createProgressBar());
1006
+ function getComputedStyleForProperty(property, value) {
1007
+ const style = `${property}: ${value};`;
1087
1008
 
1088
- return figure
1089
- }
1009
+ // the element has to be attached to the DOM have computed styles
1010
+ const element = document.body.appendChild(createElement("span", { style: "display: none;" + style }));
1011
+ const computedStyle = window.getComputedStyle(element).getPropertyValue(property);
1012
+ element.remove();
1090
1013
 
1091
- updateDOM(prevNode, dom) {
1092
- if (this.uploadError !== prevNode.uploadError) return true
1014
+ return computedStyle
1015
+ }
1093
1016
 
1094
- if (prevNode.progress !== this.progress) {
1095
- const progress = dom.querySelector("progress");
1096
- progress.value = this.progress ?? 0;
1097
- }
1017
+ class LexxyExtension {
1018
+ #editorElement
1098
1019
 
1099
- return false
1020
+ constructor(editorElement) {
1021
+ this.#editorElement = editorElement;
1100
1022
  }
1101
1023
 
1102
- exportDOM() {
1103
- return { element: null }
1024
+ get editorElement() {
1025
+ return this.#editorElement
1104
1026
  }
1105
1027
 
1106
- exportJSON() {
1107
- return {
1108
- ...super.exportJSON(),
1109
- type: "action_text_attachment_upload",
1110
- version: 1,
1111
- uploadUrl: this.uploadUrl,
1112
- blobUrlTemplate: this.blobUrlTemplate,
1113
- progress: this.progress,
1114
- width: this.width,
1115
- height: this.height,
1116
- uploadError: this.uploadError
1117
- }
1028
+ get editorConfig() {
1029
+ return this.#editorElement.config
1118
1030
  }
1119
1031
 
1120
- get #uploadStarted() {
1121
- return this.progress !== null
1032
+ // optional: defaults to true
1033
+ get enabled() {
1034
+ return true
1122
1035
  }
1123
1036
 
1124
- #createDOMForError() {
1125
- const figure = this.createAttachmentFigure();
1126
- figure.classList.add("attachment--error");
1127
- figure.appendChild(createElement("div", { innerText: `Error uploading ${this.file?.name ?? "file"}` }));
1128
- return figure
1037
+ get lexicalExtension() {
1038
+ return null
1129
1039
  }
1130
1040
 
1131
- #createDOMForImage() {
1132
- return createElement("img")
1133
- }
1041
+ initializeToolbar(_lexxyToolbar) {
1134
1042
 
1135
- #createDOMForFile() {
1136
- const extension = this.#getFileExtension();
1137
- const span = createElement("span", { className: "attachment__icon", textContent: extension });
1138
- return span
1139
1043
  }
1044
+ }
1140
1045
 
1141
- #getFileExtension() {
1142
- return this.file.name.split(".").pop().toLowerCase()
1046
+ const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
1047
+ const REMOVE_HIGHLIGHT_COMMAND = createCommand();
1048
+ const BLANK_STYLES = { "color": null, "background-color": null };
1049
+
1050
+ const hasPastedStylesState = createState("hasPastedStyles", {
1051
+ parse: (value) => value || false
1052
+ });
1053
+
1054
+ class HighlightExtension extends LexxyExtension {
1055
+ get enabled() {
1056
+ return this.editorElement.supportsRichText
1143
1057
  }
1144
1058
 
1145
- #createCaption() {
1146
- const figcaption = createElement("figcaption", { className: "attachment__caption" });
1059
+ get lexicalExtension() {
1060
+ const extension = defineExtension({
1061
+ dependencies: [ RichTextExtension ],
1062
+ name: "lexxy/highlight",
1063
+ config: {
1064
+ color: { buttons: [], permit: [] },
1065
+ "background-color": { buttons: [], permit: [] }
1066
+ },
1067
+ html: {
1068
+ import: {
1069
+ mark: $markConversion
1070
+ }
1071
+ },
1072
+ register(editor, config) {
1073
+ // keep the ref to the canonicalizers for optimized css conversion
1074
+ const canonicalizers = buildCanonicalizers(config);
1147
1075
 
1148
- const nameSpan = createElement("span", { className: "attachment__name", textContent: this.file.name || "" });
1149
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
1150
- figcaption.appendChild(nameSpan);
1151
- figcaption.appendChild(sizeSpan);
1076
+ return mergeRegister(
1077
+ editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, $toggleSelectionStyles, COMMAND_PRIORITY_NORMAL),
1078
+ editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
1079
+ editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
1080
+ editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers))
1081
+ )
1082
+ }
1083
+ });
1152
1084
 
1153
- return figcaption
1085
+ return [ extension, this.editorConfig.get("highlight") ]
1154
1086
  }
1087
+ }
1155
1088
 
1156
- #createProgressBar() {
1157
- return createElement("progress", { value: this.progress ?? 0, max: 100 })
1158
- }
1089
+ function $applyHighlightStyle(textNode, element) {
1090
+ const elementStyles = {
1091
+ color: element.style?.color,
1092
+ "background-color": element.style?.backgroundColor
1093
+ };
1159
1094
 
1160
- #setDimensionsFromImage({ width, height }) {
1161
- if (this.#hasDimensions) return
1095
+ if ($hasUpdateTag(PASTE_TAG)) { $setPastedStyles(textNode); }
1096
+ const highlightStyle = getCSSFromStyleObject(elementStyles);
1162
1097
 
1163
- this.editor.update(() => {
1164
- const writable = this.getWritable();
1165
- writable.width = width;
1166
- writable.height = height;
1167
- }, { tag: SILENT_UPDATE_TAGS });
1098
+ if (highlightStyle.length) {
1099
+ return textNode.setStyle(textNode.getStyle() + highlightStyle)
1168
1100
  }
1101
+ }
1169
1102
 
1170
- get #hasDimensions() {
1171
- return Boolean(this.width && this.height)
1103
+ function $markConversion() {
1104
+ return {
1105
+ conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
1106
+ priority: 1
1172
1107
  }
1108
+ }
1173
1109
 
1174
- async #startUploadIfNeeded() {
1175
- if (this.#uploadStarted) return
1176
-
1177
- this.#setUploadStarted();
1110
+ function buildCanonicalizers(config) {
1111
+ return [
1112
+ new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
1113
+ new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
1114
+ ]
1115
+ }
1178
1116
 
1179
- const { DirectUpload } = await import('@rails/activestorage');
1117
+ function $toggleSelectionStyles(styles) {
1118
+ const selection = $getSelection();
1119
+ if (!$isRangeSelection(selection)) return
1180
1120
 
1181
- const upload = new DirectUpload(this.file, this.uploadUrl, this);
1182
- upload.delegate = this.#createUploadDelegate();
1183
- upload.create((error, blob) => {
1184
- if (error) {
1185
- this.#handleUploadError(error);
1186
- } else {
1187
- this.#showUploadedAttachment(blob);
1188
- }
1189
- });
1121
+ const patch = {};
1122
+ for (const property in styles) {
1123
+ const oldValue = $getSelectionStyleValueForProperty(selection, property);
1124
+ patch[property] = toggleOrReplace(oldValue, styles[property]);
1190
1125
  }
1191
1126
 
1192
- #createUploadDelegate() {
1193
- const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
1127
+ $patchStyleText(selection, patch);
1128
+ }
1194
1129
 
1195
- return {
1196
- directUploadWillCreateBlobWithXHR: (request) => {
1197
- if (shouldAuthenticateUploads) request.withCredentials = true;
1198
- },
1199
- directUploadWillStoreFileWithXHR: (request) => {
1200
- if (shouldAuthenticateUploads) request.withCredentials = true;
1130
+ function toggleOrReplace(oldValue, newValue) {
1131
+ return oldValue === newValue ? null : newValue
1132
+ }
1201
1133
 
1202
- const uploadProgressHandler = (event) => this.#handleUploadProgress(event);
1203
- request.upload.addEventListener("progress", uploadProgressHandler);
1204
- }
1205
- }
1206
- }
1207
-
1208
- #setUploadStarted() {
1209
- this.#setProgress(1);
1134
+ function $syncHighlightWithStyle(textNode) {
1135
+ if (hasHighlightStyles(textNode.getStyle()) !== textNode.hasFormat("highlight")) {
1136
+ textNode.toggleFormat("highlight");
1210
1137
  }
1138
+ }
1211
1139
 
1212
- #handleUploadProgress(event) {
1213
- this.#setProgress(Math.round(event.loaded / event.total * 100));
1214
- }
1140
+ function $canonicalizePastedStyles(textNode, canonicalizers = []) {
1141
+ if ($hasPastedStyles(textNode)) {
1142
+ $setPastedStyles(textNode, false);
1215
1143
 
1216
- #setProgress(progress) {
1217
- this.editor.update(() => {
1218
- this.getWritable().progress = progress;
1219
- }, { tag: SILENT_UPDATE_TAGS });
1220
- }
1144
+ const canonicalizedCSS = applyCanonicalizers(textNode.getStyle(), canonicalizers);
1145
+ textNode.setStyle(canonicalizedCSS);
1221
1146
 
1222
- #handleUploadError(error) {
1223
- console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
1224
- this.editor.update(() => {
1225
- this.getWritable().uploadError = true;
1226
- }, { tag: SILENT_UPDATE_TAGS });
1147
+ const selection = $getSelection();
1148
+ if (textNode.isSelected(selection)) {
1149
+ selection.setStyle(textNode.getStyle());
1150
+ selection.setFormat(textNode.getFormat());
1151
+ }
1227
1152
  }
1153
+ }
1228
1154
 
1229
- async #showUploadedAttachment(blob) {
1230
- this.editor.update(() => {
1231
- this.replace(this.#toActionTextAttachmentNodeWith(blob));
1232
- }, { tag: SILENT_UPDATE_TAGS });
1233
- }
1155
+ function $setPastedStyles(textNode, value = true) {
1156
+ $setState(textNode, hasPastedStylesState, value);
1157
+ }
1234
1158
 
1235
- #toActionTextAttachmentNodeWith(blob) {
1236
- const conversion = new AttachmentNodeConversion(this, blob);
1237
- return conversion.toAttachmentNode()
1238
- }
1159
+ function $hasPastedStyles(textNode) {
1160
+ return $getState(textNode, hasPastedStylesState)
1239
1161
  }
1240
1162
 
1241
- class AttachmentNodeConversion {
1242
- constructor(uploadNode, blob) {
1243
- this.uploadNode = uploadNode;
1244
- this.blob = blob;
1163
+ const COMMANDS = [
1164
+ "bold",
1165
+ "italic",
1166
+ "strikethrough",
1167
+ "link",
1168
+ "unlink",
1169
+ "toggleHighlight",
1170
+ "removeHighlight",
1171
+ "rotateHeadingFormat",
1172
+ "insertUnorderedList",
1173
+ "insertOrderedList",
1174
+ "insertQuoteBlock",
1175
+ "insertCodeBlock",
1176
+ "insertHorizontalDivider",
1177
+ "uploadAttachments",
1178
+
1179
+ "insertTable",
1180
+
1181
+ "undo",
1182
+ "redo"
1183
+ ];
1184
+
1185
+ class CommandDispatcher {
1186
+ static configureFor(editorElement) {
1187
+ new CommandDispatcher(editorElement);
1245
1188
  }
1246
1189
 
1247
- toAttachmentNode() {
1248
- return new ActionTextAttachmentNode({
1249
- ...this.uploadNode,
1250
- ...this.#propertiesFromBlob,
1251
- src: this.#src
1252
- })
1190
+ constructor(editorElement) {
1191
+ this.editorElement = editorElement;
1192
+ this.editor = editorElement.editor;
1193
+ this.selection = editorElement.selection;
1194
+ this.contents = editorElement.contents;
1195
+ this.clipboard = editorElement.clipboard;
1196
+
1197
+ this.#registerCommands();
1198
+ this.#registerKeyboardCommands();
1199
+ this.#registerDragAndDropHandlers();
1253
1200
  }
1254
1201
 
1255
- get #propertiesFromBlob() {
1256
- const { blob } = this;
1257
- return {
1258
- sgid: blob.attachable_sgid,
1259
- altText: blob.filename,
1260
- contentType: blob.content_type,
1261
- fileName: blob.filename,
1262
- fileSize: blob.byte_size,
1263
- previewable: blob.previewable,
1264
- }
1202
+ dispatchPaste(event) {
1203
+ return this.clipboard.paste(event)
1265
1204
  }
1266
1205
 
1267
- get #src() {
1268
- return this.blob.previewable ? this.blob.url : this.#blobSrc
1206
+ dispatchBold() {
1207
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
1269
1208
  }
1270
1209
 
1271
- get #blobSrc() {
1272
- return this.uploadNode.blobUrlTemplate
1273
- .replace(":signed_id", this.blob.signed_id)
1274
- .replace(":filename", encodeURIComponent(this.blob.filename))
1210
+ dispatchItalic() {
1211
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
1275
1212
  }
1276
- }
1277
1213
 
1278
- class HorizontalDividerNode extends DecoratorNode {
1279
- static getType() {
1280
- return "horizontal_divider"
1214
+ dispatchStrikethrough() {
1215
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
1281
1216
  }
1282
1217
 
1283
- static clone(node) {
1284
- return new HorizontalDividerNode(node.__key)
1218
+ dispatchToggleHighlight(styles) {
1219
+ this.editor.dispatchCommand(TOGGLE_HIGHLIGHT_COMMAND, styles);
1285
1220
  }
1286
1221
 
1287
- static importJSON(serializedNode) {
1288
- return new HorizontalDividerNode()
1222
+ dispatchRemoveHighlight() {
1223
+ this.editor.dispatchCommand(REMOVE_HIGHLIGHT_COMMAND);
1289
1224
  }
1290
1225
 
1291
- static importDOM() {
1292
- return {
1293
- "hr": (hr) => {
1294
- return {
1295
- conversion: () => ({
1296
- node: new HorizontalDividerNode()
1297
- }),
1298
- priority: 1
1299
- }
1226
+ dispatchLink(url) {
1227
+ this.editor.update(() => {
1228
+ const selection = $getSelection();
1229
+ if (!$isRangeSelection(selection)) return
1230
+
1231
+ if (selection.isCollapsed()) {
1232
+ const autoLinkNode = $createAutoLinkNode(url);
1233
+ const textNode = $createTextNode(url);
1234
+ autoLinkNode.append(textNode);
1235
+ selection.insertNodes([ autoLinkNode ]);
1236
+ } else {
1237
+ $toggleLink(url);
1300
1238
  }
1301
- }
1239
+ });
1302
1240
  }
1303
1241
 
1304
- constructor(key) {
1305
- super(key);
1242
+ dispatchUnlink() {
1243
+ this.#toggleLink(null);
1306
1244
  }
1307
1245
 
1308
- createDOM() {
1309
- const figure = createElement("figure", { className: "horizontal-divider" });
1310
- const hr = createElement("hr");
1246
+ dispatchInsertUnorderedList() {
1247
+ const selection = $getSelection();
1248
+ if (!selection) return
1311
1249
 
1312
- figure.appendChild(hr);
1250
+ const anchorNode = selection.anchor.getNode();
1313
1251
 
1314
- return figure
1252
+ if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
1253
+ this.contents.unwrapSelectedListItems();
1254
+ } else {
1255
+ this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
1256
+ }
1315
1257
  }
1316
1258
 
1317
- updateDOM() {
1318
- return true
1259
+ dispatchInsertOrderedList() {
1260
+ const selection = $getSelection();
1261
+ if (!selection) return
1262
+
1263
+ const anchorNode = selection.anchor.getNode();
1264
+
1265
+ if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
1266
+ this.contents.unwrapSelectedListItems();
1267
+ } else {
1268
+ this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
1269
+ }
1319
1270
  }
1320
1271
 
1321
- getTextContent() {
1322
- return "┄\n\n"
1272
+ dispatchInsertQuoteBlock() {
1273
+ this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode());
1323
1274
  }
1324
1275
 
1325
- isInline() {
1326
- return false
1276
+ dispatchInsertCodeBlock() {
1277
+ this.editor.update(() => {
1278
+ if (this.selection.hasSelectedWordsInSingleLine) {
1279
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
1280
+ } else {
1281
+ this.contents.toggleNodeWrappingAllSelectedLines((node) => $isCodeNode(node), () => new CodeNode("plain"));
1282
+ }
1283
+ });
1327
1284
  }
1328
1285
 
1329
- exportDOM() {
1330
- const hr = createElement("hr");
1331
- return { element: hr }
1286
+ dispatchInsertHorizontalDivider() {
1287
+ this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
1288
+ this.editor.focus();
1332
1289
  }
1333
1290
 
1334
- exportJSON() {
1335
- return {
1336
- type: "horizontal_divider",
1337
- version: 1
1291
+ dispatchRotateHeadingFormat() {
1292
+ const selection = $getSelection();
1293
+ if (!$isRangeSelection(selection)) return
1294
+
1295
+ if ($isRootOrShadowRoot(selection.anchor.getNode())) {
1296
+ selection.insertNodes([ $createHeadingNode("h2") ]);
1297
+ return
1298
+ }
1299
+
1300
+ const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
1301
+ let nextTag = "h2";
1302
+ if ($isHeadingNode(topLevelElement)) {
1303
+ const currentTag = topLevelElement.getTag();
1304
+ if (currentTag === "h2") {
1305
+ nextTag = "h3";
1306
+ } else if (currentTag === "h3") {
1307
+ nextTag = "h4";
1308
+ } else if (currentTag === "h4") {
1309
+ nextTag = null;
1310
+ } else {
1311
+ nextTag = "h2";
1312
+ }
1313
+ }
1314
+
1315
+ if (nextTag) {
1316
+ this.contents.insertNodeWrappingEachSelectedLine(() => $createHeadingNode(nextTag));
1317
+ } else {
1318
+ this.contents.removeFormattingFromSelectedLines();
1338
1319
  }
1339
1320
  }
1340
1321
 
1341
- decorate() {
1342
- return null
1322
+ dispatchUploadAttachments() {
1323
+ const input = createElement("input", {
1324
+ type: "file",
1325
+ multiple: true,
1326
+ style: "display: none;",
1327
+ onchange: ({ target: { files } }) => {
1328
+ this.contents.uploadFiles(files, { selectLast: true });
1329
+ }
1330
+ });
1331
+
1332
+ // Append and remove to make testable
1333
+ this.editorElement.appendChild(input);
1334
+ input.click();
1335
+ setTimeout(() => input.remove(), 1000);
1343
1336
  }
1344
- }
1345
1337
 
1346
- function isSelectionHighlighted(selection) {
1347
- if (!$isRangeSelection(selection)) return false
1338
+ dispatchInsertTable() {
1339
+ this.editor.dispatchCommand(INSERT_TABLE_COMMAND, { "rows": 3, "columns": 3, "includeHeaders": true });
1340
+ }
1348
1341
 
1349
- if (selection.isCollapsed()) {
1350
- return hasHighlightStyles(selection.style)
1351
- } else {
1352
- return selection.hasFormat("highlight")
1342
+ dispatchUndo() {
1343
+ this.editor.dispatchCommand(UNDO_COMMAND, undefined);
1353
1344
  }
1354
- }
1355
1345
 
1356
- function hasHighlightStyles(cssOrStyles) {
1357
- const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
1358
- return !!(styles.color || styles["background-color"])
1359
- }
1360
-
1361
- function applyCanonicalizers(styles, canonicalizers = []) {
1362
- return canonicalizers.reduce((css, canonicalizer) => {
1363
- return canonicalizer.applyCanonicalization(css)
1364
- }, styles)
1365
- }
1366
-
1367
- class StyleCanonicalizer {
1368
- constructor(property, allowedValues= []) {
1369
- this._property = property;
1370
- this._allowedValues = allowedValues;
1371
- this._canonicalValues = this.#allowedValuesIdentityObject;
1346
+ dispatchRedo() {
1347
+ this.editor.dispatchCommand(REDO_COMMAND, undefined);
1372
1348
  }
1373
1349
 
1374
- applyCanonicalization(css) {
1375
- const styles = { ...getStyleObjectFromCSS(css) };
1376
-
1377
- styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
1378
- if (!styles[this._property]) {
1379
- delete styles[this._property];
1350
+ #registerCommands() {
1351
+ for (const command of COMMANDS) {
1352
+ const methodName = `dispatch${capitalize(command)}`;
1353
+ this.#registerCommandHandler(command, 0, this[methodName].bind(this));
1380
1354
  }
1381
1355
 
1382
- return getCSSFromStyleObject(styles)
1356
+ this.#registerCommandHandler(PASTE_COMMAND, COMMAND_PRIORITY_LOW, this.dispatchPaste.bind(this));
1383
1357
  }
1384
1358
 
1385
- getCanonicalAllowedValue(value) {
1386
- return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
1359
+ #registerCommandHandler(command, priority, handler) {
1360
+ this.editor.registerCommand(command, handler, priority);
1387
1361
  }
1388
1362
 
1389
- // Private
1390
-
1391
- get #allowedValuesIdentityObject() {
1392
- return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
1363
+ #registerKeyboardCommands() {
1364
+ this.editor.registerCommand(KEY_TAB_COMMAND, this.#handleTabKey.bind(this), COMMAND_PRIORITY_NORMAL);
1393
1365
  }
1394
1366
 
1395
- #resolveCannonicalValue(value) {
1396
- let index = this.#computedAllowedValues.indexOf(value);
1397
- index ||= this.#computedAllowedValues.indexOf(getComputedStyleForProperty(this._property, value));
1398
- return index === -1 ? null : this._allowedValues[index]
1367
+ #registerDragAndDropHandlers() {
1368
+ if (this.editorElement.supportsAttachments) {
1369
+ this.dragCounter = 0;
1370
+ this.editor.getRootElement().addEventListener("dragover", this.#handleDragOver.bind(this));
1371
+ this.editor.getRootElement().addEventListener("drop", this.#handleDrop.bind(this));
1372
+ this.editor.getRootElement().addEventListener("dragenter", this.#handleDragEnter.bind(this));
1373
+ this.editor.getRootElement().addEventListener("dragleave", this.#handleDragLeave.bind(this));
1374
+ }
1399
1375
  }
1400
1376
 
1401
- get #computedAllowedValues() {
1402
- return this._computedAllowedValues ||= this._allowedValues.map(
1403
- value => getComputedStyleForProperty(this._property, value)
1404
- )
1377
+ #handleDragEnter(event) {
1378
+ this.dragCounter++;
1379
+ if (this.dragCounter === 1) {
1380
+ this.editor.getRootElement().classList.add("lexxy-editor--drag-over");
1381
+ }
1405
1382
  }
1406
- }
1407
1383
 
1408
- function getComputedStyleForProperty(property, value) {
1409
- const style = `${property}: ${value};`;
1384
+ #handleDragLeave(event) {
1385
+ this.dragCounter--;
1386
+ if (this.dragCounter === 0) {
1387
+ this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
1388
+ }
1389
+ }
1410
1390
 
1411
- // the element has to be attached to the DOM have computed styles
1412
- const element = document.body.appendChild(createElement("span", { style: "display: none;" + style }));
1413
- const computedStyle = window.getComputedStyle(element).getPropertyValue(property);
1414
- element.remove();
1391
+ #handleDragOver(event) {
1392
+ event.preventDefault();
1393
+ }
1415
1394
 
1416
- return computedStyle
1417
- }
1395
+ #handleDrop(event) {
1396
+ event.preventDefault();
1418
1397
 
1419
- class LexxyExtension {
1420
- #editorElement
1398
+ this.dragCounter = 0;
1399
+ this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
1421
1400
 
1422
- constructor(editorElement) {
1423
- this.#editorElement = editorElement;
1424
- }
1401
+ const dataTransfer = event.dataTransfer;
1402
+ if (!dataTransfer) return
1425
1403
 
1426
- get editorElement() {
1427
- return this.#editorElement
1428
- }
1404
+ const files = Array.from(dataTransfer.files);
1405
+ if (!files.length) return
1429
1406
 
1430
- get editorConfig() {
1431
- return this.#editorElement.config
1432
- }
1407
+ this.contents.uploadFiles(files, { selectLast: true });
1433
1408
 
1434
- // optional: defaults to true
1435
- get enabled() {
1436
- return true
1409
+ this.editor.focus();
1437
1410
  }
1438
1411
 
1439
- get lexicalExtension() {
1440
- return null
1412
+ #handleTabKey(event) {
1413
+ if (this.selection.isInsideList) {
1414
+ return this.#handleTabForList(event)
1415
+ } else if (this.selection.isInsideCodeBlock) {
1416
+ return this.#handleTabForCode()
1417
+ }
1418
+ return false
1441
1419
  }
1442
1420
 
1443
- initializeToolbar(_lexxyToolbar) {
1421
+ #handleTabForList(event) {
1422
+ if (event.shiftKey && !this.selection.isIndentedList) return false
1444
1423
 
1424
+ event.preventDefault();
1425
+ const command = event.shiftKey? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND;
1426
+ return this.editor.dispatchCommand(command)
1445
1427
  }
1446
- }
1447
-
1448
- const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
1449
- const REMOVE_HIGHLIGHT_COMMAND = createCommand();
1450
- const BLANK_STYLES = { "color": null, "background-color": null };
1451
-
1452
- const hasPastedStylesState = createState("hasPastedStyles", {
1453
- parse: (value) => value || false
1454
- });
1455
1428
 
1456
- class HighlightExtension extends LexxyExtension {
1457
- get enabled() {
1458
- return this.editorElement.supportsRichText
1429
+ #handleTabForCode() {
1430
+ const selection = $getSelection();
1431
+ return $isRangeSelection(selection) && selection.isCollapsed()
1459
1432
  }
1460
1433
 
1461
- get lexicalExtension() {
1462
- const extension = defineExtension({
1463
- dependencies: [ RichTextExtension ],
1464
- name: "lexxy/highlight",
1465
- config: {
1466
- color: { buttons: [], permit: [] },
1467
- "background-color": { buttons: [], permit: [] }
1468
- },
1469
- html: {
1470
- import: {
1471
- mark: $markConversion
1472
- }
1473
- },
1474
- register(editor, config) {
1475
- // keep the ref to the canonicalizers for optimized css conversion
1476
- const canonicalizers = buildCanonicalizers(config);
1477
-
1478
- return mergeRegister(
1479
- editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, $toggleSelectionStyles, COMMAND_PRIORITY_NORMAL),
1480
- editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
1481
- editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
1482
- editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers))
1483
- )
1434
+ // Not using TOGGLE_LINK_COMMAND because it's not handled unless you use React/LinkPlugin
1435
+ #toggleLink(url) {
1436
+ this.editor.update(() => {
1437
+ if (url === null) {
1438
+ $toggleLink(null);
1439
+ } else {
1440
+ $toggleLink(url);
1484
1441
  }
1485
1442
  });
1486
-
1487
- return [ extension, this.editorConfig.get("highlight") ]
1488
1443
  }
1489
1444
  }
1490
1445
 
1491
- function $applyHighlightStyle(textNode, element) {
1492
- const elementStyles = {
1493
- color: element.style?.color,
1494
- "background-color": element.style?.backgroundColor
1495
- };
1446
+ function capitalize(str) {
1447
+ return str.charAt(0).toUpperCase() + str.slice(1)
1448
+ }
1496
1449
 
1497
- if ($hasUpdateTag(PASTE_TAG)) { $setPastedStyles(textNode); }
1498
- const highlightStyle = getCSSFromStyleObject(elementStyles);
1450
+ function debounceAsync(fn, wait) {
1451
+ let timeout;
1499
1452
 
1500
- if (highlightStyle.length) {
1501
- return textNode.setStyle(textNode.getStyle() + highlightStyle)
1502
- }
1503
- }
1453
+ return (...args) => {
1454
+ clearTimeout(timeout);
1504
1455
 
1505
- function $markConversion() {
1506
- return {
1507
- conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
1508
- priority: 1
1456
+ return new Promise((resolve, reject) => {
1457
+ timeout = setTimeout(async () => {
1458
+ try {
1459
+ const result = await fn(...args);
1460
+ resolve(result);
1461
+ } catch (err) {
1462
+ reject(err);
1463
+ }
1464
+ }, wait);
1465
+ })
1509
1466
  }
1510
1467
  }
1511
1468
 
1512
- function buildCanonicalizers(config) {
1513
- return [
1514
- new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
1515
- new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
1516
- ]
1469
+ function nextFrame() {
1470
+ return new Promise(requestAnimationFrame)
1517
1471
  }
1518
1472
 
1519
- function $toggleSelectionStyles(styles) {
1520
- const selection = $getSelection();
1521
- if (!$isRangeSelection(selection)) return
1522
-
1523
- const patch = {};
1524
- for (const property in styles) {
1525
- const oldValue = $getSelectionStyleValueForProperty(selection, property);
1526
- patch[property] = toggleOrReplace(oldValue, styles[property]);
1527
- }
1528
-
1529
- $patchStyleText(selection, patch);
1473
+ function bytesToHumanSize(bytes) {
1474
+ if (bytes === 0) return "0 B"
1475
+ const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
1476
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
1477
+ const value = bytes / Math.pow(1024, i);
1478
+ return `${ value.toFixed(2) } ${ sizes[i] }`
1530
1479
  }
1531
1480
 
1532
- function toggleOrReplace(oldValue, newValue) {
1533
- return oldValue === newValue ? null : newValue
1481
+ function extractFileName(string) {
1482
+ return string.split("/").pop()
1534
1483
  }
1535
1484
 
1536
- function $syncHighlightWithStyle(textNode) {
1537
- if (hasHighlightStyles(textNode.getStyle()) !== textNode.hasFormat("highlight")) {
1538
- textNode.toggleFormat("highlight");
1485
+ class ActionTextAttachmentNode extends DecoratorNode {
1486
+ static getType() {
1487
+ return "action_text_attachment"
1539
1488
  }
1540
- }
1541
-
1542
- function $canonicalizePastedStyles(textNode, canonicalizers = []) {
1543
- if ($hasPastedStyles(textNode)) {
1544
- $setPastedStyles(textNode, false);
1545
-
1546
- const canonicalizedCSS = applyCanonicalizers(textNode.getStyle(), canonicalizers);
1547
- textNode.setStyle(canonicalizedCSS);
1548
1489
 
1549
- const selection = $getSelection();
1550
- if (textNode.isSelected(selection)) {
1551
- selection.setStyle(textNode.getStyle());
1552
- selection.setFormat(textNode.getFormat());
1553
- }
1490
+ static clone(node) {
1491
+ return new ActionTextAttachmentNode({ ...node }, node.__key)
1554
1492
  }
1555
- }
1556
-
1557
- function $setPastedStyles(textNode, value = true) {
1558
- $setState(textNode, hasPastedStylesState, value);
1559
- }
1560
1493
 
1561
- function $hasPastedStyles(textNode) {
1562
- return $getState(textNode, hasPastedStylesState)
1563
- }
1494
+ static importJSON(serializedNode) {
1495
+ return new ActionTextAttachmentNode({ ...serializedNode })
1496
+ }
1564
1497
 
1565
- const COMMANDS = [
1566
- "bold",
1567
- "italic",
1568
- "strikethrough",
1569
- "link",
1570
- "unlink",
1571
- "toggleHighlight",
1572
- "removeHighlight",
1573
- "rotateHeadingFormat",
1574
- "insertUnorderedList",
1575
- "insertOrderedList",
1576
- "insertQuoteBlock",
1577
- "insertCodeBlock",
1578
- "insertHorizontalDivider",
1579
- "uploadAttachments",
1498
+ static importDOM() {
1499
+ return {
1500
+ [this.TAG_NAME]: () => {
1501
+ return {
1502
+ conversion: (attachment) => ({
1503
+ node: new ActionTextAttachmentNode({
1504
+ sgid: attachment.getAttribute("sgid"),
1505
+ src: attachment.getAttribute("url"),
1506
+ previewable: attachment.getAttribute("previewable"),
1507
+ altText: attachment.getAttribute("alt"),
1508
+ caption: attachment.getAttribute("caption"),
1509
+ contentType: attachment.getAttribute("content-type"),
1510
+ fileName: attachment.getAttribute("filename"),
1511
+ fileSize: attachment.getAttribute("filesize"),
1512
+ width: attachment.getAttribute("width"),
1513
+ height: attachment.getAttribute("height")
1514
+ })
1515
+ }), priority: 1
1516
+ }
1517
+ },
1518
+ "img": () => {
1519
+ return {
1520
+ conversion: (img) => {
1521
+ const fileName = extractFileName(img.getAttribute("src") ?? "");
1522
+ return {
1523
+ node: new ActionTextAttachmentNode({
1524
+ src: img.getAttribute("src"),
1525
+ fileName: fileName,
1526
+ caption: img.getAttribute("alt") || "",
1527
+ contentType: "image/*",
1528
+ width: img.getAttribute("width"),
1529
+ height: img.getAttribute("height")
1530
+ })
1531
+ }
1532
+ }, priority: 1
1533
+ }
1534
+ },
1535
+ "video": () => {
1536
+ return {
1537
+ conversion: (video) => {
1538
+ const videoSource = video.getAttribute("src") || video.querySelector("source")?.src;
1539
+ const fileName = videoSource?.split("/")?.pop();
1540
+ const contentType = video.querySelector("source")?.getAttribute("content-type") || "video/*";
1541
+
1542
+ return {
1543
+ node: new ActionTextAttachmentNode({
1544
+ src: videoSource,
1545
+ fileName: fileName,
1546
+ contentType: contentType
1547
+ })
1548
+ }
1549
+ }, priority: 1
1550
+ }
1551
+ }
1552
+ }
1553
+ }
1554
+
1555
+ static get TAG_NAME() {
1556
+ return Lexxy.global.get("attachmentTagName")
1557
+ }
1558
+
1559
+ constructor({ tagName, sgid, src, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) {
1560
+ super(key);
1561
+
1562
+ this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
1563
+ this.sgid = sgid;
1564
+ this.src = src;
1565
+ this.previewable = previewable;
1566
+ this.altText = altText || "";
1567
+ this.caption = caption || "";
1568
+ this.contentType = contentType || "";
1569
+ this.fileName = fileName || "";
1570
+ this.fileSize = fileSize;
1571
+ this.width = width;
1572
+ this.height = height;
1573
+
1574
+ this.editor = $getEditor();
1575
+ }
1576
+
1577
+ createDOM() {
1578
+ const figure = this.createAttachmentFigure();
1579
+
1580
+ if (this.isPreviewableAttachment) {
1581
+ figure.appendChild(this.#createDOMForImage());
1582
+ figure.appendChild(this.#createEditableCaption());
1583
+ } else {
1584
+ figure.appendChild(this.#createDOMForFile());
1585
+ figure.appendChild(this.#createDOMForNotImage());
1586
+ }
1587
+
1588
+ return figure
1589
+ }
1590
+
1591
+ updateDOM(_prevNode, dom) {
1592
+ const caption = dom.querySelector("figcaption textarea");
1593
+ if (caption && this.caption) {
1594
+ caption.value = this.caption;
1595
+ }
1596
+
1597
+ return false
1598
+ }
1599
+
1600
+ getTextContent() {
1601
+ return `[${this.caption || this.fileName}]\n\n`
1602
+ }
1603
+
1604
+ isInline() {
1605
+ return this.isAttached() && !this.getParent().is($getNearestRootOrShadowRoot(this))
1606
+ }
1607
+
1608
+ exportDOM() {
1609
+ const attachment = createElement(this.tagName, {
1610
+ sgid: this.sgid,
1611
+ previewable: this.previewable || null,
1612
+ url: this.src,
1613
+ alt: this.altText,
1614
+ caption: this.caption,
1615
+ "content-type": this.contentType,
1616
+ filename: this.fileName,
1617
+ filesize: this.fileSize,
1618
+ width: this.width,
1619
+ height: this.height,
1620
+ presentation: "gallery"
1621
+ });
1622
+
1623
+ return { element: attachment }
1624
+ }
1625
+
1626
+ exportJSON() {
1627
+ return {
1628
+ type: "action_text_attachment",
1629
+ version: 1,
1630
+ tagName: this.tagName,
1631
+ sgid: this.sgid,
1632
+ src: this.src,
1633
+ previewable: this.previewable,
1634
+ altText: this.altText,
1635
+ caption: this.caption,
1636
+ contentType: this.contentType,
1637
+ fileName: this.fileName,
1638
+ fileSize: this.fileSize,
1639
+ width: this.width,
1640
+ height: this.height
1641
+ }
1642
+ }
1643
+
1644
+ decorate() {
1645
+ return null
1646
+ }
1647
+
1648
+ createAttachmentFigure() {
1649
+ const figure = createAttachmentFigure(this.contentType, this.isPreviewableAttachment, this.fileName);
1650
+
1651
+ const deleteButton = createElement("lexxy-node-delete-button");
1652
+ figure.appendChild(deleteButton);
1653
+
1654
+ return figure
1655
+ }
1656
+
1657
+ get isPreviewableAttachment() {
1658
+ return this.isPreviewableImage || this.previewable
1659
+ }
1660
+
1661
+ get isPreviewableImage() {
1662
+ return isPreviewableImage(this.contentType)
1663
+ }
1664
+
1665
+ #createDOMForImage(options = {}) {
1666
+ const img = createElement("img", { src: this.src, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
1667
+ const container = createElement("div", { className: "attachment__container" });
1668
+ container.appendChild(img);
1669
+ return container
1670
+ }
1671
+
1672
+ get #imageDimensions() {
1673
+ if (this.width && this.height) {
1674
+ return { width: this.width, height: this.height }
1675
+ } else {
1676
+ return {}
1677
+ }
1678
+ }
1679
+
1680
+ #createDOMForFile() {
1681
+ const extension = this.fileName ? this.fileName.split(".").pop().toLowerCase() : "unknown";
1682
+ return createElement("span", { className: "attachment__icon", textContent: `${extension}` })
1683
+ }
1684
+
1685
+ #createDOMForNotImage() {
1686
+ const figcaption = createElement("figcaption", { className: "attachment__caption" });
1687
+
1688
+ const nameTag = createElement("strong", { className: "attachment__name", textContent: this.caption || this.fileName });
1689
+
1690
+ figcaption.appendChild(nameTag);
1691
+
1692
+ if (this.fileSize) {
1693
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.fileSize) });
1694
+ figcaption.appendChild(sizeSpan);
1695
+ }
1696
+
1697
+ return figcaption
1698
+ }
1699
+
1700
+ #createEditableCaption() {
1701
+ const caption = createElement("figcaption", { className: "attachment__caption" });
1702
+ const input = createElement("textarea", {
1703
+ value: this.caption,
1704
+ placeholder: this.fileName,
1705
+ rows: "1"
1706
+ });
1707
+
1708
+ input.addEventListener("focusin", () => input.placeholder = "Add caption...");
1709
+ input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
1710
+ input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
1711
+
1712
+ caption.appendChild(input);
1713
+
1714
+ return caption
1715
+ }
1716
+
1717
+ #handleCaptionInputBlurred(event) {
1718
+ this.#updateCaptionValueFromInput(event.target);
1719
+ }
1720
+
1721
+ #updateCaptionValueFromInput(input) {
1722
+ input.placeholder = this.fileName;
1723
+ this.editor.update(() => {
1724
+ this.getWritable().caption = input.value;
1725
+ });
1726
+ }
1727
+
1728
+ #handleCaptionInputKeydown(event) {
1729
+ if (event.key === "Enter") {
1730
+ event.preventDefault();
1731
+ event.stopPropagation();
1732
+ event.target.blur();
1733
+
1734
+ this.editor.update(() => {
1735
+ // Place the cursor after the current image
1736
+ this.selectNext(0, 0);
1737
+ }, {
1738
+ tag: HISTORY_MERGE_TAG
1739
+ });
1740
+ }
1741
+
1742
+ }
1743
+ }
1744
+
1745
+ function $createActionTextAttachmentNode(...args) {
1746
+ return new ActionTextAttachmentNode(...args)
1747
+ }
1748
+
1749
+ function $isActionTextAttachmentNode(node) {
1750
+ return node instanceof ActionTextAttachmentNode
1751
+ }
1752
+
1753
+ class Selection {
1754
+ constructor(editorElement) {
1755
+ this.editorElement = editorElement;
1756
+ this.editorContentElement = editorElement.editorContentElement;
1757
+ this.editor = this.editorElement.editor;
1758
+ this.previouslySelectedKeys = new Set();
1759
+
1760
+ this.#listenForNodeSelections();
1761
+ this.#processSelectionChangeCommands();
1762
+ this.#containEditorFocus();
1763
+ }
1764
+
1765
+ set current(selection) {
1766
+ this.editor.update(() => {
1767
+ this.#syncSelectedClasses();
1768
+ });
1769
+ }
1770
+
1771
+ get hasNodeSelection() {
1772
+ return this.editor.getEditorState().read(() => {
1773
+ const selection = $getSelection();
1774
+ return selection !== null && $isNodeSelection(selection)
1775
+ })
1776
+ }
1777
+
1778
+ get cursorPosition() {
1779
+ let position = { x: 0, y: 0 };
1780
+
1781
+ this.editor.getEditorState().read(() => {
1782
+ const range = this.#getValidSelectionRange();
1783
+ if (!range) return
1784
+
1785
+ const rect = this.#getReliableRectFromRange(range);
1786
+ if (!rect) return
1787
+
1788
+ position = this.#calculateCursorPosition(rect, range);
1789
+ });
1790
+
1791
+ return position
1792
+ }
1793
+
1794
+ placeCursorAtTheEnd() {
1795
+ this.editor.update(() => {
1796
+ const root = $getRoot();
1797
+ const lastDescendant = root.getLastDescendant();
1798
+
1799
+ if (lastDescendant && $isTextNode(lastDescendant)) {
1800
+ lastDescendant.selectEnd();
1801
+ } else {
1802
+ root.selectEnd();
1803
+ }
1804
+ });
1805
+ }
1806
+
1807
+ selectedNodeWithOffset() {
1808
+ const selection = $getSelection();
1809
+ if (!selection) return { node: null, offset: 0 }
1810
+
1811
+ if ($isRangeSelection(selection)) {
1812
+ return {
1813
+ node: selection.anchor.getNode(),
1814
+ offset: selection.anchor.offset
1815
+ }
1816
+ } else if ($isNodeSelection(selection)) {
1817
+ const [ node ] = selection.getNodes();
1818
+ return {
1819
+ node,
1820
+ offset: 0
1821
+ }
1822
+ }
1823
+
1824
+ return { node: null, offset: 0 }
1825
+ }
1826
+
1827
+ preservingSelection(fn) {
1828
+ let selectionState = null;
1829
+
1830
+ this.editor.getEditorState().read(() => {
1831
+ const selection = $getSelection();
1832
+ if (selection && $isRangeSelection(selection)) {
1833
+ selectionState = {
1834
+ anchor: { key: selection.anchor.key, offset: selection.anchor.offset },
1835
+ focus: { key: selection.focus.key, offset: selection.focus.offset }
1836
+ };
1837
+ }
1838
+ });
1839
+
1840
+ fn();
1841
+
1842
+ if (selectionState) {
1843
+ this.editor.update(() => {
1844
+ const selection = $getSelection();
1845
+ if (selection && $isRangeSelection(selection)) {
1846
+ selection.anchor.set(selectionState.anchor.key, selectionState.anchor.offset, "text");
1847
+ selection.focus.set(selectionState.focus.key, selectionState.focus.offset, "text");
1848
+ }
1849
+ });
1850
+ }
1851
+ }
1852
+
1853
+ getFormat() {
1854
+ const selection = $getSelection();
1855
+ if (!$isRangeSelection(selection)) return {}
1856
+
1857
+ const anchorNode = selection.anchor.getNode();
1858
+ if (!anchorNode.getParent()) return {}
1859
+
1860
+ const topLevelElement = anchorNode.getTopLevelElementOrThrow();
1861
+ const listType = getListType(anchorNode);
1580
1862
 
1581
- "insertTable",
1863
+ return {
1864
+ isBold: selection.hasFormat("bold"),
1865
+ isItalic: selection.hasFormat("italic"),
1866
+ isStrikethrough: selection.hasFormat("strikethrough"),
1867
+ isHighlight: isSelectionHighlighted(selection),
1868
+ isInLink: $getNearestNodeOfType(anchorNode, LinkNode) !== null,
1869
+ isInQuote: $isQuoteNode(topLevelElement),
1870
+ isInHeading: $isHeadingNode(topLevelElement),
1871
+ isInCode: selection.hasFormat("code") || $getNearestNodeOfType(anchorNode, CodeNode) !== null,
1872
+ isInList: listType !== null,
1873
+ listType,
1874
+ isInTable: $getTableCellNodeFromLexicalNode(anchorNode) !== null
1875
+ }
1876
+ }
1582
1877
 
1583
- "undo",
1584
- "redo"
1585
- ];
1878
+ nearestNodeOfType(nodeType) {
1879
+ const anchorNode = $getSelection()?.anchor?.getNode();
1880
+ return $getNearestNodeOfType(anchorNode, nodeType)
1881
+ }
1586
1882
 
1587
- class CommandDispatcher {
1588
- static configureFor(editorElement) {
1589
- new CommandDispatcher(editorElement);
1883
+ get hasSelectedWordsInSingleLine() {
1884
+ const selection = $getSelection();
1885
+ if (!$isRangeSelection(selection)) return false
1886
+
1887
+ if (selection.isCollapsed()) return false
1888
+
1889
+ const anchorNode = selection.anchor.getNode();
1890
+ const focusNode = selection.focus.getNode();
1891
+
1892
+ if (anchorNode.getTopLevelElement() !== focusNode.getTopLevelElement()) {
1893
+ return false
1894
+ }
1895
+
1896
+ const anchorElement = anchorNode.getTopLevelElement();
1897
+ if (!anchorElement) return false
1898
+
1899
+ const nodes = selection.getNodes();
1900
+ for (const node of nodes) {
1901
+ if ($isLineBreakNode(node)) {
1902
+ return false
1903
+ }
1904
+ }
1905
+
1906
+ return true
1590
1907
  }
1591
1908
 
1592
- constructor(editorElement) {
1593
- this.editorElement = editorElement;
1594
- this.editor = editorElement.editor;
1595
- this.selection = editorElement.selection;
1596
- this.contents = editorElement.contents;
1597
- this.clipboard = editorElement.clipboard;
1909
+ get isInsideList() {
1910
+ return this.nearestNodeOfType(ListItemNode)
1911
+ }
1598
1912
 
1599
- this.#registerCommands();
1600
- this.#registerKeyboardCommands();
1601
- this.#registerDragAndDropHandlers();
1913
+ get isIndentedList() {
1914
+ const closestListNode = this.nearestNodeOfType(ListNode);
1915
+ return closestListNode && ($getListDepth(closestListNode) > 1)
1602
1916
  }
1603
1917
 
1604
- dispatchPaste(event) {
1605
- return this.clipboard.paste(event)
1918
+ get isInsideCodeBlock() {
1919
+ return this.nearestNodeOfType(CodeNode) !== null
1606
1920
  }
1607
1921
 
1608
- dispatchBold() {
1609
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
1922
+ get isTableCellSelected() {
1923
+ return this.nearestNodeOfType(TableCellNode) !== null
1610
1924
  }
1611
1925
 
1612
- dispatchItalic() {
1613
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
1926
+ get isOnPreviewableImage() {
1927
+ const selection = $getSelection();
1928
+ const firstNode = selection?.getNodes().at(0);
1929
+ return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage
1614
1930
  }
1615
1931
 
1616
- dispatchStrikethrough() {
1617
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
1932
+ get nodeAfterCursor() {
1933
+ const { anchorNode, offset } = this.#getCollapsedSelectionData();
1934
+ if (!anchorNode) return null
1935
+
1936
+ if ($isTextNode(anchorNode)) {
1937
+ return this.#getNodeAfterTextNode(anchorNode, offset)
1938
+ }
1939
+
1940
+ if ($isElementNode(anchorNode)) {
1941
+ return this.#getNodeAfterElementNode(anchorNode, offset)
1942
+ }
1943
+
1944
+ return this.#findNextSiblingUp(anchorNode)
1945
+ }
1946
+
1947
+ get topLevelNodeAfterCursor() {
1948
+ const { anchorNode, offset } = this.#getCollapsedSelectionData();
1949
+ if (!anchorNode) return null
1950
+
1951
+ if ($isTextNode(anchorNode)) {
1952
+ return this.#getNextNodeFromTextEnd(anchorNode)
1953
+ }
1954
+
1955
+ if ($isElementNode(anchorNode)) {
1956
+ return this.#getNodeAfterElementNode(anchorNode, offset)
1957
+ }
1958
+
1959
+ return this.#findNextSiblingUp(anchorNode)
1960
+ }
1961
+
1962
+ get nodeBeforeCursor() {
1963
+ const { anchorNode, offset } = this.#getCollapsedSelectionData();
1964
+ if (!anchorNode) return null
1965
+
1966
+ if ($isTextNode(anchorNode)) {
1967
+ return this.#getNodeBeforeTextNode(anchorNode, offset)
1968
+ }
1969
+
1970
+ if ($isElementNode(anchorNode)) {
1971
+ return this.#getNodeBeforeElementNode(anchorNode, offset)
1972
+ }
1973
+
1974
+ return this.#findPreviousSiblingUp(anchorNode)
1975
+ }
1976
+
1977
+ get topLevelNodeBeforeCursor() {
1978
+ const { anchorNode, offset } = this.#getCollapsedSelectionData();
1979
+ if (!anchorNode) return null
1980
+
1981
+ if ($isTextNode(anchorNode)) {
1982
+ return this.#getPreviousNodeFromTextStart(anchorNode)
1983
+ }
1984
+
1985
+ if ($isElementNode(anchorNode)) {
1986
+ return this.#getNodeBeforeElementNode(anchorNode, offset)
1987
+ }
1988
+
1989
+ return this.#findPreviousSiblingUp(anchorNode)
1990
+ }
1991
+
1992
+ get #currentlySelectedKeys() {
1993
+ if (this.currentlySelectedKeys) { return this.currentlySelectedKeys }
1994
+
1995
+ this.currentlySelectedKeys = new Set();
1996
+
1997
+ const selection = $getSelection();
1998
+ if (selection && $isNodeSelection(selection)) {
1999
+ for (const node of selection.getNodes()) {
2000
+ this.currentlySelectedKeys.add(node.getKey());
2001
+ }
2002
+ }
2003
+
2004
+ return this.currentlySelectedKeys
2005
+ }
2006
+
2007
+ #processSelectionChangeCommands() {
2008
+ this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW);
2009
+ this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW);
2010
+ this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
2011
+ this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
2012
+
2013
+ this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW);
2014
+
2015
+ this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
2016
+ this.current = $getSelection();
2017
+ }, COMMAND_PRIORITY_LOW);
2018
+ }
2019
+
2020
+ #listenForNodeSelections() {
2021
+ this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
2022
+ if (!isDOMNode(target)) return false
2023
+
2024
+ const targetNode = $getNearestNodeFromDOMNode(target);
2025
+ return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
2026
+ }, COMMAND_PRIORITY_LOW);
2027
+
2028
+ this.editor.getRootElement().addEventListener("lexxy:internal:move-to-next-line", (event) => {
2029
+ this.#selectOrAppendNextLine();
2030
+ });
2031
+ }
2032
+
2033
+ #containEditorFocus() {
2034
+ // Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements
2035
+ // above when navigating UP/DOWN when Lexical shows its fake cursor on custom decorator nodes.
2036
+ this.editorContentElement.addEventListener("keydown", (event) => {
2037
+ if (event.key === "ArrowUp") {
2038
+ const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
2039
+
2040
+ if (lexicalCursor) {
2041
+ let currentElement = lexicalCursor.previousElementSibling;
2042
+ while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
2043
+ currentElement = currentElement.previousElementSibling;
2044
+ }
2045
+
2046
+ if (!currentElement) {
2047
+ event.preventDefault();
2048
+ }
2049
+ }
2050
+ }
2051
+
2052
+ if (event.key === "ArrowDown") {
2053
+ const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
2054
+
2055
+ if (lexicalCursor) {
2056
+ let currentElement = lexicalCursor.nextElementSibling;
2057
+ while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
2058
+ currentElement = currentElement.nextElementSibling;
2059
+ }
2060
+
2061
+ if (!currentElement) {
2062
+ event.preventDefault();
2063
+ }
2064
+ }
2065
+ }
2066
+ }, true);
2067
+ }
2068
+
2069
+ #syncSelectedClasses() {
2070
+ this.#clearPreviouslyHighlightedItems();
2071
+ this.#highlightNewItems();
2072
+
2073
+ this.previouslySelectedKeys = this.#currentlySelectedKeys;
2074
+ this.currentlySelectedKeys = null;
2075
+ }
2076
+
2077
+ #clearPreviouslyHighlightedItems() {
2078
+ for (const key of this.previouslySelectedKeys) {
2079
+ if (!this.#currentlySelectedKeys.has(key)) {
2080
+ const dom = this.editor.getElementByKey(key);
2081
+ if (dom) dom.classList.remove("node--selected");
2082
+ }
2083
+ }
2084
+ }
2085
+
2086
+ #highlightNewItems() {
2087
+ for (const key of this.#currentlySelectedKeys) {
2088
+ if (!this.previouslySelectedKeys.has(key)) {
2089
+ const nodeElement = this.editor.getElementByKey(key);
2090
+ if (nodeElement) nodeElement.classList.add("node--selected");
2091
+ }
2092
+ }
2093
+ }
2094
+
2095
+ async #selectPreviousNode() {
2096
+ if (this.hasNodeSelection) {
2097
+ return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
2098
+ } else {
2099
+ return this.#selectInLexical(this.nodeBeforeCursor)
2100
+ }
2101
+ }
2102
+
2103
+ async #selectNextNode() {
2104
+ if (this.hasNodeSelection) {
2105
+ return await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0))
2106
+ } else {
2107
+ return this.#selectInLexical(this.nodeAfterCursor)
2108
+ }
2109
+ }
2110
+
2111
+ async #selectPreviousTopLevelNode() {
2112
+ if (this.hasNodeSelection) {
2113
+ return await this.#withCurrentNode((currentNode) => currentNode.getTopLevelElement().selectPrevious())
2114
+ } else {
2115
+ return this.#selectInLexical(this.topLevelNodeBeforeCursor)
2116
+ }
1618
2117
  }
1619
2118
 
1620
- dispatchToggleHighlight(styles) {
1621
- this.editor.dispatchCommand(TOGGLE_HIGHLIGHT_COMMAND, styles);
2119
+ async #selectNextTopLevelNode() {
2120
+ if (this.hasNodeSelection) {
2121
+ return await this.#withCurrentNode((currentNode) => currentNode.getTopLevelElement().selectNext(0, 0))
2122
+ } else {
2123
+ return this.#selectInLexical(this.topLevelNodeAfterCursor)
2124
+ }
1622
2125
  }
1623
2126
 
1624
- dispatchRemoveHighlight() {
1625
- this.editor.dispatchCommand(REMOVE_HIGHLIGHT_COMMAND);
2127
+ async #withCurrentNode(fn) {
2128
+ await nextFrame();
2129
+ if (this.hasNodeSelection) {
2130
+ this.editor.update(() => {
2131
+ fn($getSelection().getNodes()[0]);
2132
+ this.editor.focus();
2133
+ });
2134
+ }
1626
2135
  }
1627
2136
 
1628
- dispatchLink(url) {
2137
+ async #selectOrAppendNextLine() {
1629
2138
  this.editor.update(() => {
1630
- const selection = $getSelection();
1631
- if (!$isRangeSelection(selection)) return
2139
+ const topLevelElement = this.#getTopLevelElementFromSelection();
2140
+ if (!topLevelElement) return
1632
2141
 
1633
- if (selection.isCollapsed()) {
1634
- const autoLinkNode = $createAutoLinkNode(url);
1635
- const textNode = $createTextNode(url);
1636
- autoLinkNode.append(textNode);
1637
- selection.insertNodes([ autoLinkNode ]);
1638
- } else {
1639
- $toggleLink(url);
1640
- }
2142
+ this.#moveToOrCreateNextLine(topLevelElement);
1641
2143
  });
1642
2144
  }
1643
2145
 
1644
- dispatchUnlink() {
1645
- this.#toggleLink(null);
1646
- }
1647
-
1648
- dispatchInsertUnorderedList() {
2146
+ #getTopLevelElementFromSelection() {
1649
2147
  const selection = $getSelection();
1650
- if (!selection) return
1651
-
1652
- const anchorNode = selection.anchor.getNode();
2148
+ if (!selection) return null
1653
2149
 
1654
- if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
1655
- this.contents.unwrapSelectedListItems();
1656
- } else {
1657
- this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
2150
+ if ($isNodeSelection(selection)) {
2151
+ return this.#getTopLevelFromNodeSelection(selection)
1658
2152
  }
1659
- }
1660
2153
 
1661
- dispatchInsertOrderedList() {
1662
- const selection = $getSelection();
1663
- if (!selection) return
1664
-
1665
- const anchorNode = selection.anchor.getNode();
1666
-
1667
- if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
1668
- this.contents.unwrapSelectedListItems();
1669
- } else {
1670
- this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
2154
+ if ($isRangeSelection(selection)) {
2155
+ return this.#getTopLevelFromRangeSelection(selection)
1671
2156
  }
1672
- }
1673
2157
 
1674
- dispatchInsertQuoteBlock() {
1675
- this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode());
2158
+ return null
1676
2159
  }
1677
2160
 
1678
- dispatchInsertCodeBlock() {
1679
- this.editor.update(() => {
1680
- if (this.selection.hasSelectedWordsInSingleLine) {
1681
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
1682
- } else {
1683
- this.contents.toggleNodeWrappingAllSelectedLines((node) => $isCodeNode(node), () => new CodeNode("plain"));
1684
- }
1685
- });
2161
+ #getTopLevelFromNodeSelection(selection) {
2162
+ const nodes = selection.getNodes();
2163
+ return nodes.length > 0 ? nodes[0].getTopLevelElement() : null
1686
2164
  }
1687
2165
 
1688
- dispatchInsertHorizontalDivider() {
1689
- this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
1690
- this.editor.focus();
2166
+ #getTopLevelFromRangeSelection(selection) {
2167
+ const anchorNode = selection.anchor.getNode();
2168
+ return anchorNode.getTopLevelElement()
1691
2169
  }
1692
2170
 
1693
- dispatchRotateHeadingFormat() {
1694
- const selection = $getSelection();
1695
- if (!$isRangeSelection(selection)) return
2171
+ #moveToOrCreateNextLine(topLevelElement) {
2172
+ const nextSibling = topLevelElement.getNextSibling();
1696
2173
 
1697
- if ($isRootOrShadowRoot(selection.anchor.getNode())) {
1698
- selection.insertNodes([ $createHeadingNode("h2") ]);
1699
- return
2174
+ if (nextSibling) {
2175
+ nextSibling.selectStart();
2176
+ } else {
2177
+ this.#createAndSelectNewParagraph();
1700
2178
  }
2179
+ }
1701
2180
 
1702
- const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
1703
- let nextTag = "h2";
1704
- if ($isHeadingNode(topLevelElement)) {
1705
- const currentTag = topLevelElement.getTag();
1706
- if (currentTag === "h2") {
1707
- nextTag = "h3";
1708
- } else if (currentTag === "h3") {
1709
- nextTag = "h4";
1710
- } else if (currentTag === "h4") {
1711
- nextTag = null;
1712
- } else {
1713
- nextTag = "h2";
1714
- }
1715
- }
2181
+ #createAndSelectNewParagraph() {
2182
+ const root = $getRoot();
2183
+ const newParagraph = $createParagraphNode();
2184
+ root.append(newParagraph);
2185
+ newParagraph.selectStart();
2186
+ }
1716
2187
 
1717
- if (nextTag) {
1718
- this.contents.insertNodeWrappingEachSelectedLine(() => $createHeadingNode(nextTag));
2188
+ #selectInLexical(node) {
2189
+ if ($isDecoratorNode(node)) {
2190
+ const selection = $createNodeSelectionWith(node);
2191
+ $setSelection(selection);
2192
+ return selection
1719
2193
  } else {
1720
- this.contents.removeFormattingFromSelectedLines();
2194
+ return false
1721
2195
  }
1722
2196
  }
1723
2197
 
1724
- dispatchUploadAttachments() {
1725
- const input = createElement("input", {
1726
- type: "file",
1727
- multiple: true,
1728
- style: "display: none;",
1729
- onchange: ({ target }) => {
1730
- const files = Array.from(target.files);
1731
- if (!files.length) return
2198
+ #selectDecoratorNodeBeforeDeletion(backwards) {
2199
+ const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor;
2200
+ if (!$isDecoratorNode(node)) return false
1732
2201
 
1733
- for (const file of files) {
1734
- this.contents.uploadFile(file);
1735
- }
1736
- }
1737
- });
2202
+ this.#removeEmptyElementAnchorNode();
1738
2203
 
1739
- this.editorElement.appendChild(input); // Append and remove just for the sake of making it testable
1740
- input.click();
1741
- setTimeout(() => input.remove(), 1000);
2204
+ const selection = this.#selectInLexical(node);
2205
+ return Boolean(selection)
1742
2206
  }
1743
2207
 
1744
- dispatchInsertTable() {
1745
- this.editor.dispatchCommand(INSERT_TABLE_COMMAND, { "rows": 3, "columns": 3, "includeHeaders": true });
2208
+ #removeEmptyElementAnchorNode(anchor = $getSelection()?.anchor) {
2209
+ const anchorNode = anchor?.getNode();
2210
+ if ($isElementNode(anchorNode) && anchorNode?.isEmpty()) anchorNode.remove();
1746
2211
  }
1747
2212
 
1748
- dispatchUndo() {
1749
- this.editor.dispatchCommand(UNDO_COMMAND, undefined);
1750
- }
2213
+ #getValidSelectionRange() {
2214
+ const lexicalSelection = $getSelection();
2215
+ if (!lexicalSelection || !lexicalSelection.isCollapsed()) return null
1751
2216
 
1752
- dispatchRedo() {
1753
- this.editor.dispatchCommand(REDO_COMMAND, undefined);
2217
+ const nativeSelection = window.getSelection();
2218
+ if (!nativeSelection || nativeSelection.rangeCount === 0) return null
2219
+
2220
+ return nativeSelection.getRangeAt(0)
1754
2221
  }
1755
2222
 
1756
- #registerCommands() {
1757
- for (const command of COMMANDS) {
1758
- const methodName = `dispatch${capitalize(command)}`;
1759
- this.#registerCommandHandler(command, 0, this[methodName].bind(this));
2223
+ #getReliableRectFromRange(range) {
2224
+ let rect = range.getBoundingClientRect();
2225
+
2226
+ if (this.#isRectUnreliable(rect)) {
2227
+ const marker = this.#createAndInsertMarker(range);
2228
+ rect = marker.getBoundingClientRect();
2229
+ this.#restoreSelectionAfterMarker(marker);
2230
+ marker.remove();
1760
2231
  }
1761
2232
 
1762
- this.#registerCommandHandler(PASTE_COMMAND, COMMAND_PRIORITY_LOW, this.dispatchPaste.bind(this));
2233
+ return rect
1763
2234
  }
1764
2235
 
1765
- #registerCommandHandler(command, priority, handler) {
1766
- this.editor.registerCommand(command, handler, priority);
2236
+ #isRectUnreliable(rect) {
2237
+ return rect.width === 0 && rect.height === 0 || rect.top === 0 && rect.left === 0
1767
2238
  }
1768
2239
 
1769
- #registerKeyboardCommands() {
1770
- this.editor.registerCommand(KEY_TAB_COMMAND, this.#handleTabKey.bind(this), COMMAND_PRIORITY_NORMAL);
2240
+ #createAndInsertMarker(range) {
2241
+ const marker = this.#createMarker();
2242
+ range.insertNode(marker);
2243
+ return marker
1771
2244
  }
1772
2245
 
1773
- #registerDragAndDropHandlers() {
1774
- if (this.editorElement.supportsAttachments) {
1775
- this.dragCounter = 0;
1776
- this.editor.getRootElement().addEventListener("dragover", this.#handleDragOver.bind(this));
1777
- this.editor.getRootElement().addEventListener("drop", this.#handleDrop.bind(this));
1778
- this.editor.getRootElement().addEventListener("dragenter", this.#handleDragEnter.bind(this));
1779
- this.editor.getRootElement().addEventListener("dragleave", this.#handleDragLeave.bind(this));
1780
- }
2246
+ #createMarker() {
2247
+ const marker = document.createElement("span");
2248
+ marker.textContent = "\u200b";
2249
+ marker.style.display = "inline-block";
2250
+ marker.style.width = "1px";
2251
+ marker.style.height = "1em";
2252
+ marker.style.lineHeight = "normal";
2253
+ marker.setAttribute("nonce", getNonce());
2254
+ return marker
1781
2255
  }
1782
2256
 
1783
- #handleDragEnter(event) {
1784
- this.dragCounter++;
1785
- if (this.dragCounter === 1) {
1786
- this.editor.getRootElement().classList.add("lexxy-editor--drag-over");
1787
- }
2257
+ #restoreSelectionAfterMarker(marker) {
2258
+ const nativeSelection = window.getSelection();
2259
+ nativeSelection.removeAllRanges();
2260
+ const newRange = document.createRange();
2261
+ newRange.setStartAfter(marker);
2262
+ newRange.collapse(true);
2263
+ nativeSelection.addRange(newRange);
1788
2264
  }
1789
2265
 
1790
- #handleDragLeave(event) {
1791
- this.dragCounter--;
1792
- if (this.dragCounter === 0) {
1793
- this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
2266
+ #calculateCursorPosition(rect, range) {
2267
+ const rootRect = this.editor.getRootElement().getBoundingClientRect();
2268
+ const x = rect.left - rootRect.left;
2269
+ let y = rect.top - rootRect.top;
2270
+
2271
+ const fontSize = this.#getFontSizeForCursor(range);
2272
+ if (!isNaN(fontSize)) {
2273
+ y += fontSize;
1794
2274
  }
2275
+
2276
+ return { x, y, fontSize }
1795
2277
  }
1796
2278
 
1797
- #handleDragOver(event) {
1798
- event.preventDefault();
2279
+ #getFontSizeForCursor(range) {
2280
+ const nativeSelection = window.getSelection();
2281
+ const anchorNode = nativeSelection.anchorNode;
2282
+ const parentElement = this.#getElementFromNode(anchorNode);
2283
+
2284
+ if (parentElement instanceof HTMLElement) {
2285
+ const computed = window.getComputedStyle(parentElement);
2286
+ return parseFloat(computed.fontSize)
2287
+ }
2288
+
2289
+ return 0
1799
2290
  }
1800
2291
 
1801
- #handleDrop(event) {
1802
- event.preventDefault();
2292
+ #getElementFromNode(node) {
2293
+ return node?.nodeType === Node.TEXT_NODE ? node.parentElement : node
2294
+ }
1803
2295
 
1804
- this.dragCounter = 0;
1805
- this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
2296
+ #getCollapsedSelectionData() {
2297
+ const selection = $getSelection();
2298
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
2299
+ return { anchorNode: null, offset: 0 }
2300
+ }
1806
2301
 
1807
- const dataTransfer = event.dataTransfer;
1808
- if (!dataTransfer) return
2302
+ const { anchor } = selection;
2303
+ return { anchorNode: anchor.getNode(), offset: anchor.offset }
2304
+ }
1809
2305
 
1810
- const files = Array.from(dataTransfer.files);
1811
- if (!files.length) return
2306
+ #getNodeAfterTextNode(anchorNode, offset) {
2307
+ if (offset === anchorNode.getTextContentSize()) {
2308
+ return this.#getNextNodeFromTextEnd(anchorNode)
2309
+ }
2310
+ return null
2311
+ }
1812
2312
 
1813
- for (const file of files) {
1814
- this.contents.uploadFile(file);
2313
+ #getNextNodeFromTextEnd(anchorNode) {
2314
+ if (anchorNode.getNextSibling() instanceof DecoratorNode) {
2315
+ return anchorNode.getNextSibling()
1815
2316
  }
2317
+ const parent = anchorNode.getParent();
2318
+ return parent ? parent.getNextSibling() : null
2319
+ }
1816
2320
 
1817
- this.editor.focus();
2321
+ #getNodeAfterElementNode(anchorNode, offset) {
2322
+ if (offset < anchorNode.getChildrenSize()) {
2323
+ return anchorNode.getChildAtIndex(offset)
2324
+ }
2325
+ return this.#findNextSiblingUp(anchorNode)
1818
2326
  }
1819
2327
 
1820
- #handleTabKey(event) {
1821
- if (this.selection.isInsideList) {
1822
- return this.#handleTabForList(event)
1823
- } else if (this.selection.isInsideCodeBlock) {
1824
- return this.#handleTabForCode()
2328
+ #getNodeBeforeTextNode(anchorNode, offset) {
2329
+ if (offset === 0) {
2330
+ return this.#getPreviousNodeFromTextStart(anchorNode)
1825
2331
  }
1826
- return false
2332
+ return null
1827
2333
  }
1828
2334
 
1829
- #handleTabForList(event) {
1830
- if (event.shiftKey && !this.selection.isIndentedList) return false
2335
+ #getPreviousNodeFromTextStart(anchorNode) {
2336
+ if (anchorNode.getPreviousSibling() instanceof DecoratorNode) {
2337
+ return anchorNode.getPreviousSibling()
2338
+ }
2339
+ const parent = anchorNode.getParent();
2340
+ return parent.getPreviousSibling()
2341
+ }
1831
2342
 
1832
- event.preventDefault();
1833
- const command = event.shiftKey? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND;
1834
- return this.editor.dispatchCommand(command)
2343
+ #getNodeBeforeElementNode(anchorNode, offset) {
2344
+ if (offset > 0) {
2345
+ return anchorNode.getChildAtIndex(offset - 1)
2346
+ }
2347
+ return this.#findPreviousSiblingUp(anchorNode)
1835
2348
  }
1836
2349
 
1837
- #handleTabForCode() {
1838
- const selection = $getSelection();
1839
- return $isRangeSelection(selection) && selection.isCollapsed()
2350
+ #findNextSiblingUp(node) {
2351
+ let current = node;
2352
+ while (current && current.getNextSibling() == null) {
2353
+ current = current.getParent();
2354
+ }
2355
+ return current ? current.getNextSibling() : null
1840
2356
  }
1841
2357
 
1842
- // Not using TOGGLE_LINK_COMMAND because it's not handled unless you use React/LinkPlugin
1843
- #toggleLink(url) {
1844
- this.editor.update(() => {
1845
- if (url === null) {
1846
- $toggleLink(null);
1847
- } else {
1848
- $toggleLink(url);
1849
- }
1850
- });
2358
+ #findPreviousSiblingUp(node) {
2359
+ let current = node;
2360
+ while (current && current.getPreviousSibling() == null) {
2361
+ current = current.getParent();
2362
+ }
2363
+ return current ? current.getPreviousSibling() : null
1851
2364
  }
1852
2365
  }
1853
2366
 
1854
- function capitalize(str) {
1855
- return str.charAt(0).toUpperCase() + str.slice(1)
2367
+ function sanitize(html) {
2368
+ return DOMPurify.sanitize(html, buildConfig())
1856
2369
  }
1857
2370
 
1858
- function debounceAsync(fn, wait) {
1859
- let timeout;
1860
-
1861
- return (...args) => {
1862
- clearTimeout(timeout);
2371
+ function dasherize(value) {
2372
+ return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
2373
+ }
1863
2374
 
1864
- return new Promise((resolve, reject) => {
1865
- timeout = setTimeout(async () => {
1866
- try {
1867
- const result = await fn(...args);
1868
- resolve(result);
1869
- } catch (err) {
1870
- reject(err);
1871
- }
1872
- }, wait);
1873
- })
2375
+ function isUrl(string) {
2376
+ try {
2377
+ new URL(string);
2378
+ return true
2379
+ } catch {
2380
+ return false
1874
2381
  }
1875
2382
  }
1876
2383
 
1877
- function nextFrame() {
1878
- return new Promise(requestAnimationFrame)
2384
+ function normalizeFilteredText(string) {
2385
+ return string
2386
+ .toLowerCase()
2387
+ .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
1879
2388
  }
1880
2389
 
1881
- class Selection {
1882
- constructor(editorElement) {
1883
- this.editorElement = editorElement;
1884
- this.editorContentElement = editorElement.editorContentElement;
1885
- this.editor = this.editorElement.editor;
1886
- this.previouslySelectedKeys = new Set();
2390
+ function filterMatches(text, potentialMatch) {
2391
+ return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))
2392
+ }
1887
2393
 
1888
- this.#listenForNodeSelections();
1889
- this.#processSelectionChangeCommands();
1890
- this.#containEditorFocus();
1891
- }
2394
+ function upcaseFirst(string) {
2395
+ return string.charAt(0).toUpperCase() + string.slice(1)
2396
+ }
1892
2397
 
1893
- set current(selection) {
1894
- this.editor.update(() => {
1895
- this.#syncSelectedClasses();
1896
- });
1897
- }
2398
+ class EditorConfiguration {
2399
+ #editorElement
2400
+ #config
1898
2401
 
1899
- get hasNodeSelection() {
1900
- return this.editor.getEditorState().read(() => {
1901
- const selection = $getSelection();
1902
- return selection !== null && $isNodeSelection(selection)
1903
- })
2402
+ constructor(editorElement) {
2403
+ this.#editorElement = editorElement;
2404
+ this.#config = new Configuration(
2405
+ Lexxy.presets.get("default"),
2406
+ Lexxy.presets.get(editorElement.preset),
2407
+ this.#overrides
2408
+ );
1904
2409
  }
1905
2410
 
1906
- get cursorPosition() {
1907
- let position = { x: 0, y: 0 };
1908
-
1909
- this.editor.getEditorState().read(() => {
1910
- const range = this.#getValidSelectionRange();
1911
- if (!range) return
1912
-
1913
- const rect = this.#getReliableRectFromRange(range);
1914
- if (!rect) return
1915
-
1916
- position = this.#calculateCursorPosition(rect, range);
1917
- });
1918
-
1919
- return position
2411
+ get(path) {
2412
+ return this.#config.get(path)
1920
2413
  }
1921
2414
 
1922
- placeCursorAtTheEnd() {
1923
- this.editor.update(() => {
1924
- const root = $getRoot();
1925
- const lastDescendant = root.getLastDescendant();
1926
-
1927
- if (lastDescendant && $isTextNode(lastDescendant)) {
1928
- lastDescendant.selectEnd();
1929
- } else {
1930
- root.selectEnd();
2415
+ get #overrides() {
2416
+ const overrides = {};
2417
+ for (const option of this.#defaultOptions) {
2418
+ const attribute = dasherize(option);
2419
+ if (this.#editorElement.hasAttribute(attribute)) {
2420
+ overrides[option] = this.#parseAttribute(attribute);
1931
2421
  }
1932
- });
2422
+ }
2423
+ return overrides
1933
2424
  }
1934
2425
 
1935
- selectedNodeWithOffset() {
1936
- const selection = $getSelection();
1937
- if (!selection) return { node: null, offset: 0 }
2426
+ get #defaultOptions() {
2427
+ return Object.keys(Lexxy.presets.get("default"))
2428
+ }
1938
2429
 
1939
- if ($isRangeSelection(selection)) {
1940
- return {
1941
- node: selection.anchor.getNode(),
1942
- offset: selection.anchor.offset
1943
- }
1944
- } else if ($isNodeSelection(selection)) {
1945
- const [ node ] = selection.getNodes();
1946
- return {
1947
- node,
1948
- offset: 0
1949
- }
2430
+ #parseAttribute(attribute) {
2431
+ const value = this.#editorElement.getAttribute(attribute);
2432
+ try {
2433
+ return JSON.parse(value)
2434
+ } catch {
2435
+ return value
1950
2436
  }
2437
+ }
2438
+ }
1951
2439
 
1952
- return { node: null, offset: 0 }
2440
+ class CustomActionTextAttachmentNode extends DecoratorNode {
2441
+ static getType() {
2442
+ return "custom_action_text_attachment"
1953
2443
  }
1954
2444
 
1955
- preservingSelection(fn) {
1956
- let selectionState = null;
2445
+ static clone(node) {
2446
+ return new CustomActionTextAttachmentNode({ ...node }, node.__key)
2447
+ }
1957
2448
 
1958
- this.editor.getEditorState().read(() => {
1959
- const selection = $getSelection();
1960
- if (selection && $isRangeSelection(selection)) {
1961
- selectionState = {
1962
- anchor: { key: selection.anchor.key, offset: selection.anchor.offset },
1963
- focus: { key: selection.focus.key, offset: selection.focus.offset }
1964
- };
1965
- }
1966
- });
2449
+ static importJSON(serializedNode) {
2450
+ return new CustomActionTextAttachmentNode({ ...serializedNode })
2451
+ }
1967
2452
 
1968
- fn();
2453
+ static importDOM() {
1969
2454
 
1970
- if (selectionState) {
1971
- this.editor.update(() => {
1972
- const selection = $getSelection();
1973
- if (selection && $isRangeSelection(selection)) {
1974
- selection.anchor.set(selectionState.anchor.key, selectionState.anchor.offset, "text");
1975
- selection.focus.set(selectionState.focus.key, selectionState.focus.offset, "text");
2455
+ return {
2456
+ [this.TAG_NAME]: (element) => {
2457
+ if (!element.getAttribute("content")) {
2458
+ return null
1976
2459
  }
1977
- });
1978
- }
1979
- }
1980
-
1981
- getFormat() {
1982
- const selection = $getSelection();
1983
- if (!$isRangeSelection(selection)) return {}
1984
2460
 
1985
- const anchorNode = selection.anchor.getNode();
1986
- if (!anchorNode.getParent()) return {}
2461
+ return {
2462
+ conversion: (attachment) => {
2463
+ // Preserve initial space if present since Lexical removes it
2464
+ const nodes = [];
2465
+ const previousSibling = attachment.previousSibling;
2466
+ if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
2467
+ nodes.push($createTextNode(" "));
2468
+ }
1987
2469
 
1988
- const topLevelElement = anchorNode.getTopLevelElementOrThrow();
1989
- const listType = getListType(anchorNode);
2470
+ nodes.push(new CustomActionTextAttachmentNode({
2471
+ sgid: attachment.getAttribute("sgid"),
2472
+ innerHtml: JSON.parse(attachment.getAttribute("content")),
2473
+ contentType: attachment.getAttribute("content-type")
2474
+ }));
1990
2475
 
1991
- return {
1992
- isBold: selection.hasFormat("bold"),
1993
- isItalic: selection.hasFormat("italic"),
1994
- isStrikethrough: selection.hasFormat("strikethrough"),
1995
- isHighlight: isSelectionHighlighted(selection),
1996
- isInLink: $getNearestNodeOfType(anchorNode, LinkNode) !== null,
1997
- isInQuote: $isQuoteNode(topLevelElement),
1998
- isInHeading: $isHeadingNode(topLevelElement),
1999
- isInCode: selection.hasFormat("code") || $getNearestNodeOfType(anchorNode, CodeNode) !== null,
2000
- isInList: listType !== null,
2001
- listType,
2002
- isInTable: $getTableCellNodeFromLexicalNode(anchorNode) !== null
2476
+ nodes.push($createTextNode(" "));
2477
+
2478
+ return { node: nodes }
2479
+ },
2480
+ priority: 2
2481
+ }
2482
+ }
2003
2483
  }
2004
2484
  }
2005
2485
 
2006
- nearestNodeOfType(nodeType) {
2007
- const anchorNode = $getSelection()?.anchor?.getNode();
2008
- return $getNearestNodeOfType(anchorNode, nodeType)
2486
+ static get TAG_NAME() {
2487
+ return Lexxy.global.get("attachmentTagName")
2009
2488
  }
2010
2489
 
2011
- get hasSelectedWordsInSingleLine() {
2012
- const selection = $getSelection();
2013
- if (!$isRangeSelection(selection)) return false
2014
-
2015
- if (selection.isCollapsed()) return false
2490
+ constructor({ tagName, sgid, contentType, innerHtml }, key) {
2491
+ super(key);
2016
2492
 
2017
- const anchorNode = selection.anchor.getNode();
2018
- const focusNode = selection.focus.getNode();
2493
+ const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
2019
2494
 
2020
- if (anchorNode.getTopLevelElement() !== focusNode.getTopLevelElement()) {
2021
- return false
2022
- }
2495
+ this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
2496
+ this.sgid = sgid;
2497
+ this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
2498
+ this.innerHtml = innerHtml;
2499
+ }
2023
2500
 
2024
- const anchorElement = anchorNode.getTopLevelElement();
2025
- if (!anchorElement) return false
2501
+ createDOM() {
2502
+ const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
2026
2503
 
2027
- const nodes = selection.getNodes();
2028
- for (const node of nodes) {
2029
- if ($isLineBreakNode(node)) {
2030
- return false
2031
- }
2032
- }
2504
+ figure.insertAdjacentHTML("beforeend", this.innerHtml);
2033
2505
 
2034
- return true
2035
- }
2506
+ const deleteButton = createElement("lexxy-node-delete-button");
2507
+ figure.appendChild(deleteButton);
2036
2508
 
2037
- get isInsideList() {
2038
- return this.nearestNodeOfType(ListItemNode)
2509
+ return figure
2039
2510
  }
2040
2511
 
2041
- get isIndentedList() {
2042
- const closestListNode = this.nearestNodeOfType(ListNode);
2043
- return closestListNode && ($getListDepth(closestListNode) > 1)
2512
+ updateDOM() {
2513
+ return false
2044
2514
  }
2045
2515
 
2046
- get isInsideCodeBlock() {
2047
- return this.nearestNodeOfType(CodeNode) !== null
2516
+ getTextContent() {
2517
+ return this.createDOM().textContent.trim() || `[${this.contentType}]`
2048
2518
  }
2049
2519
 
2050
- get isTableCellSelected() {
2051
- return this.nearestNodeOfType(TableCellNode) !== null
2520
+ isInline() {
2521
+ return true
2052
2522
  }
2053
2523
 
2054
- get nodeAfterCursor() {
2055
- const { anchorNode, offset } = this.#getCollapsedSelectionData();
2056
- if (!anchorNode) return null
2524
+ exportDOM() {
2525
+ const attachment = createElement(this.tagName, {
2526
+ sgid: this.sgid,
2527
+ content: JSON.stringify(this.innerHtml),
2528
+ "content-type": this.contentType
2529
+ });
2057
2530
 
2058
- if ($isTextNode(anchorNode)) {
2059
- return this.#getNodeAfterTextNode(anchorNode, offset)
2060
- }
2531
+ return { element: attachment }
2532
+ }
2061
2533
 
2062
- if ($isElementNode(anchorNode)) {
2063
- return this.#getNodeAfterElementNode(anchorNode, offset)
2534
+ exportJSON() {
2535
+ return {
2536
+ type: "custom_action_text_attachment",
2537
+ version: 1,
2538
+ tagName: this.tagName,
2539
+ sgid: this.sgid,
2540
+ contentType: this.contentType,
2541
+ innerHtml: this.innerHtml
2064
2542
  }
2065
-
2066
- return this.#findNextSiblingUp(anchorNode)
2067
2543
  }
2068
2544
 
2069
- get topLevelNodeAfterCursor() {
2070
- const { anchorNode, offset } = this.#getCollapsedSelectionData();
2071
- if (!anchorNode) return null
2072
-
2073
- if ($isTextNode(anchorNode)) {
2074
- return this.#getNextNodeFromTextEnd(anchorNode)
2075
- }
2545
+ decorate() {
2546
+ return null
2547
+ }
2548
+ }
2076
2549
 
2077
- if ($isElementNode(anchorNode)) {
2078
- return this.#getNodeAfterElementNode(anchorNode, offset)
2079
- }
2550
+ class FormatEscaper {
2551
+ constructor(editorElement) {
2552
+ this.editorElement = editorElement;
2553
+ this.editor = editorElement.editor;
2554
+ }
2080
2555
 
2081
- return this.#findNextSiblingUp(anchorNode)
2556
+ monitor() {
2557
+ this.editor.registerCommand(
2558
+ KEY_ENTER_COMMAND,
2559
+ (event) => this.#handleEnterKey(event),
2560
+ COMMAND_PRIORITY_HIGH
2561
+ );
2082
2562
  }
2083
2563
 
2084
- get nodeBeforeCursor() {
2085
- const { anchorNode, offset } = this.#getCollapsedSelectionData();
2086
- if (!anchorNode) return null
2564
+ #handleEnterKey(event) {
2565
+ const selection = $getSelection();
2566
+ if (!$isRangeSelection(selection)) return false
2087
2567
 
2088
- if ($isTextNode(anchorNode)) {
2089
- return this.#getNodeBeforeTextNode(anchorNode, offset)
2090
- }
2568
+ const anchorNode = selection.anchor.getNode();
2091
2569
 
2092
- if ($isElementNode(anchorNode)) {
2093
- return this.#getNodeBeforeElementNode(anchorNode, offset)
2094
- }
2570
+ if (!this.#isInsideBlockquote(anchorNode)) return false
2095
2571
 
2096
- return this.#findPreviousSiblingUp(anchorNode)
2572
+ return this.#handleLists(event, anchorNode)
2573
+ || this.#handleBlockquotes(event, anchorNode)
2097
2574
  }
2098
2575
 
2099
- get topLevelNodeBeforeCursor() {
2100
- const { anchorNode, offset } = this.#getCollapsedSelectionData();
2101
- if (!anchorNode) return null
2102
-
2103
- if ($isTextNode(anchorNode)) {
2104
- return this.#getPreviousNodeFromTextStart(anchorNode)
2576
+ #handleLists(event, anchorNode) {
2577
+ if (this.#shouldEscapeFromEmptyListItem(anchorNode) || this.#shouldEscapeFromEmptyParagraphInListItem(anchorNode)) {
2578
+ event.preventDefault();
2579
+ this.#escapeFromList(anchorNode);
2580
+ return true
2105
2581
  }
2106
2582
 
2107
- if ($isElementNode(anchorNode)) {
2108
- return this.#getNodeBeforeElementNode(anchorNode, offset)
2583
+ return false
2584
+ }
2585
+
2586
+ #handleBlockquotes(event, anchorNode) {
2587
+ if (this.#shouldEscapeFromEmptyParagraphInBlockquote(anchorNode)) {
2588
+ event.preventDefault();
2589
+ this.#escapeFromBlockquote(anchorNode);
2590
+ return true
2109
2591
  }
2110
2592
 
2111
- return this.#findPreviousSiblingUp(anchorNode)
2593
+ return false
2112
2594
  }
2113
2595
 
2114
- get #currentlySelectedKeys() {
2115
- if (this.currentlySelectedKeys) { return this.currentlySelectedKeys }
2116
-
2117
- this.currentlySelectedKeys = new Set();
2596
+ #isInsideBlockquote(node) {
2597
+ let currentNode = node;
2118
2598
 
2119
- const selection = $getSelection();
2120
- if (selection && $isNodeSelection(selection)) {
2121
- for (const node of selection.getNodes()) {
2122
- this.currentlySelectedKeys.add(node.getKey());
2599
+ while (currentNode) {
2600
+ if ($isQuoteNode(currentNode)) {
2601
+ return true
2123
2602
  }
2603
+ currentNode = currentNode.getParent();
2124
2604
  }
2125
2605
 
2126
- return this.currentlySelectedKeys
2606
+ return false
2127
2607
  }
2128
2608
 
2129
- #processSelectionChangeCommands() {
2130
- this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW);
2131
- this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW);
2132
- this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
2133
- this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
2134
-
2135
- this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW);
2609
+ #shouldEscapeFromEmptyListItem(node) {
2610
+ const listItem = this.#getListItemNode(node);
2611
+ if (!listItem) return false
2136
2612
 
2137
- this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
2138
- this.current = $getSelection();
2139
- }, COMMAND_PRIORITY_LOW);
2613
+ return this.#isNodeEmpty(listItem)
2140
2614
  }
2141
2615
 
2142
- #listenForNodeSelections() {
2143
- this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
2144
- if (!isDOMNode(target)) return false
2616
+ #shouldEscapeFromEmptyParagraphInListItem(node) {
2617
+ const paragraph = this.#getParagraphNode(node);
2618
+ if (!paragraph) return false
2145
2619
 
2146
- const targetNode = $getNearestNodeFromDOMNode(target);
2147
- return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
2148
- }, COMMAND_PRIORITY_LOW);
2620
+ if (!this.#isNodeEmpty(paragraph)) return false
2149
2621
 
2150
- this.editor.getRootElement().addEventListener("lexxy:internal:move-to-next-line", (event) => {
2151
- this.#selectOrAppendNextLine();
2152
- });
2622
+ const parent = paragraph.getParent();
2623
+ return parent && $isListItemNode(parent)
2153
2624
  }
2154
2625
 
2155
- #containEditorFocus() {
2156
- // Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements
2157
- // above when navigating UP/DOWN when Lexical shows its fake cursor on custom decorator nodes.
2158
- this.editorContentElement.addEventListener("keydown", (event) => {
2159
- if (event.key === "ArrowUp") {
2160
- const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
2161
-
2162
- if (lexicalCursor) {
2163
- let currentElement = lexicalCursor.previousElementSibling;
2164
- while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
2165
- currentElement = currentElement.previousElementSibling;
2166
- }
2167
-
2168
- if (!currentElement) {
2169
- event.preventDefault();
2170
- }
2171
- }
2172
- }
2173
-
2174
- if (event.key === "ArrowDown") {
2175
- const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
2176
-
2177
- if (lexicalCursor) {
2178
- let currentElement = lexicalCursor.nextElementSibling;
2179
- while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
2180
- currentElement = currentElement.nextElementSibling;
2181
- }
2182
-
2183
- if (!currentElement) {
2184
- event.preventDefault();
2185
- }
2186
- }
2187
- }
2188
- }, true);
2189
- }
2626
+ #isNodeEmpty(node) {
2627
+ if (node.getTextContent().trim() !== "") return false
2190
2628
 
2191
- #syncSelectedClasses() {
2192
- this.#clearPreviouslyHighlightedItems();
2193
- this.#highlightNewItems();
2629
+ const children = node.getChildren();
2630
+ if (children.length === 0) return true
2194
2631
 
2195
- this.previouslySelectedKeys = this.#currentlySelectedKeys;
2196
- this.currentlySelectedKeys = null;
2632
+ return children.every(child => {
2633
+ if ($isLineBreakNode(child)) return true
2634
+ return this.#isNodeEmpty(child)
2635
+ })
2197
2636
  }
2198
2637
 
2199
- #clearPreviouslyHighlightedItems() {
2200
- for (const key of this.previouslySelectedKeys) {
2201
- if (!this.#currentlySelectedKeys.has(key)) {
2202
- const dom = this.editor.getElementByKey(key);
2203
- if (dom) dom.classList.remove("node--selected");
2204
- }
2205
- }
2206
- }
2638
+ #getListItemNode(node) {
2639
+ let currentNode = node;
2207
2640
 
2208
- #highlightNewItems() {
2209
- for (const key of this.#currentlySelectedKeys) {
2210
- if (!this.previouslySelectedKeys.has(key)) {
2211
- const nodeElement = this.editor.getElementByKey(key);
2212
- if (nodeElement) nodeElement.classList.add("node--selected");
2641
+ while (currentNode) {
2642
+ if ($isListItemNode(currentNode)) {
2643
+ return currentNode
2213
2644
  }
2645
+ currentNode = currentNode.getParent();
2214
2646
  }
2215
- }
2216
2647
 
2217
- async #selectPreviousNode() {
2218
- if (this.hasNodeSelection) {
2219
- return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
2220
- } else {
2221
- return this.#selectInLexical(this.nodeBeforeCursor)
2222
- }
2648
+ return null
2223
2649
  }
2224
2650
 
2225
- async #selectNextNode() {
2226
- if (this.hasNodeSelection) {
2227
- return await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0))
2228
- } else {
2229
- return this.#selectInLexical(this.nodeAfterCursor)
2230
- }
2231
- }
2651
+ #escapeFromList(anchorNode) {
2652
+ const listItem = this.#getListItemNode(anchorNode);
2653
+ if (!listItem) return
2232
2654
 
2233
- async #selectPreviousTopLevelNode() {
2234
- if (this.hasNodeSelection) {
2235
- return await this.#withCurrentNode((currentNode) => currentNode.getTopLevelElement().selectPrevious())
2236
- } else {
2237
- return this.#selectInLexical(this.topLevelNodeBeforeCursor)
2238
- }
2239
- }
2655
+ const parentList = listItem.getParent();
2656
+ if (!parentList || !$isListNode(parentList)) return
2240
2657
 
2241
- async #selectNextTopLevelNode() {
2242
- if (this.hasNodeSelection) {
2243
- return await this.#withCurrentNode((currentNode) => currentNode.getTopLevelElement().selectNext(0, 0))
2244
- } else {
2245
- return this.#selectInLexical(this.topLevelNodeAfterCursor)
2246
- }
2247
- }
2658
+ const blockquote = parentList.getParent();
2659
+ const isInBlockquote = blockquote && $isQuoteNode(blockquote);
2248
2660
 
2249
- async #withCurrentNode(fn) {
2250
- await nextFrame();
2251
- if (this.hasNodeSelection) {
2252
- this.editor.update(() => {
2253
- fn($getSelection().getNodes()[0]);
2254
- this.editor.focus();
2255
- });
2661
+ if (isInBlockquote) {
2662
+ const listItemsAfter = this.#getListItemSiblingsAfter(listItem);
2663
+ const nonEmptyListItems = listItemsAfter.filter(item => !this.#isNodeEmpty(item));
2664
+
2665
+ if (nonEmptyListItems.length > 0) {
2666
+ this.#splitBlockquoteWithList(blockquote, parentList, listItem, nonEmptyListItems);
2667
+ return
2668
+ }
2256
2669
  }
2257
- }
2258
2670
 
2259
- async #selectOrAppendNextLine() {
2260
- this.editor.update(() => {
2261
- const topLevelElement = this.#getTopLevelElementFromSelection();
2262
- if (!topLevelElement) return
2671
+ const paragraph = $createParagraphNode();
2672
+ parentList.insertAfter(paragraph);
2263
2673
 
2264
- this.#moveToOrCreateNextLine(topLevelElement);
2265
- });
2674
+ listItem.remove();
2675
+ paragraph.selectStart();
2266
2676
  }
2267
2677
 
2268
- #getTopLevelElementFromSelection() {
2269
- const selection = $getSelection();
2270
- if (!selection) return null
2678
+ #shouldEscapeFromEmptyParagraphInBlockquote(node) {
2679
+ const paragraph = this.#getParagraphNode(node);
2680
+ if (!paragraph) return false
2271
2681
 
2272
- if ($isNodeSelection(selection)) {
2273
- return this.#getTopLevelFromNodeSelection(selection)
2274
- }
2682
+ if (!this.#isNodeEmpty(paragraph)) return false
2275
2683
 
2276
- if ($isRangeSelection(selection)) {
2277
- return this.#getTopLevelFromRangeSelection(selection)
2684
+ const parent = paragraph.getParent();
2685
+ return parent && $isQuoteNode(parent)
2686
+ }
2687
+
2688
+ #getParagraphNode(node) {
2689
+ let currentNode = node;
2690
+
2691
+ while (currentNode) {
2692
+ if ($isParagraphNode(currentNode)) {
2693
+ return currentNode
2694
+ }
2695
+ currentNode = currentNode.getParent();
2278
2696
  }
2279
2697
 
2280
2698
  return null
2281
2699
  }
2282
2700
 
2283
- #getTopLevelFromNodeSelection(selection) {
2284
- const nodes = selection.getNodes();
2285
- return nodes.length > 0 ? nodes[0].getTopLevelElement() : null
2286
- }
2701
+ #escapeFromBlockquote(anchorNode) {
2702
+ const paragraph = this.#getParagraphNode(anchorNode);
2703
+ if (!paragraph) return
2287
2704
 
2288
- #getTopLevelFromRangeSelection(selection) {
2289
- const anchorNode = selection.anchor.getNode();
2290
- return anchorNode.getTopLevelElement()
2291
- }
2705
+ const blockquote = paragraph.getParent();
2706
+ if (!blockquote || !$isQuoteNode(blockquote)) return
2292
2707
 
2293
- #moveToOrCreateNextLine(topLevelElement) {
2294
- const nextSibling = topLevelElement.getNextSibling();
2708
+ const siblingsAfter = this.#getSiblingsAfter(paragraph);
2709
+ const nonEmptySiblings = siblingsAfter.filter(sibling => !this.#isNodeEmpty(sibling));
2295
2710
 
2296
- if (nextSibling) {
2297
- nextSibling.selectStart();
2711
+ if (nonEmptySiblings.length > 0) {
2712
+ this.#splitBlockquote(blockquote, paragraph, nonEmptySiblings);
2298
2713
  } else {
2299
- this.#createAndSelectNewParagraph();
2714
+ const newParagraph = $createParagraphNode();
2715
+ blockquote.insertAfter(newParagraph);
2716
+ paragraph.remove();
2717
+ newParagraph.selectStart();
2300
2718
  }
2301
2719
  }
2302
2720
 
2303
- #createAndSelectNewParagraph() {
2304
- const root = $getRoot();
2305
- const newParagraph = $createParagraphNode();
2306
- root.append(newParagraph);
2307
- newParagraph.selectStart();
2308
- }
2721
+ #getSiblingsAfter(node) {
2722
+ const siblings = [];
2723
+ let sibling = node.getNextSibling();
2309
2724
 
2310
- #selectInLexical(node) {
2311
- if ($isDecoratorNode(node)) {
2312
- const selection = $createNodeSelectionWith(node);
2313
- $setSelection(selection);
2314
- return selection
2315
- } else {
2316
- return false
2725
+ while (sibling) {
2726
+ siblings.push(sibling);
2727
+ sibling = sibling.getNextSibling();
2317
2728
  }
2729
+
2730
+ return siblings
2318
2731
  }
2319
2732
 
2320
- #selectDecoratorNodeBeforeDeletion(backwards) {
2321
- const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor;
2322
- if (node instanceof DecoratorNode) {
2323
- this.#selectInLexical(node);
2733
+ #getListItemSiblingsAfter(listItem) {
2734
+ const siblings = [];
2735
+ let sibling = listItem.getNextSibling();
2324
2736
 
2325
- return true
2326
- } else {
2327
- return false
2737
+ while (sibling) {
2738
+ if ($isListItemNode(sibling)) {
2739
+ siblings.push(sibling);
2740
+ }
2741
+ sibling = sibling.getNextSibling();
2328
2742
  }
2743
+
2744
+ return siblings
2329
2745
  }
2330
2746
 
2331
- #getValidSelectionRange() {
2332
- const lexicalSelection = $getSelection();
2333
- if (!lexicalSelection || !lexicalSelection.isCollapsed()) return null
2747
+ #splitBlockquoteWithList(blockquote, parentList, emptyListItem, listItemsAfter) {
2748
+ const blockquoteSiblingsAfterList = this.#getSiblingsAfter(parentList);
2749
+ const nonEmptyBlockquoteSiblings = blockquoteSiblingsAfterList.filter(sibling => !this.#isNodeEmpty(sibling));
2334
2750
 
2335
- const nativeSelection = window.getSelection();
2336
- if (!nativeSelection || nativeSelection.rangeCount === 0) return null
2751
+ const middleParagraph = $createParagraphNode();
2752
+ blockquote.insertAfter(middleParagraph);
2337
2753
 
2338
- return nativeSelection.getRangeAt(0)
2339
- }
2754
+ const newList = $createListNode(parentList.getListType());
2340
2755
 
2341
- #getReliableRectFromRange(range) {
2342
- let rect = range.getBoundingClientRect();
2756
+ const newBlockquote = $createQuoteNode();
2757
+ middleParagraph.insertAfter(newBlockquote);
2758
+ newBlockquote.append(newList);
2343
2759
 
2344
- if (this.#isRectUnreliable(rect)) {
2345
- const marker = this.#createAndInsertMarker(range);
2346
- rect = marker.getBoundingClientRect();
2347
- this.#restoreSelectionAfterMarker(marker);
2348
- marker.remove();
2349
- }
2760
+ listItemsAfter.forEach(item => {
2761
+ newList.append(item);
2762
+ });
2350
2763
 
2351
- return rect
2352
- }
2764
+ nonEmptyBlockquoteSiblings.forEach(sibling => {
2765
+ newBlockquote.append(sibling);
2766
+ });
2353
2767
 
2354
- #isRectUnreliable(rect) {
2355
- return rect.width === 0 && rect.height === 0 || rect.top === 0 && rect.left === 0
2356
- }
2768
+ emptyListItem.remove();
2357
2769
 
2358
- #createAndInsertMarker(range) {
2359
- const marker = this.#createMarker();
2360
- range.insertNode(marker);
2361
- return marker
2770
+ this.#removeTrailingEmptyListItems(parentList);
2771
+ this.#removeTrailingEmptyNodes(newBlockquote);
2772
+
2773
+ if (parentList.getChildrenSize() === 0) {
2774
+ parentList.remove();
2775
+
2776
+ if (blockquote.getChildrenSize() === 0) {
2777
+ blockquote.remove();
2778
+ }
2779
+ } else {
2780
+ this.#removeTrailingEmptyNodes(blockquote);
2781
+ }
2782
+
2783
+ middleParagraph.selectStart();
2362
2784
  }
2363
2785
 
2364
- #createMarker() {
2365
- const marker = document.createElement("span");
2366
- marker.textContent = "\u200b";
2367
- marker.style.display = "inline-block";
2368
- marker.style.width = "1px";
2369
- marker.style.height = "1em";
2370
- marker.style.lineHeight = "normal";
2371
- marker.setAttribute("nonce", getNonce());
2372
- return marker
2786
+ #removeTrailingEmptyListItems(list) {
2787
+ const items = list.getChildren();
2788
+ for (let i = items.length - 1; i >= 0; i--) {
2789
+ const item = items[i];
2790
+ if ($isListItemNode(item) && this.#isNodeEmpty(item)) {
2791
+ item.remove();
2792
+ } else {
2793
+ break
2794
+ }
2795
+ }
2373
2796
  }
2374
2797
 
2375
- #restoreSelectionAfterMarker(marker) {
2376
- const nativeSelection = window.getSelection();
2377
- nativeSelection.removeAllRanges();
2378
- const newRange = document.createRange();
2379
- newRange.setStartAfter(marker);
2380
- newRange.collapse(true);
2381
- nativeSelection.addRange(newRange);
2798
+ #removeTrailingEmptyNodes(blockquote) {
2799
+ const children = blockquote.getChildren();
2800
+ for (let i = children.length - 1; i >= 0; i--) {
2801
+ const child = children[i];
2802
+ if (this.#isNodeEmpty(child)) {
2803
+ child.remove();
2804
+ } else {
2805
+ break
2806
+ }
2807
+ }
2382
2808
  }
2383
2809
 
2384
- #calculateCursorPosition(rect, range) {
2385
- const rootRect = this.editor.getRootElement().getBoundingClientRect();
2386
- const x = rect.left - rootRect.left;
2387
- let y = rect.top - rootRect.top;
2810
+ #splitBlockquote(blockquote, emptyParagraph, siblingsAfter) {
2811
+ const newParagraph = $createParagraphNode();
2812
+ blockquote.insertAfter(newParagraph);
2388
2813
 
2389
- const fontSize = this.#getFontSizeForCursor(range);
2390
- if (!isNaN(fontSize)) {
2391
- y += fontSize;
2392
- }
2814
+ const newBlockquote = $createQuoteNode();
2815
+ newParagraph.insertAfter(newBlockquote);
2393
2816
 
2394
- return { x, y, fontSize }
2395
- }
2817
+ siblingsAfter.forEach(sibling => {
2818
+ newBlockquote.append(sibling);
2819
+ });
2396
2820
 
2397
- #getFontSizeForCursor(range) {
2398
- const nativeSelection = window.getSelection();
2399
- const anchorNode = nativeSelection.anchorNode;
2400
- const parentElement = this.#getElementFromNode(anchorNode);
2821
+ emptyParagraph.remove();
2401
2822
 
2402
- if (parentElement instanceof HTMLElement) {
2403
- const computed = window.getComputedStyle(parentElement);
2404
- return parseFloat(computed.fontSize)
2405
- }
2823
+ this.#removeTrailingEmptyNodes(blockquote);
2824
+ this.#removeTrailingEmptyNodes(newBlockquote);
2406
2825
 
2407
- return 0
2826
+ newParagraph.selectStart();
2408
2827
  }
2828
+ }
2409
2829
 
2410
- #getElementFromNode(node) {
2411
- return node?.nodeType === Node.TEXT_NODE ? node.parentElement : node
2412
- }
2830
+ async function loadFileIntoImage(file, image) {
2831
+ return new Promise((resolve) => {
2832
+ const reader = new FileReader();
2413
2833
 
2414
- #getCollapsedSelectionData() {
2415
- const selection = $getSelection();
2416
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
2417
- return { anchorNode: null, offset: 0 }
2418
- }
2834
+ image.addEventListener("load", () => {
2835
+ resolve(image);
2836
+ });
2419
2837
 
2420
- const { anchor } = selection;
2421
- return { anchorNode: anchor.getNode(), offset: anchor.offset }
2422
- }
2838
+ reader.onload = (event) => {
2839
+ image.src = event.target.result || null;
2840
+ };
2423
2841
 
2424
- #getNodeAfterTextNode(anchorNode, offset) {
2425
- if (offset === anchorNode.getTextContentSize()) {
2426
- return this.#getNextNodeFromTextEnd(anchorNode)
2427
- }
2428
- return null
2842
+ reader.readAsDataURL(file);
2843
+ })
2844
+ }
2845
+
2846
+ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
2847
+ static getType() {
2848
+ return "action_text_attachment_upload"
2429
2849
  }
2430
2850
 
2431
- #getNextNodeFromTextEnd(anchorNode) {
2432
- if (anchorNode.getNextSibling() instanceof DecoratorNode) {
2433
- return anchorNode.getNextSibling()
2434
- }
2435
- const parent = anchorNode.getParent();
2436
- return parent ? parent.getNextSibling() : null
2851
+ static clone(node) {
2852
+ return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
2437
2853
  }
2438
2854
 
2439
- #getNodeAfterElementNode(anchorNode, offset) {
2440
- if (offset < anchorNode.getChildrenSize()) {
2441
- return anchorNode.getChildAtIndex(offset)
2442
- }
2443
- return this.#findNextSiblingUp(anchorNode)
2855
+ static importJSON(serializedNode) {
2856
+ return new ActionTextAttachmentUploadNode({ ...serializedNode })
2444
2857
  }
2445
2858
 
2446
- #getNodeBeforeTextNode(anchorNode, offset) {
2447
- if (offset === 0) {
2448
- return this.#getPreviousNodeFromTextStart(anchorNode)
2449
- }
2859
+ // Should never run since this is a transient node. Defined to remove console warning.
2860
+ static importDOM() {
2450
2861
  return null
2451
2862
  }
2452
2863
 
2453
- #getPreviousNodeFromTextStart(anchorNode) {
2454
- if (anchorNode.getPreviousSibling() instanceof DecoratorNode) {
2455
- return anchorNode.getPreviousSibling()
2456
- }
2457
- const parent = anchorNode.getParent();
2458
- return parent.getPreviousSibling()
2864
+ constructor(node, key) {
2865
+ const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node;
2866
+ super({ ...node, contentType: file.type }, key);
2867
+ this.file = file;
2868
+ this.uploadUrl = uploadUrl;
2869
+ this.blobUrlTemplate = blobUrlTemplate;
2870
+ this.progress = progress ?? null;
2871
+ this.width = width;
2872
+ this.height = height;
2873
+ this.uploadError = uploadError;
2459
2874
  }
2460
2875
 
2461
- #getNodeBeforeElementNode(anchorNode, offset) {
2462
- if (offset > 0) {
2463
- return anchorNode.getChildAtIndex(offset - 1)
2464
- }
2465
- return this.#findPreviousSiblingUp(anchorNode)
2466
- }
2876
+ createDOM() {
2877
+ if (this.uploadError) return this.#createDOMForError()
2467
2878
 
2468
- #findNextSiblingUp(node) {
2469
- let current = node;
2470
- while (current && current.getNextSibling() == null) {
2471
- current = current.getParent();
2472
- }
2473
- return current ? current.getNextSibling() : null
2474
- }
2879
+ // This side-effect is trigged on DOM load to fire only once and avoid multiple
2880
+ // uploads through cloning. The upload is guarded from restarting in case the
2881
+ // node is reloaded from saved state such as from history.
2882
+ this.#startUploadIfNeeded();
2475
2883
 
2476
- #findPreviousSiblingUp(node) {
2477
- let current = node;
2478
- while (current && current.getPreviousSibling() == null) {
2479
- current = current.getParent();
2884
+ const figure = this.createAttachmentFigure();
2885
+
2886
+ if (this.isPreviewableAttachment) {
2887
+ const img = figure.appendChild(this.#createDOMForImage());
2888
+
2889
+ // load file locally to set dimensions and prevent vertical shifting
2890
+ loadFileIntoImage(this.file, img).then(img => this.#setDimensionsFromImage(img));
2891
+ } else {
2892
+ figure.appendChild(this.#createDOMForFile());
2480
2893
  }
2481
- return current ? current.getPreviousSibling() : null
2894
+
2895
+ figure.appendChild(this.#createCaption());
2896
+ figure.appendChild(this.#createProgressBar());
2897
+
2898
+ return figure
2482
2899
  }
2483
- }
2484
2900
 
2485
- function sanitize(html) {
2486
- return DOMPurify.sanitize(html, buildConfig())
2487
- }
2901
+ updateDOM(prevNode, dom) {
2902
+ if (this.uploadError !== prevNode.uploadError) return true
2488
2903
 
2489
- function dasherize(value) {
2490
- return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
2491
- }
2904
+ if (prevNode.progress !== this.progress) {
2905
+ const progress = dom.querySelector("progress");
2906
+ progress.value = this.progress ?? 0;
2907
+ }
2492
2908
 
2493
- function isUrl(string) {
2494
- try {
2495
- new URL(string);
2496
- return true
2497
- } catch {
2498
2909
  return false
2499
2910
  }
2500
- }
2501
-
2502
- function normalizeFilteredText(string) {
2503
- return string
2504
- .toLowerCase()
2505
- .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
2506
- }
2507
2911
 
2508
- function filterMatches(text, potentialMatch) {
2509
- return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))
2510
- }
2912
+ exportDOM() {
2913
+ return { element: null }
2914
+ }
2511
2915
 
2512
- function upcaseFirst(string) {
2513
- return string.charAt(0).toUpperCase() + string.slice(1)
2514
- }
2916
+ exportJSON() {
2917
+ return {
2918
+ ...super.exportJSON(),
2919
+ type: "action_text_attachment_upload",
2920
+ version: 1,
2921
+ uploadUrl: this.uploadUrl,
2922
+ blobUrlTemplate: this.blobUrlTemplate,
2923
+ progress: this.progress,
2924
+ width: this.width,
2925
+ height: this.height,
2926
+ uploadError: this.uploadError
2927
+ }
2928
+ }
2515
2929
 
2516
- class EditorConfiguration {
2517
- #editorElement
2518
- #config
2930
+ get #uploadStarted() {
2931
+ return this.progress !== null
2932
+ }
2519
2933
 
2520
- constructor(editorElement) {
2521
- this.#editorElement = editorElement;
2522
- this.#config = new Configuration(
2523
- Lexxy.presets.get("default"),
2524
- Lexxy.presets.get(editorElement.preset),
2525
- this.#overrides
2526
- );
2934
+ #createDOMForError() {
2935
+ const figure = this.createAttachmentFigure();
2936
+ figure.classList.add("attachment--error");
2937
+ figure.appendChild(createElement("div", { innerText: `Error uploading ${this.file?.name ?? "file"}` }));
2938
+ return figure
2527
2939
  }
2528
2940
 
2529
- get(path) {
2530
- return this.#config.get(path)
2941
+ #createDOMForImage() {
2942
+ return createElement("img")
2531
2943
  }
2532
2944
 
2533
- get #overrides() {
2534
- const overrides = {};
2535
- for (const option of this.#defaultOptions) {
2536
- const attribute = dasherize(option);
2537
- if (this.#editorElement.hasAttribute(attribute)) {
2538
- overrides[option] = this.#parseAttribute(attribute);
2539
- }
2540
- }
2541
- return overrides
2945
+ #createDOMForFile() {
2946
+ const extension = this.#getFileExtension();
2947
+ const span = createElement("span", { className: "attachment__icon", textContent: extension });
2948
+ return span
2542
2949
  }
2543
2950
 
2544
- get #defaultOptions() {
2545
- return Object.keys(Lexxy.presets.get("default"))
2951
+ #getFileExtension() {
2952
+ return this.file.name.split(".").pop().toLowerCase()
2546
2953
  }
2547
2954
 
2548
- #parseAttribute(attribute) {
2549
- const value = this.#editorElement.getAttribute(attribute);
2550
- try {
2551
- return JSON.parse(value)
2552
- } catch {
2553
- return value
2554
- }
2955
+ #createCaption() {
2956
+ const figcaption = createElement("figcaption", { className: "attachment__caption" });
2957
+
2958
+ const nameSpan = createElement("span", { className: "attachment__name", textContent: this.file.name || "" });
2959
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
2960
+ figcaption.appendChild(nameSpan);
2961
+ figcaption.appendChild(sizeSpan);
2962
+
2963
+ return figcaption
2555
2964
  }
2556
- }
2557
2965
 
2558
- class CustomActionTextAttachmentNode extends DecoratorNode {
2559
- static getType() {
2560
- return "custom_action_text_attachment"
2966
+ #createProgressBar() {
2967
+ return createElement("progress", { value: this.progress ?? 0, max: 100 })
2561
2968
  }
2562
2969
 
2563
- static clone(node) {
2564
- return new CustomActionTextAttachmentNode({ ...node }, node.__key)
2970
+ #setDimensionsFromImage({ width, height }) {
2971
+ if (this.#hasDimensions) return
2972
+
2973
+ this.editor.update(() => {
2974
+ const writable = this.getWritable();
2975
+ writable.width = width;
2976
+ writable.height = height;
2977
+ }, { tag: SILENT_UPDATE_TAGS });
2565
2978
  }
2566
2979
 
2567
- static importJSON(serializedNode) {
2568
- return new CustomActionTextAttachmentNode({ ...serializedNode })
2980
+ get #hasDimensions() {
2981
+ return Boolean(this.width && this.height)
2569
2982
  }
2570
2983
 
2571
- static importDOM() {
2984
+ async #startUploadIfNeeded() {
2985
+ if (this.#uploadStarted) return
2572
2986
 
2573
- return {
2574
- [this.TAG_NAME]: (element) => {
2575
- if (!element.getAttribute("content")) {
2576
- return null
2577
- }
2987
+ this.#setUploadStarted();
2578
2988
 
2579
- return {
2580
- conversion: (attachment) => {
2581
- // Preserve initial space if present since Lexical removes it
2582
- const nodes = [];
2583
- const previousSibling = attachment.previousSibling;
2584
- if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
2585
- nodes.push($createTextNode(" "));
2586
- }
2989
+ const { DirectUpload } = await import('@rails/activestorage');
2587
2990
 
2588
- nodes.push(new CustomActionTextAttachmentNode({
2589
- sgid: attachment.getAttribute("sgid"),
2590
- innerHtml: JSON.parse(attachment.getAttribute("content")),
2591
- contentType: attachment.getAttribute("content-type")
2592
- }));
2991
+ const upload = new DirectUpload(this.file, this.uploadUrl, this);
2992
+ upload.delegate = this.#createUploadDelegate();
2593
2993
 
2594
- nodes.push($createTextNode(" "));
2994
+ this.#dispatchEvent("lexxy:upload-start", { file: this.file });
2595
2995
 
2596
- return { node: nodes }
2597
- },
2598
- priority: 2
2599
- }
2996
+ upload.create((error, blob) => {
2997
+ if (error) {
2998
+ this.#dispatchEvent("lexxy:upload-end", { file: this.file, error });
2999
+ this.#handleUploadError(error);
3000
+ } else {
3001
+ this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
3002
+ this.#showUploadedAttachment(blob);
2600
3003
  }
2601
- }
2602
- }
2603
-
2604
- static get TAG_NAME() {
2605
- return Lexxy.global.get("attachmentTagName")
3004
+ });
2606
3005
  }
2607
3006
 
2608
- constructor({ tagName, sgid, contentType, innerHtml }, key) {
2609
- super(key);
3007
+ #createUploadDelegate() {
3008
+ const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
2610
3009
 
2611
- const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
3010
+ return {
3011
+ directUploadWillCreateBlobWithXHR: (request) => {
3012
+ if (shouldAuthenticateUploads) request.withCredentials = true;
3013
+ },
3014
+ directUploadWillStoreFileWithXHR: (request) => {
3015
+ if (shouldAuthenticateUploads) request.withCredentials = true;
2612
3016
 
2613
- this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
2614
- this.sgid = sgid;
2615
- this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
2616
- this.innerHtml = innerHtml;
3017
+ const uploadProgressHandler = (event) => this.#handleUploadProgress(event);
3018
+ request.upload.addEventListener("progress", uploadProgressHandler);
3019
+ }
3020
+ }
2617
3021
  }
2618
3022
 
2619
- createDOM() {
2620
- const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
2621
-
2622
- figure.insertAdjacentHTML("beforeend", this.innerHtml);
2623
-
2624
- return figure
3023
+ #setUploadStarted() {
3024
+ this.#setProgress(1);
2625
3025
  }
2626
3026
 
2627
- updateDOM() {
2628
- return false
3027
+ #handleUploadProgress(event) {
3028
+ const progress = Math.round(event.loaded / event.total * 100);
3029
+ this.#setProgress(progress);
3030
+ this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
2629
3031
  }
2630
3032
 
2631
- getTextContent() {
2632
- return this.createDOM().textContent.trim() || `[${this.contentType}]`
3033
+ #setProgress(progress) {
3034
+ this.editor.update(() => {
3035
+ this.getWritable().progress = progress;
3036
+ }, { tag: SILENT_UPDATE_TAGS });
2633
3037
  }
2634
3038
 
2635
- isInline() {
2636
- return true
3039
+ #handleUploadError(error) {
3040
+ console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
3041
+ this.editor.update(() => {
3042
+ this.getWritable().uploadError = true;
3043
+ }, { tag: SILENT_UPDATE_TAGS });
2637
3044
  }
2638
3045
 
2639
- exportDOM() {
2640
- const attachment = createElement(this.tagName, {
2641
- sgid: this.sgid,
2642
- content: JSON.stringify(this.innerHtml),
2643
- "content-type": this.contentType
2644
- });
2645
-
2646
- return { element: attachment }
3046
+ #showUploadedAttachment(blob) {
3047
+ this.editor.update(() => {
3048
+ this.replace(this.#toActionTextAttachmentNodeWith(blob));
3049
+ }, { tag: SILENT_UPDATE_TAGS });
2647
3050
  }
2648
3051
 
2649
- exportJSON() {
2650
- return {
2651
- type: "custom_action_text_attachment",
2652
- version: 1,
2653
- tagName: this.tagName,
2654
- sgid: this.sgid,
2655
- contentType: this.contentType,
2656
- innerHtml: this.innerHtml
2657
- }
3052
+ #toActionTextAttachmentNodeWith(blob) {
3053
+ const conversion = new AttachmentNodeConversion(this, blob);
3054
+ return conversion.toAttachmentNode()
2658
3055
  }
2659
3056
 
2660
- decorate() {
2661
- return null
3057
+ #dispatchEvent(name, detail) {
3058
+ const figure = this.editor.getElementByKey(this.getKey());
3059
+ if (figure) dispatch(figure, name, detail);
2662
3060
  }
2663
3061
  }
2664
3062
 
2665
- class FormatEscaper {
2666
- constructor(editorElement) {
2667
- this.editorElement = editorElement;
2668
- this.editor = editorElement.editor;
3063
+ class AttachmentNodeConversion {
3064
+ constructor(uploadNode, blob) {
3065
+ this.uploadNode = uploadNode;
3066
+ this.blob = blob;
2669
3067
  }
2670
3068
 
2671
- monitor() {
2672
- this.editor.registerCommand(
2673
- KEY_ENTER_COMMAND,
2674
- (event) => this.#handleEnterKey(event),
2675
- COMMAND_PRIORITY_HIGH
2676
- );
3069
+ toAttachmentNode() {
3070
+ return new ActionTextAttachmentNode({
3071
+ ...this.uploadNode,
3072
+ ...this.#propertiesFromBlob,
3073
+ src: this.#src
3074
+ })
2677
3075
  }
2678
3076
 
2679
- #handleEnterKey(event) {
2680
- const selection = $getSelection();
2681
- if (!$isRangeSelection(selection)) return false
2682
-
2683
- const anchorNode = selection.anchor.getNode();
3077
+ get #propertiesFromBlob() {
3078
+ const { blob } = this;
3079
+ return {
3080
+ sgid: blob.attachable_sgid,
3081
+ altText: blob.filename,
3082
+ contentType: blob.content_type,
3083
+ fileName: blob.filename,
3084
+ fileSize: blob.byte_size,
3085
+ previewable: blob.previewable,
3086
+ }
3087
+ }
2684
3088
 
2685
- if (!this.#isInsideBlockquote(anchorNode)) return false
3089
+ get #src() {
3090
+ return this.blob.previewable ? this.blob.url : this.#blobSrc
3091
+ }
2686
3092
 
2687
- return this.#handleLists(event, anchorNode)
2688
- || this.#handleBlockquotes(event, anchorNode)
3093
+ get #blobSrc() {
3094
+ return this.uploadNode.blobUrlTemplate
3095
+ .replace(":signed_id", this.blob.signed_id)
3096
+ .replace(":filename", encodeURIComponent(this.blob.filename))
2689
3097
  }
3098
+ }
2690
3099
 
2691
- #handleLists(event, anchorNode) {
2692
- if (this.#shouldEscapeFromEmptyListItem(anchorNode) || this.#shouldEscapeFromEmptyParagraphInListItem(anchorNode)) {
2693
- event.preventDefault();
2694
- this.#escapeFromList(anchorNode);
2695
- return true
2696
- }
3100
+ function $createActionTextAttachmentUploadNode(...args) {
3101
+ return new ActionTextAttachmentUploadNode(...args)
3102
+ }
2697
3103
 
2698
- return false
3104
+ class ImageGalleryNode extends ElementNode {
3105
+ $config() {
3106
+ return this.config("image_gallery", {
3107
+ extends: ElementNode,
3108
+ })
2699
3109
  }
2700
3110
 
2701
- #handleBlockquotes(event, anchorNode) {
2702
- if (this.#shouldEscapeFromEmptyParagraphInBlockquote(anchorNode)) {
2703
- event.preventDefault();
2704
- this.#escapeFromBlockquote(anchorNode);
2705
- return true
3111
+ static transform() {
3112
+ return (gallery) => {
3113
+ gallery.unwrapEmptyNode()
3114
+ || gallery.replaceWithSingularChild()
3115
+ || gallery.splitAroundInvalidChild();
2706
3116
  }
2707
-
2708
- return false
2709
3117
  }
2710
3118
 
2711
- #isInsideBlockquote(node) {
2712
- let currentNode = node;
3119
+ static importDOM() {
3120
+ return {
3121
+ div: (element) => {
3122
+ const containsAttachment = element.querySelector(`:scope > :is(${this.#attachmentTags.join()})`);
3123
+ if (!containsAttachment) return null
2713
3124
 
2714
- while (currentNode) {
2715
- if ($isQuoteNode(currentNode)) {
2716
- return true
3125
+ return {
3126
+ conversion: () => {
3127
+ return {
3128
+ node: $createImageGalleryNode(),
3129
+ after: children => $descendantsMatching(children, this.isValidChild)
3130
+ }
3131
+ },
3132
+ priority: 2
3133
+ }
2717
3134
  }
2718
- currentNode = currentNode.getParent();
2719
3135
  }
2720
-
2721
- return false
2722
3136
  }
2723
3137
 
2724
- #shouldEscapeFromEmptyListItem(node) {
2725
- const listItem = this.#getListItemNode(node);
2726
- if (!listItem) return false
2727
-
2728
- return this.#isNodeEmpty(listItem)
3138
+ static canCollapseWith(node) {
3139
+ return $isImageGalleryNode(node) || this.isValidChild(node)
2729
3140
  }
2730
3141
 
2731
- #shouldEscapeFromEmptyParagraphInListItem(node) {
2732
- const paragraph = this.#getParagraphNode(node);
2733
- if (!paragraph) return false
3142
+ static isValidChild(node) {
3143
+ return $isActionTextAttachmentNode(node) && node.isPreviewableImage
3144
+ }
2734
3145
 
2735
- if (!this.#isNodeEmpty(paragraph)) return false
3146
+ static get #attachmentTags() {
3147
+ return Object.keys(ActionTextAttachmentNode.importDOM())
3148
+ }
2736
3149
 
2737
- const parent = paragraph.getParent();
2738
- return parent && $isListItemNode(parent)
3150
+ createDOM() {
3151
+ const div = document.createElement("div");
3152
+ div.className = this.#galleryClassNames;
3153
+ return div
2739
3154
  }
2740
3155
 
2741
- #isNodeEmpty(node) {
2742
- if (node.getTextContent().trim() !== "") return false
3156
+ updateDOM(_prevNode, dom) {
3157
+ dom.className = this.#galleryClassNames;
3158
+ return false
3159
+ }
2743
3160
 
2744
- const children = node.getChildren();
2745
- if (children.length === 0) return true
3161
+ canBeEmpty() {
3162
+ // Return `true` to conform to `$isBlock(node)`
3163
+ // We clean-up empty galleries with a transform
3164
+ return true
3165
+ }
2746
3166
 
2747
- return children.every(child => {
2748
- if ($isLineBreakNode(child)) return true
2749
- return this.#isNodeEmpty(child)
2750
- })
3167
+ collapseAtStart(_selection) {
3168
+ return true
2751
3169
  }
2752
3170
 
2753
- #getListItemNode(node) {
2754
- let currentNode = node;
3171
+ insertNewAfter(selection, restoreSelection) {
3172
+ const selectionBeforeLastChild = selection.anchor.getNode().is(this) && selection.anchor.offset == this.getChildrenSize() - 1;
3173
+ if (selectionBeforeLastChild) {
3174
+ const paragraph = $createParagraphNode();
3175
+ this.insertAfter(paragraph, false);
3176
+ paragraph.insertAfter(this.getLastChild(), false);
3177
+ paragraph.selectEnd();
2755
3178
 
2756
- while (currentNode) {
2757
- if ($isListItemNode(currentNode)) {
2758
- return currentNode
2759
- }
2760
- currentNode = currentNode.getParent();
3179
+ // return null as selection has been managed
3180
+ return null
2761
3181
  }
2762
3182
 
2763
- return null
3183
+ const newNode = $createImageGalleryNode();
3184
+ this.insertAfter(newNode, restoreSelection);
3185
+ return newNode
2764
3186
  }
2765
3187
 
2766
- #escapeFromList(anchorNode) {
2767
- const listItem = this.#getListItemNode(anchorNode);
2768
- if (!listItem) return
2769
-
2770
- const parentList = listItem.getParent();
2771
- if (!parentList || !$isListNode(parentList)) return
2772
-
2773
- const blockquote = parentList.getParent();
2774
- const isInBlockquote = blockquote && $isQuoteNode(blockquote);
2775
-
2776
- if (isInBlockquote) {
2777
- const listItemsAfter = this.#getListItemSiblingsAfter(listItem);
2778
- const nonEmptyListItems = listItemsAfter.filter(item => !this.#isNodeEmpty(item));
2779
-
2780
- if (nonEmptyListItems.length > 0) {
2781
- this.#splitBlockquoteWithList(blockquote, parentList, listItem, nonEmptyListItems);
2782
- return
2783
- }
2784
- }
2785
-
2786
- const paragraph = $createParagraphNode();
2787
- parentList.insertAfter(paragraph);
3188
+ getImageAttachments() {
3189
+ const children = this.getChildren();
3190
+ return children.filter($isActionTextAttachmentNode)
3191
+ }
2788
3192
 
2789
- listItem.remove();
2790
- paragraph.selectStart();
3193
+ exportDOM() {
3194
+ const div = document.createElement("div");
3195
+ div.className = this.#galleryClassNames;
3196
+ return { element: div }
2791
3197
  }
2792
3198
 
2793
- #shouldEscapeFromEmptyParagraphInBlockquote(node) {
2794
- const paragraph = this.#getParagraphNode(node);
2795
- if (!paragraph) return false
3199
+ collapseWith(node, backwards) {
3200
+ if (!ImageGalleryNode.canCollapseWith(node)) return false
2796
3201
 
2797
- if (!this.#isNodeEmpty(paragraph)) return false
3202
+ if (backwards) {
3203
+ $insertFirst(this, node);
3204
+ } else {
3205
+ this.append(node);
3206
+ }
2798
3207
 
2799
- const parent = paragraph.getParent();
2800
- return parent && $isQuoteNode(parent)
2801
- }
3208
+ $unwrapAndFilterDescendants(this, ImageGalleryNode.isValidChild);
2802
3209
 
2803
- #getParagraphNode(node) {
2804
- let currentNode = node;
3210
+ return true
3211
+ }
2805
3212
 
2806
- while (currentNode) {
2807
- if ($isParagraphNode(currentNode)) {
2808
- return currentNode
2809
- }
2810
- currentNode = currentNode.getParent();
3213
+ unwrapEmptyNode() {
3214
+ if (this.isEmpty()) {
3215
+ const paragraph = $createParagraphNode();
3216
+ return this.replace(paragraph)
2811
3217
  }
3218
+ }
2812
3219
 
2813
- return null
3220
+ replaceWithSingularChild() {
3221
+ if (this.#hasSingularChild) {
3222
+ const child = this.getFirstChild();
3223
+ return this.replace(child)
3224
+ }
2814
3225
  }
2815
3226
 
2816
- #escapeFromBlockquote(anchorNode) {
2817
- const paragraph = this.#getParagraphNode(anchorNode);
2818
- if (!paragraph) return
3227
+ splitAroundInvalidChild() {
3228
+ for (const child of $firstToLastIterator(this)) {
3229
+ if (ImageGalleryNode.isValidChild(child)) continue
2819
3230
 
2820
- const blockquote = paragraph.getParent();
2821
- if (!blockquote || !$isQuoteNode(blockquote)) return
3231
+ const poppedNode = $makeSafeForRoot(child);
3232
+ const [ topGallery, secondGallery ] = this.splitAtIndex(poppedNode.getIndexWithinParent());
3233
+ topGallery.insertAfter(poppedNode);
3234
+ poppedNode.selectEnd();
2822
3235
 
2823
- const siblingsAfter = this.#getSiblingsAfter(paragraph);
2824
- const nonEmptySiblings = siblingsAfter.filter(sibling => !this.#isNodeEmpty(sibling));
3236
+ // remove an empty gallery rather than let it unwrap to a paragraph
3237
+ if (secondGallery.isEmpty()) secondGallery.remove();
2825
3238
 
2826
- if (nonEmptySiblings.length > 0) {
2827
- this.#splitBlockquote(blockquote, paragraph, nonEmptySiblings);
2828
- } else {
2829
- const newParagraph = $createParagraphNode();
2830
- blockquote.insertAfter(newParagraph);
2831
- paragraph.remove();
2832
- newParagraph.selectStart();
3239
+ break
2833
3240
  }
2834
3241
  }
2835
3242
 
2836
- #getSiblingsAfter(node) {
2837
- const siblings = [];
2838
- let sibling = node.getNextSibling();
3243
+ splitAtIndex(index) {
3244
+ return $splitNode(this, index)
3245
+ }
2839
3246
 
2840
- while (sibling) {
2841
- siblings.push(sibling);
2842
- sibling = sibling.getNextSibling();
2843
- }
3247
+ get #hasSingularChild() {
3248
+ return this.getChildrenSize() === 1
3249
+ }
2844
3250
 
2845
- return siblings
3251
+ get #galleryClassNames() {
3252
+ return `attachment-gallery attachment-gallery--${this.getChildrenSize()}`
2846
3253
  }
3254
+ }
2847
3255
 
2848
- #getListItemSiblingsAfter(listItem) {
2849
- const siblings = [];
2850
- let sibling = listItem.getNextSibling();
3256
+ function $createImageGalleryNode() {
3257
+ return new ImageGalleryNode()
3258
+ }
2851
3259
 
2852
- while (sibling) {
2853
- if ($isListItemNode(sibling)) {
2854
- siblings.push(sibling);
2855
- }
2856
- sibling = sibling.getNextSibling();
2857
- }
3260
+ function $isImageGalleryNode(node) {
3261
+ return node instanceof ImageGalleryNode
3262
+ }
2858
3263
 
2859
- return siblings
2860
- }
3264
+ function $findOrCreateGalleryForImage(node) {
3265
+ if (!ImageGalleryNode.canCollapseWith(node)) return null
2861
3266
 
2862
- #splitBlockquoteWithList(blockquote, parentList, emptyListItem, listItemsAfter) {
2863
- const blockquoteSiblingsAfterList = this.#getSiblingsAfter(parentList);
2864
- const nonEmptyBlockquoteSiblings = blockquoteSiblingsAfterList.filter(sibling => !this.#isNodeEmpty(sibling));
3267
+ const existingGallery = $getNearestNodeOfType(node, ImageGalleryNode);
3268
+ return existingGallery ?? $wrapNodeInElement(node, $createImageGalleryNode)
3269
+ }
2865
3270
 
2866
- const middleParagraph = $createParagraphNode();
2867
- blockquote.insertAfter(middleParagraph);
3271
+ class Uploader {
3272
+ #files
2868
3273
 
2869
- const newList = $createListNode(parentList.getListType());
3274
+ static for(editorElement, files) {
3275
+ const UploaderKlass = GalleryUploader.handle(editorElement, files) ? GalleryUploader : Uploader;
3276
+ return new UploaderKlass(editorElement, files)
3277
+ }
2870
3278
 
2871
- const newBlockquote = $createQuoteNode();
2872
- middleParagraph.insertAfter(newBlockquote);
2873
- newBlockquote.append(newList);
3279
+ constructor(editorElement, files) {
3280
+ this.#files = files;
2874
3281
 
2875
- listItemsAfter.forEach(item => {
2876
- newList.append(item);
2877
- });
3282
+ this.editorElement = editorElement;
3283
+ this.contents = editorElement.contents;
3284
+ this.selection = editorElement.selection;
3285
+ }
2878
3286
 
2879
- nonEmptyBlockquoteSiblings.forEach(sibling => {
2880
- newBlockquote.append(sibling);
2881
- });
3287
+ get files() {
3288
+ return Array.from(this.#files)
3289
+ }
2882
3290
 
2883
- emptyListItem.remove();
3291
+ $uploadFiles() {
3292
+ this.$createUploadNodes();
3293
+ this.$insertUploadNodes();
3294
+ }
2884
3295
 
2885
- this.#removeTrailingEmptyListItems(parentList);
2886
- this.#removeTrailingEmptyNodes(newBlockquote);
3296
+ $createUploadNodes() {
3297
+ this.nodes = this.files.map(file =>
3298
+ $createActionTextAttachmentUploadNode({
3299
+ ...this.#nodeUrlProperties,
3300
+ file: file,
3301
+ contentType: file.type
3302
+ })
3303
+ );
3304
+ }
2887
3305
 
2888
- if (parentList.getChildrenSize() === 0) {
2889
- parentList.remove();
3306
+ $insertUploadNodes() {
3307
+ this.nodes.forEach(this.contents.insertAtCursor);
3308
+ }
2890
3309
 
2891
- if (blockquote.getChildrenSize() === 0) {
2892
- blockquote.remove();
2893
- }
2894
- } else {
2895
- this.#removeTrailingEmptyNodes(blockquote);
3310
+ get #nodeUrlProperties() {
3311
+ return {
3312
+ uploadUrl: this.editorElement.directUploadUrl,
3313
+ blobUrlTemplate: this.editorElement.blobUrlTemplate
2896
3314
  }
3315
+ }
3316
+ }
2897
3317
 
2898
- middleParagraph.selectStart();
3318
+ class GalleryUploader extends Uploader {
3319
+ #gallery
3320
+
3321
+ static handle(editorElement, files) {
3322
+ return this.#isMultipleImageUpload(files) || this.#gallerySelection(editorElement.selection)
2899
3323
  }
2900
3324
 
2901
- #removeTrailingEmptyListItems(list) {
2902
- const items = list.getChildren();
2903
- for (let i = items.length - 1; i >= 0; i--) {
2904
- const item = items[i];
2905
- if ($isListItemNode(item) && this.#isNodeEmpty(item)) {
2906
- item.remove();
2907
- } else {
2908
- break
2909
- }
3325
+ static #isMultipleImageUpload(files) {
3326
+ let imageFileCount = 0;
3327
+ for (const file of files) {
3328
+ if (isPreviewableImage(file.type)) imageFileCount++;
3329
+ if (imageFileCount > 1) return true
2910
3330
  }
3331
+ return false
2911
3332
  }
2912
3333
 
2913
- #removeTrailingEmptyNodes(blockquote) {
2914
- const children = blockquote.getChildren();
2915
- for (let i = children.length - 1; i >= 0; i--) {
2916
- const child = children[i];
2917
- if (this.#isNodeEmpty(child)) {
2918
- child.remove();
2919
- } else {
2920
- break
2921
- }
3334
+ static #gallerySelection(selection) {
3335
+ if (selection.isOnPreviewableImage) return true
3336
+
3337
+ const { node: selectedNode } = selection.selectedNodeWithOffset();
3338
+ return $getNearestNodeOfType(selectedNode, ImageGalleryNode) !== null
3339
+ }
3340
+
3341
+ $insertUploadNodes() {
3342
+ this.#findOrCreateGallery();
3343
+ this.#insertImagesInGallery();
3344
+ this.#insertNonImagesAfterGallery();
3345
+ }
3346
+
3347
+ #findOrCreateGallery() {
3348
+ if (this.selection.isOnPreviewableImage) {
3349
+ this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
3350
+ } else {
3351
+ this.#gallery = $createImageGalleryNode();
3352
+ this.contents.insertAtCursor(this.#gallery);
2922
3353
  }
2923
3354
  }
2924
3355
 
2925
- #splitBlockquote(blockquote, emptyParagraph, siblingsAfter) {
2926
- const newParagraph = $createParagraphNode();
2927
- blockquote.insertAfter(newParagraph);
3356
+ get #selectedNode() {
3357
+ const { node } = this.selection.selectedNodeWithOffset();
3358
+ return node
3359
+ }
2928
3360
 
2929
- const newBlockquote = $createQuoteNode();
2930
- newParagraph.insertAfter(newBlockquote);
3361
+ get #galleryInsertPosition() {
3362
+ const anchor = $getSelection()?.anchor;
3363
+ const galleryHasElementSelection = anchor?.getNode().is(this.#gallery);
3364
+ if (galleryHasElementSelection) return anchor.offset
2931
3365
 
2932
- siblingsAfter.forEach(sibling => {
2933
- newBlockquote.append(sibling);
2934
- });
3366
+ const selectedNode = this.#selectedNode;
3367
+ const childIndex = this.#gallery.isParentOf(selectedNode) && selectedNode.getIndexWithinParent();
3368
+ return childIndex !== false ? (childIndex + 1) : 0
3369
+ }
2935
3370
 
2936
- emptyParagraph.remove();
3371
+ get #imageNodes() {
3372
+ return this.nodes.filter(node => ImageGalleryNode.isValidChild(node))
3373
+ }
2937
3374
 
2938
- this.#removeTrailingEmptyNodes(blockquote);
2939
- this.#removeTrailingEmptyNodes(newBlockquote);
3375
+ get #nonImageNodes() {
3376
+ return this.nodes.filter(node => !ImageGalleryNode.isValidChild(node))
3377
+ }
2940
3378
 
2941
- newParagraph.selectStart();
3379
+ #insertImagesInGallery() {
3380
+ this.#gallery.splice(this.#galleryInsertPosition, 0, this.#imageNodes);
3381
+ }
3382
+
3383
+ #insertNonImagesAfterGallery() {
3384
+ let beforeNode = this.#gallery;
3385
+
3386
+ for (const node of this.#nonImageNodes) {
3387
+ beforeNode.insertAfter(node);
3388
+ beforeNode = node;
3389
+ }
2942
3390
  }
2943
3391
  }
2944
3392
 
@@ -2951,29 +3399,34 @@ class Contents {
2951
3399
  }
2952
3400
 
2953
3401
  insertHtml(html, { tag } = {}) {
3402
+ this.insertDOM(parseHtml(html), { tag });
3403
+ }
3404
+
3405
+ insertDOM(doc, { tag } = {}) {
2954
3406
  this.editor.update(() => {
2955
3407
  const selection = $getSelection();
2956
3408
  if (!$isRangeSelection(selection)) return
2957
3409
 
2958
- const nodes = $generateNodesFromDOM(this.editor, parseHtml(html));
2959
- selection.insertNodes(nodes);
3410
+ const nodes = $generateNodesFromDOM(this.editor, doc);
3411
+ if (!this.#insertUploadNodes(nodes)) {
3412
+ selection.insertNodes(nodes);
3413
+ }
2960
3414
  }, { tag });
2961
3415
  }
2962
3416
 
2963
3417
  insertAtCursor(node) {
2964
- const selection = $getSelection();
3418
+ const selection = $getSelection() ?? $getRoot().selectEnd();
2965
3419
  const selectedNodes = selection?.getNodes();
2966
3420
 
2967
3421
  if ($isRangeSelection(selection)) {
2968
- $insertNodes([ node ]);
2969
- } else if ($isNodeSelection(selection) && selectedNodes && selectedNodes.length > 0) {
3422
+ selection.insertNodes([ node ]);
3423
+ } else if ($isNodeSelection(selection) && selectedNodes.length > 0) {
3424
+ // Overrides Lexical's default behavior of _removing_ the currently selected nodes
3425
+ // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
2970
3426
  const lastNode = selectedNodes.at(-1);
2971
3427
  lastNode.insertAfter(node);
2972
- } else {
2973
- const root = $getRoot();
2974
- root.append(node);
2975
3428
  }
2976
- }
3429
+ }
2977
3430
 
2978
3431
  insertAtCursorEnsuringLineBelow(node) {
2979
3432
  this.insertAtCursor(node);
@@ -3090,6 +3543,7 @@ class Contents {
3090
3543
  if (!this.hasSelectedText()) return
3091
3544
 
3092
3545
  this.editor.update(() => {
3546
+ $toggleLink(null);
3093
3547
  $toggleLink(url);
3094
3548
  });
3095
3549
  }
@@ -3181,23 +3635,22 @@ class Contents {
3181
3635
  }
3182
3636
  }
3183
3637
 
3184
- uploadFile(file) {
3638
+ uploadFiles(files, { selectLast } = {}) {
3185
3639
  if (!this.editorElement.supportsAttachments) {
3186
3640
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
3187
3641
  return
3188
3642
  }
3189
-
3190
- if (!this.#shouldUploadFile(file)) {
3191
- return
3192
- }
3193
-
3194
- const uploadUrl = this.editorElement.directUploadUrl;
3195
- const blobUrlTemplate = this.editorElement.blobUrlTemplate;
3643
+ const validFiles = Array.from(files).filter(this.#shouldUploadFile.bind(this));
3196
3644
 
3197
3645
  this.editor.update(() => {
3198
- const uploadedImageNode = new ActionTextAttachmentUploadNode({ file: file, uploadUrl: uploadUrl, blobUrlTemplate: blobUrlTemplate });
3199
- this.insertAtCursor(uploadedImageNode);
3200
- }, { tag: HISTORY_MERGE_TAG });
3646
+ const uploader = Uploader.for(this.editorElement, validFiles);
3647
+ uploader.$uploadFiles();
3648
+
3649
+ if (selectLast && uploader.nodes?.length) {
3650
+ const lastNode = uploader.nodes.at(-1);
3651
+ lastNode.selectEnd();
3652
+ }
3653
+ });
3201
3654
  }
3202
3655
 
3203
3656
  replaceNodeWithHTML(nodeKey, html, options = {}) {
@@ -3238,6 +3691,15 @@ class Contents {
3238
3691
  });
3239
3692
  }
3240
3693
 
3694
+ #insertUploadNodes(nodes) {
3695
+ if (nodes.every($isActionTextAttachmentNode)) {
3696
+ const uploader = Uploader.for(this.editorElement, []);
3697
+ uploader.nodes = nodes;
3698
+ uploader.$insertUploadNodes();
3699
+ return true
3700
+ }
3701
+ }
3702
+
3241
3703
  #insertLineBelowIfLastNode(node) {
3242
3704
  this.editor.update(() => {
3243
3705
  const nextSibling = node.getNextSibling();
@@ -3357,7 +3819,7 @@ class Contents {
3357
3819
  wrappingNode.append(...topLevelElement.getChildren());
3358
3820
  topLevelElement.replace(wrappingNode);
3359
3821
  } else {
3360
- $insertNodes([ newNodeFn() ]);
3822
+ selection.insertNodes([ newNodeFn() ]);
3361
3823
  }
3362
3824
  }
3363
3825
 
@@ -3617,15 +4079,15 @@ class Clipboard {
3617
4079
  paste(event) {
3618
4080
  const clipboardData = event.clipboardData;
3619
4081
 
3620
- if (!clipboardData) return false
4082
+ if (!clipboardData || this.#isPastingIntoCodeBlock()) return false
3621
4083
 
3622
- if (this.#isPlainTextOrURLPasted(clipboardData) && !this.#isPastingIntoCodeBlock()) {
4084
+ if (this.#isPlainTextOrURLPasted(clipboardData)) {
3623
4085
  this.#pastePlainText(clipboardData);
3624
4086
  event.preventDefault();
3625
4087
  return true
3626
4088
  }
3627
4089
 
3628
- this.#handlePastedFiles(clipboardData);
4090
+ return this.#handlePastedFiles(clipboardData)
3629
4091
  }
3630
4092
 
3631
4093
  #isPlainTextOrURLPasted(clipboardData) {
@@ -3694,7 +4156,15 @@ class Clipboard {
3694
4156
 
3695
4157
  #pasteMarkdown(text) {
3696
4158
  const html = marked(text);
3697
- this.contents.insertHtml(html, { tag: [ PASTE_TAG ] });
4159
+ const doc = parseHtml(html);
4160
+ const detail = Object.freeze({
4161
+ markdown: text,
4162
+ document: doc,
4163
+ addBlockSpacing: () => addBlockSpacing(doc)
4164
+ });
4165
+
4166
+ dispatch(this.editorElement, "lexxy:insert-markdown", detail);
4167
+ this.contents.insertDOM(doc, { tag: PASTE_TAG });
3698
4168
  }
3699
4169
 
3700
4170
  #pasteRichText(clipboardData) {
@@ -3705,19 +4175,22 @@ class Clipboard {
3705
4175
  }
3706
4176
 
3707
4177
  #handlePastedFiles(clipboardData) {
3708
- if (!this.editorElement.supportsAttachments) return
4178
+ if (!this.editorElement.supportsAttachments) return false
3709
4179
 
3710
4180
  const html = clipboardData.getData("text/html");
3711
- if (html) return // Ignore if image copied from browser since we will load it as a remote image
4181
+ if (html) {
4182
+ this.contents.insertHtml(html, { tag: PASTE_TAG });
4183
+ return true
4184
+ }
3712
4185
 
3713
4186
  this.#preservingScrollPosition(() => {
3714
- for (const item of clipboardData.items) {
3715
- const file = item.getAsFile();
3716
- if (!file) continue
3717
-
3718
- this.contents.uploadFile(file);
4187
+ const files = clipboardData.files;
4188
+ if (files.length) {
4189
+ this.contents.uploadFiles(files, { selectLast: true });
3719
4190
  }
3720
4191
  });
4192
+
4193
+ return true
3721
4194
  }
3722
4195
 
3723
4196
  // Deals with an issue in Safari where it scrolls to the tops after pasting attachments
@@ -4091,6 +4564,93 @@ class TablesExtension extends LexxyExtension {
4091
4564
  }
4092
4565
  }
4093
4566
 
4567
+ class AttachmentsExtension extends LexxyExtension {
4568
+ get enabled() {
4569
+ return this.editorElement.supportsAttachments
4570
+ }
4571
+
4572
+ get lexicalExtension() {
4573
+ return defineExtension({
4574
+ name: "lexxy/action-text-attachments",
4575
+ nodes: [
4576
+ ActionTextAttachmentNode,
4577
+ ActionTextAttachmentUploadNode,
4578
+ ImageGalleryNode
4579
+ ],
4580
+ register(editor) {
4581
+ return mergeRegister(
4582
+ editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL)
4583
+ )
4584
+ }
4585
+ })
4586
+ }
4587
+ }
4588
+
4589
+ function $collapseIntoGallery(backwards) {
4590
+ const anchor = $getSelection()?.anchor;
4591
+ if (!anchor) return false
4592
+
4593
+ if ($collapseAtGalleryEdge(anchor, backwards)) {
4594
+ return true
4595
+ } else if (backwards) {
4596
+ return $collapseAroundEmptyParagraph(anchor)
4597
+ || $moveSelectionBeforeGallery(anchor)
4598
+ }
4599
+
4600
+ return false
4601
+ }
4602
+
4603
+ function $collapseAroundEmptyParagraph(anchor) {
4604
+ const anchorNode = anchor.getNode();
4605
+ if (!anchorNode) return false
4606
+
4607
+ const isWithinEmptyParagraph = $isParagraphNode(anchorNode) && anchorNode.isEmpty();
4608
+ const previousSibling = anchorNode.getPreviousSibling();
4609
+ const topGallery = $findOrCreateGalleryForImage(previousSibling);
4610
+ const selectionIndex = topGallery?.getChildrenSize();
4611
+
4612
+ if (isWithinEmptyParagraph && topGallery?.collapseWith(anchorNode.getNextSibling())) {
4613
+ topGallery.select(selectionIndex, selectionIndex);
4614
+ anchorNode.remove();
4615
+ return true
4616
+ } else {
4617
+ return false
4618
+ }
4619
+ }
4620
+
4621
+ function $collapseAtGalleryEdge(anchor, backwards) {
4622
+ const anchorNode = anchor.getNode();
4623
+ if (!$isImageGalleryNode(anchorNode)) return false
4624
+
4625
+ const isAtGalleryEdge = $isAtNodeEdge(anchor, backwards);
4626
+ const sibling = backwards ? anchorNode.getPreviousSibling() : anchorNode.getNextSibling();
4627
+
4628
+ if (isAtGalleryEdge && anchorNode.collapseWith(sibling, backwards)) {
4629
+ const selectionOffset = backwards ? 1 : anchorNode.getChildrenSize() - 1;
4630
+ anchorNode.select(selectionOffset, selectionOffset);
4631
+ return true
4632
+ } else {
4633
+ return false
4634
+ }
4635
+ }
4636
+
4637
+ // Manual selection handling to prevent Lexical merging the gallery with a <p> and unwrapping it
4638
+ function $moveSelectionBeforeGallery(anchor) {
4639
+ const previousNode = anchor.getNode().getPreviousSibling();
4640
+ if (!$isImageGalleryNode(anchor.getNode()) || !$isAtNodeEdge(anchor, true) || !previousNode) return false
4641
+
4642
+ if ($isDecoratorNode(previousNode)) {
4643
+ // Handled by Lexxy decorator selection behavior
4644
+ return false
4645
+ } else if (previousNode.isEmpty()) {
4646
+ previousNode.remove();
4647
+ } else {
4648
+ previousNode.selectEnd();
4649
+ }
4650
+
4651
+ return true
4652
+ }
4653
+
4094
4654
  class LexicalEditorElement extends HTMLElement {
4095
4655
  static formAssociated = true
4096
4656
  static debug = false
@@ -4180,7 +4740,8 @@ class LexicalEditorElement extends HTMLElement {
4180
4740
  ProvisionalParagraphExtension,
4181
4741
  HighlightExtension,
4182
4742
  TrixContentExtension,
4183
- TablesExtension
4743
+ TablesExtension,
4744
+ AttachmentsExtension
4184
4745
  ]
4185
4746
  }
4186
4747
 
@@ -4340,10 +4901,6 @@ class LexicalEditorElement extends HTMLElement {
4340
4901
  );
4341
4902
  }
4342
4903
 
4343
- if (this.supportsAttachments) {
4344
- nodes.push(ActionTextAttachmentNode, ActionTextAttachmentUploadNode);
4345
- }
4346
-
4347
4904
  return nodes
4348
4905
  }
4349
4906
 
@@ -5129,16 +5686,16 @@ class LexicalPromptElement extends HTMLElement {
5129
5686
 
5130
5687
  #registerKeyListeners() {
5131
5688
  // We can't use a regular keydown for Enter as Lexical handles it first
5132
- this.keyListeners.push(this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
5133
- this.keyListeners.push(this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
5689
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
5690
+ this.keyListeners.push(this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
5134
5691
 
5135
5692
  if (this.#doesSpaceSelect) {
5136
- this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
5693
+ this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
5137
5694
  }
5138
5695
 
5139
- // Register arrow keys with HIGH priority to prevent Lexical's selection handlers from running
5140
- this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_HIGH));
5141
- this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_HIGH));
5696
+ // Register arrow keys with CRITICAL priority to prevent Lexical's selection handlers from running
5697
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_CRITICAL));
5698
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_CRITICAL));
5142
5699
  }
5143
5700
 
5144
5701
  #handleArrowUp(event) {
@@ -5412,11 +5969,18 @@ class CodeLanguagePicker extends HTMLElement {
5412
5969
  connectedCallback() {
5413
5970
  this.editorElement = this.closest("lexxy-editor");
5414
5971
  this.editor = this.editorElement.editor;
5972
+ this.classList.add("lexxy-floating-controls");
5415
5973
 
5416
5974
  this.#attachLanguagePicker();
5975
+ this.#hide();
5417
5976
  this.#monitorForCodeBlockSelection();
5418
5977
  }
5419
5978
 
5979
+ disconnectedCallback() {
5980
+ this.unregisterUpdateListener?.();
5981
+ this.unregisterUpdateListener = null;
5982
+ }
5983
+
5420
5984
  #attachLanguagePicker() {
5421
5985
  this.languagePickerElement = this.#createLanguagePicker();
5422
5986
 
@@ -5471,14 +6035,14 @@ class CodeLanguagePicker extends HTMLElement {
5471
6035
  }
5472
6036
 
5473
6037
  #monitorForCodeBlockSelection() {
5474
- this.editor.registerUpdateListener(() => {
6038
+ this.unregisterUpdateListener = this.editor.registerUpdateListener(() => {
5475
6039
  this.editor.getEditorState().read(() => {
5476
6040
  const codeNode = this.#getCurrentCodeNode();
5477
6041
 
5478
6042
  if (codeNode) {
5479
6043
  this.#codeNodeWasSelected(codeNode);
5480
6044
  } else {
5481
- this.#hideLanguagePicker();
6045
+ this.#hide();
5482
6046
  }
5483
6047
  });
5484
6048
  });
@@ -5507,7 +6071,7 @@ class CodeLanguagePicker extends HTMLElement {
5507
6071
  const language = codeNode.getLanguage();
5508
6072
 
5509
6073
  this.#updateLanguagePickerWith(language);
5510
- this.#showLanguagePicker();
6074
+ this.#show();
5511
6075
  this.#positionLanguagePicker(codeNode);
5512
6076
  }
5513
6077
 
@@ -5531,15 +6095,66 @@ class CodeLanguagePicker extends HTMLElement {
5531
6095
  this.style.right = `${relativeRight}px`;
5532
6096
  }
5533
6097
 
5534
- #showLanguagePicker() {
6098
+ #show() {
5535
6099
  this.hidden = false;
5536
6100
  }
5537
6101
 
5538
- #hideLanguagePicker() {
6102
+ #hide() {
5539
6103
  this.hidden = true;
5540
6104
  }
5541
6105
  }
5542
6106
 
6107
+ const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
6108
+ <path d="M11.2041 1.01074C12.2128 1.113 13 1.96435 13 3V4H15L15.1025 4.00488C15.6067 4.05621 16 4.48232 16 5C16 5.55228 15.5523 6 15 6H14.8457L14.1416 15.1533C14.0614 16.1953 13.1925 17 12.1475 17H5.85254L5.6582 16.9902C4.76514 16.9041 4.03607 16.2296 3.88184 15.3457L3.8584 15.1533L3.1543 6H3C2.44772 6 2 5.55228 2 5C2 4.44772 2.44772 4 3 4H5V3C5 1.89543 5.89543 1 7 1H11L11.2041 1.01074ZM5.85254 15H12.1475L12.8398 6H5.16016L5.85254 15ZM7 4H11V3H7V4Z"/>
6109
+ </svg>`;
6110
+
6111
+ class NodeDeleteButton extends HTMLElement {
6112
+ connectedCallback() {
6113
+ this.editorElement = this.closest("lexxy-editor");
6114
+ this.editor = this.editorElement.editor;
6115
+ this.classList.add("lexxy-floating-controls");
6116
+
6117
+ if (!this.deleteButton) {
6118
+ this.#attachDeleteButton();
6119
+ }
6120
+ }
6121
+
6122
+ disconnectedCallback() {
6123
+ if (this.deleteButton && this.handleDeleteClick) {
6124
+ this.deleteButton.removeEventListener("click", this.handleDeleteClick);
6125
+ }
6126
+
6127
+ this.handleDeleteClick = null;
6128
+ this.deleteButton = null;
6129
+ this.editor = null;
6130
+ this.editorElement = null;
6131
+ }
6132
+ #attachDeleteButton() {
6133
+ const container = createElement("div", { className: "lexxy-floating-controls__group" });
6134
+
6135
+ this.deleteButton = createElement("button", {
6136
+ className: "lexxy-node-delete",
6137
+ type: "button",
6138
+ "aria-label": "Remove"
6139
+ });
6140
+ this.deleteButton.tabIndex = -1;
6141
+ this.deleteButton.innerHTML = DELETE_ICON;
6142
+
6143
+ this.handleDeleteClick = () => this.#deleteNode();
6144
+ this.deleteButton.addEventListener("click", this.handleDeleteClick);
6145
+ container.appendChild(this.deleteButton);
6146
+
6147
+ this.appendChild(container);
6148
+ }
6149
+
6150
+ #deleteNode() {
6151
+ this.editor.update(() => {
6152
+ const node = $getNearestNodeFromDOMNode(this);
6153
+ node?.remove();
6154
+ });
6155
+ }
6156
+ }
6157
+
5543
6158
  class TableController {
5544
6159
  constructor(editorElement) {
5545
6160
  this.editor = editorElement.editor;
@@ -5931,20 +6546,22 @@ var TableIcons = {
5931
6546
 
5932
6547
  "toggle-column":
5933
6548
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5934
- <path fill-rule="evenodd" clip-rule="evenodd" d="M13.6533 17.9922C14.4097 17.9154 15 17.2767 15 16.5L15 3L14.9922 2.84668C14.9205 2.14069 14.3593 1.57949 13.6533 1.50781L13.5 1.5L4.5 1.5L4.34668 1.50781C3.59028 1.58461 3 2.22334 3 3L3 16.5C3 17.2767 3.59028 17.9154 4.34668 17.9922L4.5 18L13.5 18L13.6533 17.9922ZM9 3L13.5 3L13.5 16.5L9 16.5L9 3Z" />
6549
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M13.6533 17.9922C14.4097 17.9154 15 17.2767 15 16.5L15 3L14.9922 2.84668C14.9205 2.14069 14.3593 1.57949 13.6533 1.50781L13.5 1.5L4.5 1.5L4.34668 1.50781C3.59028 1.58461 3 2.22334 3 3L3 16.5C3 17.2767 3.59028 17.9154 4.34668 17.9922L4.5 18L13.5 18L13.6533 17.9922ZM9 3L13.5 3L13.5 16.5L9 16.5L9 3Z"/>
5935
6550
  </svg>`,
5936
6551
 
5937
6552
  "delete-table":
5938
- `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
5939
- <path d="M18.2129 19.2305C18.0925 20.7933 16.7892 22 15.2217 22H7.77832C6.21084 22 4.90753 20.7933 4.78711 19.2305L4 9H19L18.2129 19.2305Z"/><path d="M13 2C14.1046 2 15 2.89543 15 4H19C19.5523 4 20 4.44772 20 5V6C20 6.55228 19.5523 7 19 7H4C3.44772 7 3 6.55228 3 6V5C3 4.44772 3.44772 4 4 4H8C8 2.89543 8.89543 2 10 2H13Z"/>
6553
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
6554
+ <path d="M11.2041 1.01074C12.2128 1.113 13 1.96435 13 3V4H15L15.1025 4.00488C15.6067 4.05621 16 4.48232 16 5C16 5.55228 15.5523 6 15 6H14.8457L14.1416 15.1533C14.0614 16.1953 13.1925 17 12.1475 17H5.85254L5.6582 16.9902C4.76514 16.9041 4.03607 16.2296 3.88184 15.3457L3.8584 15.1533L3.1543 6H3C2.44772 6 2 5.55228 2 5C2 4.44772 2.44772 4 3 4H5V3C5 1.89543 5.89543 1 7 1H11L11.2041 1.01074ZM5.85254 15H12.1475L12.8398 6H5.16016L5.85254 15ZM7 4H11V3H7V4Z"/>
5940
6555
  </svg>`
5941
6556
  };
5942
6557
 
5943
6558
  class TableTools extends HTMLElement {
5944
6559
  connectedCallback() {
5945
6560
  this.tableController = new TableController(this.#editorElement);
6561
+ this.classList.add("lexxy-floating-controls");
5946
6562
 
5947
6563
  this.#setUpButtons();
6564
+ this.#hide();
5948
6565
  this.#monitorForTableSelection();
5949
6566
  this.#registerKeyboardShortcuts();
5950
6567
  }
@@ -5982,7 +6599,7 @@ class TableTools extends HTMLElement {
5982
6599
  }
5983
6600
 
5984
6601
  #createButtonsContainer(childType, setCountProperty, moreMenu) {
5985
- const container = createElement("div", { className: `lexxy-table-control lexxy-table-control--${childType}` });
6602
+ const container = createElement("div", { className: `lexxy-floating-controls__group lexxy-table-control lexxy-table-control--${childType}` });
5986
6603
 
5987
6604
  const plusButton = this.#createButton(`Add ${childType}`, { action: "insert", childType, direction: "after" }, "+");
5988
6605
  const minusButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType }, "−");
@@ -6021,7 +6638,7 @@ class TableTools extends HTMLElement {
6021
6638
  }
6022
6639
 
6023
6640
  #createMoreMenuSection(childType) {
6024
- const section = createElement("div", { className: "lexxy-table-control__more-menu-details" });
6641
+ const section = createElement("div", { className: "lexxy-floating-controls__group lexxy-table-control__more-menu-details" });
6025
6642
  const addBeforeButton = this.#createButton(`Add ${childType} before`, { action: "insert", childType, direction: "before" });
6026
6643
  const addAfterButton = this.#createButton(`Add ${childType} after`, { action: "insert", childType, direction: "after" });
6027
6644
  const toggleStyleButton = this.#createButton(`Toggle ${childType} style`, { action: "toggle", childType });
@@ -6036,7 +6653,7 @@ class TableTools extends HTMLElement {
6036
6653
  }
6037
6654
 
6038
6655
  #createDeleteTableButton() {
6039
- const container = createElement("div", { className: "lexxy-table-control" });
6656
+ const container = createElement("div", { className: "lexxy-table-control lexxy-floating-controls__group" });
6040
6657
 
6041
6658
  const deleteTableButton = this.#createButton("Delete this table?", { action: "delete", childType: "table" });
6042
6659
  deleteTableButton.classList.add("lexxy-table-control__button--delete-table");
@@ -6255,6 +6872,7 @@ function defineElements() {
6255
6872
  "lexxy-highlight-dropdown": HighlightDropdown,
6256
6873
  "lexxy-prompt": LexicalPromptElement,
6257
6874
  "lexxy-code-language-picker": CodeLanguagePicker,
6875
+ "lexxy-node-delete-button": NodeDeleteButton,
6258
6876
  "lexxy-table-tools": TableTools,
6259
6877
  };
6260
6878
 
@@ -6268,4 +6886,4 @@ const configure = Lexxy.configure;
6268
6886
  // Pushing elements definition to after the current call stack to allow global configuration to take place first
6269
6887
  setTimeout(defineElements, 0);
6270
6888
 
6271
- export { ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, configure };
6889
+ export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, configure };