@37signals/lexxy 0.8.5-beta → 0.9.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,21 +9,21 @@ 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, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
13
- import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, DecoratorNode, $createNodeSelection, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $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_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isDecoratorNode, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, ElementNode, $splitNode, $createLineBreakNode, $isRootNode, ParagraphNode, RootNode, DRAGSTART_COMMAND, DROP_COMMAND, CLEAR_HISTORY_COMMAND, $addUpdateTag, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
12
+ import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType } from '@lexical/selection';
13
+ import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, DecoratorNode, $createTextNode, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isElementNode, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createParagraphNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isRootOrShadowRoot, ElementNode, $splitNode, $isParagraphNode, $createLineBreakNode, $isRootNode, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, CLEAR_HISTORY_COMMAND, $addUpdateTag, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
14
14
  import { buildEditorFromExtensions } from '@lexical/extension';
15
- import { ListNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListItemNode, $getListDepth, $isListItemNode, $isListNode, $createListNode, registerList } from '@lexical/list';
15
+ import { ListNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListItemNode, $getListDepth, $isListItemNode, $isListNode, registerList } from '@lexical/list';
16
16
  import { $createAutoLinkNode, $toggleLink, LinkNode, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
17
17
  import { registerPlainText } from '@lexical/plain-text';
18
- import { RichTextExtension, $createQuoteNode, $isQuoteNode, $createHeadingNode, $isHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
18
+ import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
19
19
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
20
- import { $isCodeNode, CodeHighlightNode, $isCodeHighlightNode, CodeNode, normalizeCodeLang, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
20
+ import { $isCodeNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeHighlightNode, $createCodeHighlightNode, normalizeCodeLang, 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, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
23
+ import { createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
24
24
  export { highlightCode as highlightAll, highlightCode } from './lexxy_helpers.esm.js';
25
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, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $descendantsMatching } from '@lexical/utils';
26
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $descendantsMatching } from '@lexical/utils';
27
27
  import { marked } from 'marked';
28
28
  import { $insertDataTransferForRichText } from '@lexical/clipboard';
29
29
 
@@ -81,7 +81,9 @@ const presets = new Configuration({
81
81
  markdown: true,
82
82
  multiLine: true,
83
83
  richText: true,
84
- toolbar: true,
84
+ toolbar: {
85
+ upload: "both"
86
+ },
85
87
  highlight: {
86
88
  buttons: {
87
89
  color: range(1, 9).map(n => `var(--highlight-${n})`),
@@ -260,11 +262,37 @@ var ToolbarIcons = {
260
262
  <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
263
  </svg>`,
262
264
 
265
+ "underline":
266
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
267
+ <path d="M14 14C14.5523 14 15 14.4477 15 15C15 15.5523 14.5523 16 14 16H4C3.44772 16 3 15.5523 3 15C3 14.4477 3.44772 14 4 14H14Z"/>
268
+ <path d="M12.625 1.59375C13.25 1.59375 13.625 1.97656 13.625 2.64062V9.02344C13.625 11.4844 11.8516 13.1875 8.99219 13.1875C6.14062 13.1875 4.35938 11.4844 4.35938 9.02344V2.64062C4.35938 1.97656 4.74219 1.59375 5.36719 1.59375C6 1.59375 6.375 1.97656 6.375 2.64062V8.84375C6.375 10.3828 7.32031 11.4297 8.99219 11.4297C10.6641 11.4297 11.6172 10.3828 11.6172 8.84375V2.64062C11.6172 1.97656 11.9922 1.59375 12.625 1.59375Z"/>
269
+ </svg>`,
270
+
263
271
  "heading":
264
272
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
265
273
  <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
274
  </svg>`,
267
275
 
276
+ "h2":
277
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
278
+ <path d="M8.12207 4.30078C8.84668 4.30078 9.25684 4.74512 9.25684 5.51758V12.5518C9.25677 13.3241 8.84662 13.7686 8.12207 13.7686C7.39752 13.7686 6.9942 13.3309 6.99414 12.5518V9.87207H3.56934V12.5518C3.56927 13.3241 3.15912 13.7686 2.43457 13.7686C1.71002 13.7686 1.3067 13.3309 1.30664 12.5518V5.51758C1.30664 4.73828 1.70996 4.30078 2.43457 4.30078C3.15918 4.30078 3.56934 4.74512 3.56934 5.51758V8.07422H6.99414V5.51758C6.99414 4.73828 7.39746 4.30078 8.12207 4.30078ZM13.6445 4.19824C15.5244 4.19824 16.8984 5.34668 16.8984 6.91211C16.8984 7.8759 16.4335 8.7237 15.292 9.84473L13.3438 11.8135V11.9092H16.1875C16.8232 11.9092 17.2197 12.251 17.2197 12.8115C17.2196 13.3651 16.83 13.7002 16.1875 13.7002H11.5117C10.8487 13.7002 10.4112 13.3241 10.4111 12.75C10.4111 12.3399 10.6368 11.9843 11.3203 11.3145L13.6855 8.88086C14.4169 8.13583 14.7245 7.64349 14.7246 7.12402C14.7246 6.4541 14.2393 6.00293 13.5215 6.00293C12.9404 6.00293 12.5166 6.29688 12.2158 6.90527C11.9151 7.37002 11.6552 7.54785 11.2588 7.54785C10.7188 7.54785 10.3429 7.17861 10.3428 6.65918C10.3428 5.3877 11.7783 4.19824 13.6445 4.19824Z"/>
279
+ </svg>`,
280
+
281
+ "h3":
282
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
283
+ <path d="M13.5967 4.19824C15.5928 4.19824 16.9873 5.23047 16.9873 6.7002C16.9872 7.705 16.2421 8.60059 15.2988 8.7168V8.86719C16.4199 8.94923 17.2607 9.89942 17.2607 11.0889C17.2606 12.7362 15.7362 13.9053 13.583 13.9053C11.6827 13.9053 10.1925 12.873 10.1924 11.7041C10.1924 11.1846 10.5547 10.8154 11.0537 10.8154C11.3818 10.8154 11.6553 10.9727 11.9629 11.3555C12.3799 11.9159 12.92 12.2031 13.583 12.2031C14.4853 12.2031 15.0731 11.7313 15.0732 11C15.0732 10.2754 14.4785 9.7832 13.5898 9.7832H13.0361C12.5645 9.7832 12.2159 9.4208 12.2158 8.92188C12.2158 8.44336 12.5576 8.07422 13.0361 8.07422H13.5693C14.3075 8.07422 14.8544 7.60928 14.8545 6.97363C14.8545 6.33789 14.3213 5.90039 13.5557 5.90039C12.9678 5.90039 12.5029 6.16016 12.0859 6.71387C11.8399 7.03508 11.5527 7.17871 11.1973 7.17871C10.671 7.17871 10.295 6.82314 10.2949 6.31738C10.2949 5.18945 11.751 4.19824 13.5967 4.19824ZM8.0332 4.30078C8.75781 4.30078 9.16797 4.74512 9.16797 5.51758V12.5518C9.1679 13.3241 8.75776 13.7686 8.0332 13.7686C7.30865 13.7686 6.90534 13.3309 6.90527 12.5518V9.87207H3.48047V12.5518C3.4804 13.3241 3.07026 13.7686 2.3457 13.7686C1.62115 13.7686 1.21784 13.3309 1.21777 12.5518V5.51758C1.21777 4.73828 1.62109 4.30078 2.3457 4.30078C3.07031 4.30078 3.48047 4.74512 3.48047 5.51758V8.07422H6.90527V5.51758C6.90527 4.73828 7.30859 4.30078 8.0332 4.30078Z"/>
284
+ </svg>`,
285
+
286
+ "h4":
287
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
288
+ <path d="M14.6357 4.22559C15.7432 4.22559 16.4336 4.80664 16.4336 5.73633V10.3164H16.7275C17.2881 10.3164 17.6436 10.6787 17.6436 11.2256C17.6435 11.7655 17.3017 12.1006 16.7275 12.1006H16.4336V12.6611C16.4335 13.3515 16.0234 13.7891 15.374 13.7891C14.7247 13.7891 14.3282 13.3583 14.3281 12.6611V12.1006H11.04C10.2335 12.1006 9.76863 11.6766 9.76855 10.918C9.76855 10.5762 9.85064 10.3026 10.1104 9.74219C10.7666 8.42289 11.5733 7.0146 12.5713 5.54492C13.2549 4.56738 13.7812 4.22559 14.6357 4.22559ZM7.88965 4.30078C8.61426 4.30078 9.02441 4.74512 9.02441 5.51758V12.5518C9.02435 13.3241 8.6142 13.7686 7.88965 13.7686C7.1651 13.7686 6.76178 13.3309 6.76172 12.5518V9.87207H3.33691V12.5518C3.33685 13.3241 2.9267 13.7686 2.20215 13.7686C1.4776 13.7686 1.07428 13.3309 1.07422 12.5518V5.51758C1.07422 4.73828 1.47754 4.30078 2.20215 4.30078C2.92676 4.30078 3.33691 4.74512 3.33691 5.51758V8.07422H6.76172V5.51758C6.76172 4.73828 7.16504 4.30078 7.88965 4.30078ZM14.2188 6.07812C13.6035 7.02841 12.2158 9.48929 11.7988 10.2686V10.3164H14.3281V6.07812H14.2188Z"/>
289
+ </svg>`,
290
+
291
+ "paragraph":
292
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
293
+ <path d="M9 12C9.55228 12 10 12.4477 10 13C10 13.5523 9.55228 14 9 14H3C2.44772 14 2 13.5523 2 13C2 12.4477 2.44772 12 3 12H9ZM15 8C15.5523 8 16 8.44772 16 9C16 9.55228 15.5523 10 15 10H3C2.44772 10 2 9.55228 2 9C2 8.44772 2.44772 8 3 8H15ZM15 4C15.5523 4 16 4.44772 16 5C16 5.55228 15.5523 6 15 6H3C2.44772 6 2 5.55228 2 5C2 4.44772 2.44772 4 3 4H15Z"/>
294
+ </svg>`,
295
+
268
296
  "highlight":
269
297
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
270
298
  <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"/>
@@ -300,6 +328,11 @@ var ToolbarIcons = {
300
328
  <path d="M2.84155 6V3.01367H2.79053L1.85596 3.64478V2.79614L2.84155 2.12476H3.82715V6H2.84155Z"/>
301
329
  </svg>`,
302
330
 
331
+ "image":
332
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
333
+ <path d="M14 2C15.6569 2 17 3.34315 17 5V13C17 14.6569 15.6569 16 14 16H4C2.34315 16 1 14.6569 1 13V5C1 3.34315 2.34315 2 4 2H14ZM3.06348 13.3496C3.2053 13.7294 3.57078 14 4 14H13.5859L11 11.4141L9.70703 12.707C9.31651 13.0976 8.68349 13.0976 8.29297 12.707C7.90244 12.3165 7.90244 11.6835 8.29297 11.293L8.58594 11L7 9.41406L3.06348 13.3496ZM4 4C3.44772 4 3 4.44772 3 5V10.5859L6.29297 7.29297L6.36914 7.22461C6.76191 6.90427 7.34092 6.92686 7.70703 7.29297L10 9.58594L10.293 9.29297L10.3691 9.22461C10.7619 8.90427 11.3409 8.92686 11.707 9.29297L15 12.5859V5C15 4.44772 14.5523 4 14 4H4ZM12.5 5C13.3284 5 14 5.67157 14 6.5C14 7.32843 13.3284 8 12.5 8C11.6716 8 11 7.32843 11 6.5C11 5.67157 11.6716 5 12.5 5Z"/>
334
+ </svg>`,
335
+
303
336
  "attachment":
304
337
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
305
338
  <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"/>
@@ -360,6 +393,14 @@ class LexicalToolbarElement extends HTMLElement {
360
393
  }
361
394
  }
362
395
 
396
+ configure(config) {
397
+ if (typeof config === "object" && config !== null) {
398
+ for (const [ button, value ] of Object.entries(config)) {
399
+ this.setAttribute(`data-${button}`, value);
400
+ }
401
+ }
402
+ }
403
+
363
404
  setEditor(editorElement) {
364
405
  this.editorElement = editorElement;
365
406
  this.editor = editorElement.editor;
@@ -525,19 +566,29 @@ class LexicalToolbarElement extends HTMLElement {
525
566
  const anchorNode = selection.anchor.getNode();
526
567
  if (!anchorNode.getParent()) { return }
527
568
 
528
- const { isBold, isItalic, isStrikethrough, isHighlight, isInLink, isInQuote, isInHeading,
529
- isInCode, isInList, listType, isInTable } = this.selection.getFormat();
569
+ const { isBold, isItalic, isStrikethrough, isUnderline, isHighlight, isInLink, isInQuote, isInHeading,
570
+ headingTag, isInCode, isInList, listType, isInTable } = this.selection.getFormat();
530
571
 
531
572
  this.#setButtonPressed("bold", isBold);
532
573
  this.#setButtonPressed("italic", isItalic);
574
+
575
+ this.#setButtonPressed("format", isInHeading || isStrikethrough || isUnderline);
576
+ this.#setButtonPressed("paragraph", !isInHeading);
577
+ this.#setButtonPressed("heading-large", headingTag === "h2");
578
+ this.#setButtonPressed("heading-medium", headingTag === "h3");
579
+ this.#setButtonPressed("heading-small", headingTag === "h4");
533
580
  this.#setButtonPressed("strikethrough", isStrikethrough);
581
+ this.#setButtonPressed("underline", isUnderline);
582
+
583
+ this.#setButtonPressed("lists", isInList);
584
+ this.#setButtonPressed("unordered-list", isInList && listType === "bullet");
585
+ this.#setButtonPressed("ordered-list", isInList && listType === "number");
586
+
534
587
  this.#setButtonPressed("highlight", isHighlight);
535
588
  this.#setButtonPressed("link", isInLink);
536
589
  this.#setButtonPressed("quote", isInQuote);
537
- this.#setButtonPressed("heading", isInHeading);
538
590
  this.#setButtonPressed("code", isInCode);
539
- this.#setButtonPressed("unordered-list", isInList && listType === "bullet");
540
- this.#setButtonPressed("ordered-list", isInList && listType === "number");
591
+
541
592
  this.#setButtonPressed("table", isInTable);
542
593
 
543
594
  this.#updateUndoRedoButtonStates();
@@ -645,6 +696,14 @@ class LexicalToolbarElement extends HTMLElement {
645
696
 
646
697
  static get defaultTemplate() {
647
698
  return `
699
+ <button class="lexxy-editor__toolbar-button" type="button" name="image" data-command="uploadAttachments" data-prevent-overflow="true" title="Add images">
700
+ ${ToolbarIcons.image}
701
+ </button>
702
+
703
+ <button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" type="button" name="file" data-command="uploadAttachments" title="Upload files">
704
+ ${ToolbarIcons.attachment}
705
+ </button>
706
+
648
707
  <button class="lexxy-editor__toolbar-button" type="button" name="bold" data-command="bold" title="Bold">
649
708
  ${ToolbarIcons.bold}
650
709
  </button>
@@ -653,15 +712,49 @@ class LexicalToolbarElement extends HTMLElement {
653
712
  ${ToolbarIcons.italic}
654
713
  </button>
655
714
 
656
- <button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough">
657
- ${ToolbarIcons.strikethrough}
658
- </button>
715
+ <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron" name="lexxy-dropdown">
716
+ <summary class="lexxy-editor__toolbar-button" name="format" title="Text formatting">
717
+ ${ToolbarIcons.heading}
718
+ </summary>
719
+ <div class="lexxy-editor__toolbar-dropdown-list">
720
+ <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph">
721
+ ${ToolbarIcons.paragraph} <span>Normal</span>
722
+ </button>
723
+ <button type="button" name="heading-large" data-command="setFormatHeadingLarge" title="Large heading">
724
+ ${ToolbarIcons.h2} <span>Large Heading</span>
725
+ </button>
726
+ <button type="button" name="heading-medium" data-command="setFormatHeadingMedium" title="Medium heading">
727
+ ${ToolbarIcons.h3} <span>Medium Heading</span>
728
+ </button>
729
+ <button type="button" name="heading-small" data-command="setFormatHeadingSmall" title="Small heading">
730
+ ${ToolbarIcons.h4} <span>Small Heading</span>
731
+ </button>
732
+ <div class="separator" role="separator"></div>
733
+ <button type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough">
734
+ ${ToolbarIcons.strikethrough} <span>Strikethrough</span>
735
+ </button>
736
+ <button type="button" name="underline" data-command="underline" title="Underline">
737
+ ${ToolbarIcons.underline} <span>Underline</span>
738
+ </button>
739
+ </div>
740
+ </details>
659
741
 
660
- <button class="lexxy-editor__toolbar-button" type="button" name="heading" data-command="rotateHeadingFormat" title="Heading">
661
- ${ToolbarIcons.heading}
662
- </button>
663
742
 
664
- <details class="lexxy-editor__toolbar-dropdown" name="lexxy-dropdown">
743
+ <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron" name="lexxy-dropdown">
744
+ <summary class="lexxy-editor__toolbar-button" name="lists" title="Lists">
745
+ ${ToolbarIcons.ul}
746
+ </summary>
747
+ <div class="lexxy-editor__toolbar-dropdown-list">
748
+ <button type="button" name="unordered-list" data-command="insertUnorderedList" title="Bullet list">
749
+ ${ToolbarIcons.ul} <span>Bullets</span>
750
+ </button>
751
+ <button type="button" name="ordered-list" data-command="insertOrderedList" title="Numbered list">
752
+ ${ToolbarIcons.ol} <span>Numbers</span>
753
+ </button>
754
+ </div>
755
+ </details>
756
+
757
+ <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron" name="lexxy-dropdown">
665
758
  <summary class="lexxy-editor__toolbar-button" name="highlight" title="Color highlight">
666
759
  ${ToolbarIcons.highlight}
667
760
  </summary>
@@ -694,18 +787,6 @@ class LexicalToolbarElement extends HTMLElement {
694
787
  ${ToolbarIcons.code}
695
788
  </button>
696
789
 
697
- <button class="lexxy-editor__toolbar-button" type="button" name="unordered-list" data-command="insertUnorderedList" title="Bullet list">
698
- ${ToolbarIcons.ul}
699
- </button>
700
-
701
- <button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" type="button" name="ordered-list" data-command="insertOrderedList" title="Numbered list">
702
- ${ToolbarIcons.ol}
703
- </button>
704
-
705
- <button class="lexxy-editor__toolbar-button" type="button" name="upload" data-command="uploadAttachments" data-prevent-overflow="true" title="Upload file">
706
- ${ToolbarIcons.attachment}
707
- </button>
708
-
709
790
  <button class="lexxy-editor__toolbar-button" type="button" name="table" data-command="insertTable" title="Insert a table">
710
791
  ${ToolbarIcons.table}
711
792
  </button>
@@ -1032,6 +1113,149 @@ class HorizontalDividerNode extends DecoratorNode {
1032
1113
  }
1033
1114
  }
1034
1115
 
1116
+ function bytesToHumanSize(bytes) {
1117
+ if (bytes === 0) return "0 B"
1118
+ const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
1119
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
1120
+ const value = bytes / Math.pow(1024, i);
1121
+ return `${ value.toFixed(2) } ${ sizes[i] }`
1122
+ }
1123
+
1124
+ function extractFileName(string) {
1125
+ return string.split("/").pop()
1126
+ }
1127
+
1128
+ // The content attribute is raw HTML (matching Trix/ActionText). Older Lexxy
1129
+ // versions JSON-encoded it, so try JSON.parse first for backward compatibility.
1130
+ function parseAttachmentContent(content) {
1131
+ try {
1132
+ return JSON.parse(content)
1133
+ } catch {
1134
+ return content
1135
+ }
1136
+ }
1137
+
1138
+ class CustomActionTextAttachmentNode extends DecoratorNode {
1139
+ static getType() {
1140
+ return "custom_action_text_attachment"
1141
+ }
1142
+
1143
+ static clone(node) {
1144
+ return new CustomActionTextAttachmentNode({ ...node }, node.__key)
1145
+ }
1146
+
1147
+ static importJSON(serializedNode) {
1148
+ return new CustomActionTextAttachmentNode({ ...serializedNode })
1149
+ }
1150
+
1151
+ static importDOM() {
1152
+ return {
1153
+ [this.TAG_NAME]: (element) => {
1154
+ if (!element.getAttribute("content")) {
1155
+ return null
1156
+ }
1157
+
1158
+ return {
1159
+ conversion: (attachment) => {
1160
+ // Preserve initial space if present since Lexical removes it
1161
+ const nodes = [];
1162
+ const previousSibling = attachment.previousSibling;
1163
+ if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
1164
+ nodes.push($createTextNode(" "));
1165
+ }
1166
+
1167
+ const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
1168
+
1169
+ nodes.push(new CustomActionTextAttachmentNode({
1170
+ sgid: attachment.getAttribute("sgid"),
1171
+ innerHtml,
1172
+ plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
1173
+ contentType: attachment.getAttribute("content-type")
1174
+ }));
1175
+
1176
+ const nextSibling = attachment.nextSibling;
1177
+ if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && /^\s/.test(nextSibling.textContent)) {
1178
+ nodes.push($createTextNode(" "));
1179
+ }
1180
+
1181
+ return { node: nodes }
1182
+ },
1183
+ priority: 2
1184
+ }
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ static get TAG_NAME() {
1190
+ return Lexxy.global.get("attachmentTagName")
1191
+ }
1192
+
1193
+ constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
1194
+ super(key);
1195
+
1196
+ const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
1197
+
1198
+ this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
1199
+ this.sgid = sgid;
1200
+ this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
1201
+ this.innerHtml = innerHtml;
1202
+ this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
1203
+ }
1204
+
1205
+ createDOM() {
1206
+ const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
1207
+
1208
+ figure.insertAdjacentHTML("beforeend", this.innerHtml);
1209
+
1210
+ const deleteButton = createElement("lexxy-node-delete-button");
1211
+ figure.appendChild(deleteButton);
1212
+
1213
+ return figure
1214
+ }
1215
+
1216
+ updateDOM() {
1217
+ return false
1218
+ }
1219
+
1220
+ getTextContent() {
1221
+ return "\ufeff"
1222
+ }
1223
+
1224
+ getReadableTextContent() {
1225
+ return this.plainText || `[${this.contentType}]`
1226
+ }
1227
+
1228
+ isInline() {
1229
+ return true
1230
+ }
1231
+
1232
+ exportDOM() {
1233
+ const attachment = createElement(this.tagName, {
1234
+ sgid: this.sgid,
1235
+ content: this.innerHtml,
1236
+ "content-type": this.contentType
1237
+ });
1238
+
1239
+ return { element: attachment }
1240
+ }
1241
+
1242
+ exportJSON() {
1243
+ return {
1244
+ type: "custom_action_text_attachment",
1245
+ version: 1,
1246
+ tagName: this.tagName,
1247
+ sgid: this.sgid,
1248
+ contentType: this.contentType,
1249
+ innerHtml: this.innerHtml,
1250
+ plainText: this.plainText
1251
+ }
1252
+ }
1253
+
1254
+ decorate() {
1255
+ return null
1256
+ }
1257
+ }
1258
+
1035
1259
  const SILENT_UPDATE_TAGS = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
1036
1260
 
1037
1261
  function $createNodeSelectionWith(...nodes) {
@@ -1099,6 +1323,71 @@ function extendConversion(nodeKlass, conversionName, callback = (output => outpu
1099
1323
  }
1100
1324
  }
1101
1325
 
1326
+ function $isCursorOnLastLine(selection) {
1327
+ const anchorNode = selection.anchor.getNode();
1328
+ const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
1329
+ const children = elementNode.getChildren();
1330
+ if (children.length === 0) return true
1331
+
1332
+ const lastChild = children[children.length - 1];
1333
+
1334
+ if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
1335
+ if (anchorNode === lastChild) return true
1336
+
1337
+ const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
1338
+ if (lastLineBreakIndex === -1) return true
1339
+
1340
+ const anchorIndex = children.indexOf(anchorNode);
1341
+ return anchorIndex > lastLineBreakIndex
1342
+ }
1343
+
1344
+ function $isBlankNode(node) {
1345
+ if (node.getTextContent().trim() !== "") return false
1346
+
1347
+ const children = node.getChildren?.();
1348
+ if (!children || children.length === 0) return true
1349
+
1350
+ return children.every(child => {
1351
+ if ($isLineBreakNode(child)) return true
1352
+ return $isBlankNode(child)
1353
+ })
1354
+ }
1355
+
1356
+ function $trimTrailingBlankNodes(parent) {
1357
+ for (const child of $lastToFirstIterator(parent)) {
1358
+ if ($isBlankNode(child)) {
1359
+ child.remove();
1360
+ } else {
1361
+ break
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ // A list item is structurally empty if it contains no meaningful content.
1367
+ // Unlike getTextContent().trim() === "", this walks descendants to ensure
1368
+ // decorator nodes (mentions, attachments whose getTextContent() may return
1369
+ // invisible characters like \ufeff) are treated as non-empty content.
1370
+ function $isListItemStructurallyEmpty(listItem) {
1371
+ const children = listItem.getChildren();
1372
+ for (const child of children) {
1373
+ if ($isDecoratorNode(child)) return false
1374
+ if ($isLineBreakNode(child)) continue
1375
+ if ($isTextNode(child)) {
1376
+ if (child.getTextContent().trim() !== "") return false
1377
+ } else if ($isElementNode(child)) {
1378
+ if (child.getTextContent().trim() !== "") return false
1379
+ }
1380
+ }
1381
+ return true
1382
+ }
1383
+
1384
+ function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1385
+ return $isTextNode(node)
1386
+ && node.getTextContent() === " "
1387
+ && index === childCount - 1
1388
+ && previousNode instanceof CustomActionTextAttachmentNode
1389
+ }
1390
+
1102
1391
  function isSelectionHighlighted(selection) {
1103
1392
  if (!$isRangeSelection(selection)) return false
1104
1393
 
@@ -1209,6 +1498,12 @@ const hasPastedStylesState = createState("hasPastedStyles", {
1209
1498
  parse: (value) => value || false
1210
1499
  });
1211
1500
 
1501
+ // Stores pending highlight ranges extracted during HTML import, keyed by CodeNode key.
1502
+ // After the code retokenizer creates fresh CodeHighlightNodes, a mutation listener
1503
+ // reads this map and re-applies the highlight styles. Scoped per editor instance
1504
+ // so entries don't leak across editors or outlive a torn-down editor.
1505
+ const pendingCodeHighlights = new WeakMap();
1506
+
1212
1507
  class HighlightExtension extends LexxyExtension {
1213
1508
  get enabled() {
1214
1509
  return this.editorElement.supportsRichText
@@ -1231,12 +1526,20 @@ class HighlightExtension extends LexxyExtension {
1231
1526
  // keep the ref to the canonicalizers for optimized css conversion
1232
1527
  const canonicalizers = buildCanonicalizers(config);
1233
1528
 
1529
+ // Register the <pre> converter directly in the conversion cache so it
1530
+ // coexists with other extensions' "pre" converters (the extension-level
1531
+ // html.import uses Object.assign, which means only one "pre" per key).
1532
+ $registerPreConversion(editor);
1533
+
1234
1534
  return mergeRegister(
1235
1535
  editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, (styles) => $toggleSelectionStyles(editor, styles), COMMAND_PRIORITY_NORMAL),
1236
1536
  editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(editor, BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
1237
1537
  editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
1238
1538
  editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode),
1239
- editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers))
1539
+ editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers)),
1540
+ editor.registerMutationListener(CodeNode, (mutations) => {
1541
+ $applyPendingCodeHighlights(editor, mutations);
1542
+ }, { skipInitialization: true })
1240
1543
  )
1241
1544
  }
1242
1545
  });
@@ -1266,50 +1569,249 @@ function $markConversion() {
1266
1569
  }
1267
1570
  }
1268
1571
 
1269
- function buildCanonicalizers(config) {
1270
- return [
1271
- new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
1272
- new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
1273
- ]
1572
+ // Register a custom <pre> converter directly in the editor's HTML conversion
1573
+ // cache. We can't use the extension-level html.import because Object.assign
1574
+ // merges all extensions' converters by tag, and a later extension (e.g.
1575
+ // TrixContentExtension) would overwrite ours.
1576
+ function $registerPreConversion(editor) {
1577
+ if (!editor._htmlConversions) return
1578
+
1579
+ let preEntries = editor._htmlConversions.get("pre");
1580
+ if (!preEntries) {
1581
+ preEntries = [];
1582
+ editor._htmlConversions.set("pre", preEntries);
1583
+ }
1584
+ preEntries.push($preConversionWithHighlightsFactory(editor));
1274
1585
  }
1275
1586
 
1276
- function $toggleSelectionStyles(editor, styles) {
1277
- const selection = $getSelection();
1278
- if (!$isRangeSelection(selection)) return
1587
+ // Returns a <pre> converter factory scoped to a specific editor instance.
1588
+ // The factory extracts highlight ranges from <mark> elements before the code
1589
+ // retokenizer can destroy them. The ranges are stored in pendingCodeHighlights
1590
+ // and applied after retokenization via a mutation listener.
1591
+ function $preConversionWithHighlightsFactory(editor) {
1592
+ return function $preConversionWithHighlights(domNode) {
1593
+ const highlights = extractHighlightRanges(domNode);
1594
+ if (highlights.length === 0) return null
1279
1595
 
1280
- const patch = {};
1281
- for (const property in styles) {
1282
- const oldValue = $getSelectionStyleValueForProperty(selection, property);
1283
- patch[property] = toggleOrReplace(oldValue, styles[property]);
1596
+ return {
1597
+ conversion: (domNode) => {
1598
+ const language = domNode.getAttribute("data-language");
1599
+ const codeNode = $createCodeNode(language);
1600
+ $getPendingHighlights(editor).set(codeNode.getKey(), highlights);
1601
+ return { node: codeNode }
1602
+ },
1603
+ priority: 2
1604
+ }
1284
1605
  }
1606
+ }
1285
1607
 
1286
- if ($selectionIsInCodeBlock(selection)) {
1287
- $patchCodeHighlightStyles(editor, selection, patch);
1288
- } else {
1289
- $patchStyleText(selection, patch);
1608
+ // Walk the DOM tree inside a <pre> element and build a list of
1609
+ // { start, end, style } ranges for every <mark> element found.
1610
+ function extractHighlightRanges(preElement) {
1611
+ const ranges = [];
1612
+ const codeElement = preElement.querySelector("code") || preElement;
1613
+
1614
+ let offset = 0;
1615
+
1616
+ function walk(node) {
1617
+ if (node.nodeType === Node.TEXT_NODE) {
1618
+ offset += node.textContent.length;
1619
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
1620
+ // <br> maps to a LineBreakNode (1 character) in Lexical
1621
+ if (node.tagName === "BR") {
1622
+ offset += 1;
1623
+ return
1624
+ }
1625
+
1626
+ const isMark = node.tagName === "MARK";
1627
+ const start = offset;
1628
+
1629
+ for (const child of node.childNodes) {
1630
+ walk(child);
1631
+ }
1632
+
1633
+ if (isMark) {
1634
+ const style = extractHighlightStyleFromElement(node);
1635
+ if (style) {
1636
+ ranges.push({ start, end: offset, style });
1637
+ }
1638
+ }
1639
+ }
1640
+ }
1641
+
1642
+ for (const child of codeElement.childNodes) {
1643
+ walk(child);
1290
1644
  }
1645
+
1646
+ return ranges
1291
1647
  }
1292
1648
 
1293
- function $selectionIsInCodeBlock(selection) {
1294
- const nodes = selection.getNodes();
1295
- return nodes.some((node) => {
1296
- const parent = $isCodeHighlightNode(node) ? node.getParent() : node;
1297
- return $isCodeNode(parent)
1298
- })
1649
+ function $getPendingHighlights(editor) {
1650
+ let map = pendingCodeHighlights.get(editor);
1651
+ if (!map) {
1652
+ map = new Map();
1653
+ pendingCodeHighlights.set(editor, map);
1654
+ }
1655
+ return map
1299
1656
  }
1300
1657
 
1301
- function $patchCodeHighlightStyles(editor, selection, patch) {
1302
- // Capture selection state and node keys before the nested update
1303
- const nodeKeys = selection.getNodes()
1304
- .filter((node) => $isCodeHighlightNode(node))
1305
- .map((node) => ({
1306
- key: node.getKey(),
1307
- startOffset: $getNodeSelectionOffsets(node, selection)[0],
1308
- endOffset: $getNodeSelectionOffsets(node, selection)[1],
1309
- textSize: node.getTextContentSize()
1310
- }));
1658
+ function extractHighlightStyleFromElement(element) {
1659
+ const styles = {};
1660
+ if (element.style?.color) styles.color = element.style.color;
1661
+ if (element.style?.backgroundColor) styles["background-color"] = element.style.backgroundColor;
1662
+ const css = getCSSFromStyleObject(styles);
1663
+ return css.length > 0 ? css : null
1664
+ }
1311
1665
 
1312
- // Use skipTransforms to prevent the code highlighting system from
1666
+ // Called from the CodeNode mutation listener after the retokenizer has
1667
+ // replaced TextNodes with fresh CodeHighlightNodes.
1668
+ function $applyPendingCodeHighlights(editor, mutations) {
1669
+ const pending = $getPendingHighlights(editor);
1670
+ const keysToProcess = [];
1671
+
1672
+ for (const [ key, type ] of mutations) {
1673
+ if (type !== "destroyed" && pending.has(key)) {
1674
+ keysToProcess.push(key);
1675
+ }
1676
+ }
1677
+
1678
+ if (keysToProcess.length === 0) return
1679
+
1680
+ // Use a deferred update so the retokenizer has finished its
1681
+ // skipTransforms update before we touch the nodes.
1682
+ editor.update(() => {
1683
+ for (const key of keysToProcess) {
1684
+ const highlights = pending.get(key);
1685
+ pending.delete(key);
1686
+ if (!highlights) continue
1687
+
1688
+ const codeNode = $getNodeByKey(key);
1689
+ if (!codeNode || !$isCodeNode(codeNode)) continue
1690
+
1691
+ $applyHighlightRangesToCodeNode(codeNode, highlights);
1692
+ }
1693
+ }, { skipTransforms: true, discrete: true });
1694
+ }
1695
+
1696
+ // Apply saved highlight ranges to the CodeHighlightNode children
1697
+ // of a CodeNode, splitting nodes at range boundaries as needed.
1698
+ // We can't use TextNode.splitText() because it creates TextNode
1699
+ // instances (not CodeHighlightNodes) for the split parts. Instead,
1700
+ // we manually create CodeHighlightNode replacements.
1701
+ function $applyHighlightRangesToCodeNode(codeNode, highlights) {
1702
+ if (highlights.length === 0) return
1703
+
1704
+ for (const { start: hlStart, end: hlEnd, style } of highlights) {
1705
+ // Rebuild the child-to-offset mapping for each highlight range because
1706
+ // earlier ranges may have split nodes, invalidating previous mappings.
1707
+ const childRanges = $buildChildRanges(codeNode);
1708
+
1709
+ for (const { node, start: nodeStart, end: nodeEnd } of childRanges) {
1710
+ // Check if this child overlaps with the highlight range
1711
+ const overlapStart = Math.max(hlStart, nodeStart);
1712
+ const overlapEnd = Math.min(hlEnd, nodeEnd);
1713
+
1714
+ if (overlapStart >= overlapEnd) continue
1715
+
1716
+ // Calculate offsets relative to this node
1717
+ const relStart = overlapStart - nodeStart;
1718
+ const relEnd = overlapEnd - nodeStart;
1719
+ const nodeLength = nodeEnd - nodeStart;
1720
+
1721
+ if (relStart === 0 && relEnd === nodeLength) {
1722
+ // Entire node is highlighted - apply style directly
1723
+ node.setStyle(style);
1724
+ $setCodeHighlightFormat(node, true);
1725
+ } else {
1726
+ // Need to split: replace the node with 2 or 3 CodeHighlightNodes
1727
+ const text = node.getTextContent();
1728
+ const highlightType = node.getHighlightType();
1729
+ const replacements = [];
1730
+
1731
+ if (relStart > 0) {
1732
+ replacements.push($createCodeHighlightNode(text.slice(0, relStart), highlightType));
1733
+ }
1734
+
1735
+ const styledNode = $createCodeHighlightNode(text.slice(relStart, relEnd), highlightType);
1736
+ styledNode.setStyle(style);
1737
+ $setCodeHighlightFormat(styledNode, true);
1738
+ replacements.push(styledNode);
1739
+
1740
+ if (relEnd < nodeLength) {
1741
+ replacements.push($createCodeHighlightNode(text.slice(relEnd), highlightType));
1742
+ }
1743
+
1744
+ for (const replacement of replacements) {
1745
+ node.insertBefore(replacement);
1746
+ }
1747
+ node.remove();
1748
+ }
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ function $buildChildRanges(codeNode) {
1754
+ const childRanges = [];
1755
+ let charOffset = 0;
1756
+
1757
+ for (const child of codeNode.getChildren()) {
1758
+ if ($isCodeHighlightNode(child)) {
1759
+ const text = child.getTextContent();
1760
+ childRanges.push({ node: child, start: charOffset, end: charOffset + text.length });
1761
+ charOffset += text.length;
1762
+ } else {
1763
+ // LineBreakNode, TabNode - count as 1 character each (\n, \t)
1764
+ charOffset += 1;
1765
+ }
1766
+ }
1767
+
1768
+ return childRanges
1769
+ }
1770
+
1771
+ function buildCanonicalizers(config) {
1772
+ return [
1773
+ new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
1774
+ new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
1775
+ ]
1776
+ }
1777
+
1778
+ function $toggleSelectionStyles(editor, styles) {
1779
+ const selection = $getSelection();
1780
+ if (!$isRangeSelection(selection)) return
1781
+
1782
+ const patch = {};
1783
+ for (const property in styles) {
1784
+ const oldValue = $getSelectionStyleValueForProperty(selection, property);
1785
+ patch[property] = toggleOrReplace(oldValue, styles[property]);
1786
+ }
1787
+
1788
+ if ($selectionIsInCodeBlock(selection)) {
1789
+ $patchCodeHighlightStyles(editor, selection, patch);
1790
+ } else {
1791
+ $patchStyleText(selection, patch);
1792
+ }
1793
+ }
1794
+
1795
+ function $selectionIsInCodeBlock(selection) {
1796
+ const nodes = selection.getNodes();
1797
+ return nodes.some((node) => {
1798
+ const parent = $isCodeHighlightNode(node) ? node.getParent() : node;
1799
+ return $isCodeNode(parent)
1800
+ })
1801
+ }
1802
+
1803
+ function $patchCodeHighlightStyles(editor, selection, patch) {
1804
+ // Capture selection state and node keys before the nested update
1805
+ const nodeKeys = selection.getNodes()
1806
+ .filter((node) => $isCodeHighlightNode(node))
1807
+ .map((node) => ({
1808
+ key: node.getKey(),
1809
+ startOffset: $getNodeSelectionOffsets(node, selection)[0],
1810
+ endOffset: $getNodeSelectionOffsets(node, selection)[1],
1811
+ textSize: node.getTextContentSize()
1812
+ }));
1813
+
1814
+ // Use skipTransforms to prevent the code highlighting system from
1313
1815
  // re-tokenizing and wiping out the style changes we apply.
1314
1816
  // Use discrete to force a synchronous commit, ensuring the changes
1315
1817
  // are committed before editor.focus() triggers a second update cycle
@@ -1445,11 +1947,15 @@ const COMMANDS = [
1445
1947
  "bold",
1446
1948
  "italic",
1447
1949
  "strikethrough",
1950
+ "underline",
1448
1951
  "link",
1449
1952
  "unlink",
1450
1953
  "toggleHighlight",
1451
1954
  "removeHighlight",
1452
- "rotateHeadingFormat",
1955
+ "setFormatHeadingLarge",
1956
+ "setFormatHeadingMedium",
1957
+ "setFormatHeadingSmall",
1958
+ "setFormatParagraph",
1453
1959
  "insertUnorderedList",
1454
1960
  "insertOrderedList",
1455
1961
  "insertQuoteBlock",
@@ -1498,6 +2004,10 @@ class CommandDispatcher {
1498
2004
  this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
1499
2005
  }
1500
2006
 
2007
+ dispatchUnderline() {
2008
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
2009
+ }
2010
+
1501
2011
  dispatchToggleHighlight(styles) {
1502
2012
  this.editor.dispatchCommand(TOGGLE_HIGHLIGHT_COMMAND, styles);
1503
2013
  }
@@ -1533,7 +2043,7 @@ class CommandDispatcher {
1533
2043
  const anchorNode = selection.anchor.getNode();
1534
2044
 
1535
2045
  if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
1536
- this.contents.unwrapSelectedListItems();
2046
+ this.contents.applyParagraphFormat();
1537
2047
  } else {
1538
2048
  this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
1539
2049
  }
@@ -1546,26 +2056,72 @@ class CommandDispatcher {
1546
2056
  const anchorNode = selection.anchor.getNode();
1547
2057
 
1548
2058
  if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
1549
- this.contents.unwrapSelectedListItems();
2059
+ this.contents.applyParagraphFormat();
1550
2060
  } else {
1551
2061
  this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
1552
2062
  }
1553
2063
  }
1554
2064
 
1555
2065
  dispatchInsertQuoteBlock() {
1556
- if (!this.contents.wrapSelectedSoftBreakLines(() => $createQuoteNode())) {
1557
- this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode());
1558
- }
2066
+ this.contents.toggleBlockquote();
1559
2067
  }
1560
2068
 
1561
2069
  dispatchInsertCodeBlock() {
1562
- this.editor.update(() => {
1563
- if (this.selection.hasSelectedWordsInSingleLine) {
1564
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
2070
+ if (this.selection.hasSelectedWordsInSingleLine) {
2071
+ this.#toggleInlineCode();
2072
+ } else {
2073
+ this.contents.toggleCodeBlock();
2074
+ }
2075
+ }
2076
+
2077
+ #toggleInlineCode() {
2078
+ const selection = $getSelection();
2079
+ if (!$isRangeSelection(selection)) return
2080
+
2081
+ if (!selection.isCollapsed()) {
2082
+ const textNodes = selection.getNodes().filter($isTextNode);
2083
+ const applyingCode = !textNodes.every((node) => node.hasFormat("code"));
2084
+
2085
+ if (applyingCode) {
2086
+ this.#stripInlineFormattingFromSelection(selection, textNodes);
2087
+ }
2088
+ }
2089
+
2090
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
2091
+ }
2092
+
2093
+ // Strip all inline formatting (bold, italic, etc.) from the selected text
2094
+ // nodes so that applying code produces a single merged <code> element instead
2095
+ // of one per differently-formatted span.
2096
+ #stripInlineFormattingFromSelection(selection, textNodes) {
2097
+ const isBackward = selection.isBackward();
2098
+ const startPoint = isBackward ? selection.focus : selection.anchor;
2099
+ const endPoint = isBackward ? selection.anchor : selection.focus;
2100
+
2101
+ for (let i = 0; i < textNodes.length; i++) {
2102
+ const node = textNodes[i];
2103
+ if (node.getFormat() === 0) continue
2104
+
2105
+ const isFirst = i === 0;
2106
+ const isLast = i === textNodes.length - 1;
2107
+ const startOffset = isFirst && startPoint.type === "text" ? startPoint.offset : 0;
2108
+ const endOffset = isLast && endPoint.type === "text" ? endPoint.offset : node.getTextContentSize();
2109
+
2110
+ if (startOffset === 0 && endOffset === node.getTextContentSize()) {
2111
+ node.setFormat(0);
1565
2112
  } else {
1566
- this.contents.toggleNodeWrappingAllSelectedLines((node) => $isCodeNode(node), () => new CodeNode("plain"));
2113
+ const splits = node.splitText(startOffset, endOffset);
2114
+ const target = startOffset === 0 ? splits[0] : splits[1];
2115
+ target.setFormat(0);
2116
+
2117
+ if (isFirst && startPoint.type === "text") {
2118
+ startPoint.set(target.getKey(), 0, "text");
2119
+ }
2120
+ if (isLast && endPoint.type === "text") {
2121
+ endPoint.set(target.getKey(), endOffset - startOffset, "text");
2122
+ }
1567
2123
  }
1568
- });
2124
+ }
1569
2125
  }
1570
2126
 
1571
2127
  dispatchInsertHorizontalDivider() {
@@ -1573,35 +2129,20 @@ class CommandDispatcher {
1573
2129
  this.editor.focus();
1574
2130
  }
1575
2131
 
1576
- dispatchRotateHeadingFormat() {
1577
- const selection = $getSelection();
1578
- if (!$isRangeSelection(selection)) return
2132
+ dispatchSetFormatHeadingLarge() {
2133
+ this.contents.applyHeadingFormat("h2");
2134
+ }
1579
2135
 
1580
- if ($isRootOrShadowRoot(selection.anchor.getNode())) {
1581
- selection.insertNodes([ $createHeadingNode("h2") ]);
1582
- return
1583
- }
2136
+ dispatchSetFormatHeadingMedium() {
2137
+ this.contents.applyHeadingFormat("h3");
2138
+ }
1584
2139
 
1585
- const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
1586
- let nextTag = "h2";
1587
- if ($isHeadingNode(topLevelElement)) {
1588
- const currentTag = topLevelElement.getTag();
1589
- if (currentTag === "h2") {
1590
- nextTag = "h3";
1591
- } else if (currentTag === "h3") {
1592
- nextTag = "h4";
1593
- } else if (currentTag === "h4") {
1594
- nextTag = null;
1595
- } else {
1596
- nextTag = "h2";
1597
- }
1598
- }
2140
+ dispatchSetFormatHeadingSmall() {
2141
+ this.contents.applyHeadingFormat("h4");
2142
+ }
1599
2143
 
1600
- if (nextTag) {
1601
- this.contents.insertNodeWrappingEachSelectedLine(() => $createHeadingNode(nextTag));
1602
- } else {
1603
- this.contents.removeFormattingFromSelectedLines();
1604
- }
2144
+ dispatchSetFormatParagraph() {
2145
+ this.contents.applyParagraphFormat();
1605
2146
  }
1606
2147
 
1607
2148
  dispatchUploadAttachments() {
@@ -1801,28 +2342,40 @@ function nextFrame() {
1801
2342
  return new Promise(requestAnimationFrame)
1802
2343
  }
1803
2344
 
1804
- function bytesToHumanSize(bytes) {
1805
- if (bytes === 0) return "0 B"
1806
- const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
1807
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
1808
- const value = bytes / Math.pow(1024, i);
1809
- return `${ value.toFixed(2) } ${ sizes[i] }`
1810
- }
1811
-
1812
- function extractFileName(string) {
1813
- return string.split("/").pop()
2345
+ function dasherize(value) {
2346
+ return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
1814
2347
  }
1815
2348
 
1816
- // Lexxy exports the content attribute as a JSON string (via JSON.stringify),
1817
- // but Trix/ActionText stores it as raw HTML. Try JSON first, fall back to raw.
1818
- function parseAttachmentContent(content) {
2349
+ function isUrl(string) {
1819
2350
  try {
1820
- return JSON.parse(content)
2351
+ new URL(string);
2352
+ return true
1821
2353
  } catch {
1822
- return content
2354
+ return false
1823
2355
  }
1824
2356
  }
1825
2357
 
2358
+ function normalizeFilteredText(string) {
2359
+ return string
2360
+ .toLowerCase()
2361
+ .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
2362
+ }
2363
+
2364
+ function filterMatches(text, potentialMatch) {
2365
+ return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))
2366
+ }
2367
+
2368
+ function upcaseFirst(string) {
2369
+ return string.charAt(0).toUpperCase() + string.slice(1)
2370
+ }
2371
+
2372
+ // Parses a value that may arrive as a boolean or as a string (e.g. from DOM
2373
+ // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
2374
+ function parseBoolean(value) {
2375
+ if (typeof value === "string") return value === "true"
2376
+ return Boolean(value)
2377
+ }
2378
+
1826
2379
  class ActionTextAttachmentNode extends DecoratorNode {
1827
2380
  static getType() {
1828
2381
  return "action_text_attachment"
@@ -1903,7 +2456,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
1903
2456
  this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
1904
2457
  this.sgid = sgid;
1905
2458
  this.src = src;
1906
- this.previewable = previewable;
2459
+ this.previewable = parseBoolean(previewable);
1907
2460
  this.altText = altText || "";
1908
2461
  this.caption = caption || "";
1909
2462
  this.contentType = contentType || "";
@@ -2007,11 +2560,32 @@ class ActionTextAttachmentNode extends DecoratorNode {
2007
2560
 
2008
2561
  #createDOMForImage(options = {}) {
2009
2562
  const img = createElement("img", { src: this.src, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
2563
+
2564
+ if (this.previewable && !this.isPreviewableImage) {
2565
+ img.onerror = () => this.#swapPreviewToFileDOM(img);
2566
+ }
2567
+
2010
2568
  const container = createElement("div", { className: "attachment__container" });
2011
2569
  container.appendChild(img);
2012
2570
  return container
2013
2571
  }
2014
2572
 
2573
+ #swapPreviewToFileDOM(img) {
2574
+ const figure = img.closest("figure.attachment");
2575
+ if (!figure) return
2576
+
2577
+ figure.className = figure.className.replace("attachment--preview", "attachment--file");
2578
+
2579
+ const container = figure.querySelector(".attachment__container");
2580
+ if (container) container.remove();
2581
+
2582
+ const caption = figure.querySelector("figcaption");
2583
+ if (caption) caption.remove();
2584
+
2585
+ figure.appendChild(this.#createDOMForFile());
2586
+ figure.appendChild(this.#createDOMForNotImage());
2587
+ }
2588
+
2015
2589
  get #imageDimensions() {
2016
2590
  if (this.width && this.height) {
2017
2591
  return { width: this.width, height: this.height }
@@ -2109,6 +2683,7 @@ class Selection {
2109
2683
  this.#listenForNodeSelections();
2110
2684
  this.#processSelectionChangeCommands();
2111
2685
  this.#containEditorFocus();
2686
+ this.#clearStaleInlineCodeFormat();
2112
2687
  }
2113
2688
 
2114
2689
  set current(selection) {
@@ -2208,16 +2783,19 @@ class Selection {
2208
2783
 
2209
2784
  const topLevelElement = anchorNode.getTopLevelElementOrThrow();
2210
2785
  const listType = getListType(anchorNode);
2786
+ const headingNode = this.#getNearestHeadingNode(anchorNode);
2211
2787
 
2212
2788
  return {
2213
2789
  isBold: selection.hasFormat("bold"),
2214
2790
  isItalic: selection.hasFormat("italic"),
2215
2791
  isStrikethrough: selection.hasFormat("strikethrough"),
2792
+ isUnderline: selection.hasFormat("underline"),
2216
2793
  isHighlight: isSelectionHighlighted(selection),
2217
2794
  isInLink: $getNearestNodeOfType(anchorNode, LinkNode) !== null,
2218
2795
  isInQuote: $isQuoteNode(topLevelElement),
2219
- isInHeading: $isHeadingNode(topLevelElement),
2220
- isInCode: selection.hasFormat("code") || $getNearestNodeOfType(anchorNode, CodeNode) !== null,
2796
+ isInHeading: headingNode !== null,
2797
+ isInCode: this.#isInCode(selection, anchorNode),
2798
+ headingTag: headingNode?.getTag() ?? null,
2221
2799
  isInList: listType !== null,
2222
2800
  listType,
2223
2801
  isInTable: $getTableCellNodeFromLexicalNode(anchorNode) !== null
@@ -2352,6 +2930,53 @@ class Selection {
2352
2930
  return this.#findPreviousSiblingUp(anchorNode)
2353
2931
  }
2354
2932
 
2933
+ // When all inline code text is deleted, Lexical's selection retains the stale
2934
+ // code format flag. Verify the flag is backed by actual code-formatted content:
2935
+ // a code block ancestor or a text node that carries the code format.
2936
+ #isInCode(selection, anchorNode) {
2937
+ if ($getNearestNodeOfType(anchorNode, CodeNode) !== null) return true
2938
+ if (!selection.hasFormat("code")) return false
2939
+
2940
+ return $isTextNode(anchorNode) && anchorNode.hasFormat("code")
2941
+ }
2942
+
2943
+ // After deleting all inline code text, Lexical preserves the code format on
2944
+ // the selection even though no code-formatted content remains. This listener
2945
+ // detects that stale state and clears it so newly typed text won't be
2946
+ // code-formatted.
2947
+ #clearStaleInlineCodeFormat() {
2948
+ this.editor.registerUpdateListener(({ editorState, tags }) => {
2949
+ if (tags.has("history-merge") || tags.has("skip-dom-selection")) return
2950
+
2951
+ let isStale = false;
2952
+
2953
+ editorState.read(() => {
2954
+ const selection = $getSelection();
2955
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return
2956
+ if (!selection.hasFormat("code")) return
2957
+
2958
+ const anchorNode = selection.anchor.getNode();
2959
+ if (this.#isInCode(selection, anchorNode)) return
2960
+
2961
+ isStale = true;
2962
+ });
2963
+
2964
+ if (isStale) {
2965
+ setTimeout(() => {
2966
+ this.editor.update(() => {
2967
+ const selection = $getSelection();
2968
+ if (!$isRangeSelection(selection) || !selection.hasFormat("code")) return
2969
+
2970
+ const anchorNode = selection.anchor.getNode();
2971
+ if (this.#isInCode(selection, anchorNode)) return
2972
+
2973
+ selection.toggleFormat("code");
2974
+ });
2975
+ }, 0);
2976
+ }
2977
+ });
2978
+ }
2979
+
2355
2980
  get #currentlySelectedKeys() {
2356
2981
  if (this.currentlySelectedKeys) { return this.currentlySelectedKeys }
2357
2982
 
@@ -2535,6 +3160,24 @@ class Selection {
2535
3160
  return anchorNode.getTopLevelElement()
2536
3161
  }
2537
3162
 
3163
+ #getNearestHeadingNode(anchorNode) {
3164
+ const topLevelElement = anchorNode.getTopLevelElementOrThrow();
3165
+
3166
+ let headingNode = $isHeadingNode(topLevelElement) ? topLevelElement : null;
3167
+ if (!headingNode) {
3168
+ let current = anchorNode.getParent();
3169
+ while (current) {
3170
+ if ($isHeadingNode(current)) {
3171
+ headingNode = current;
3172
+ break
3173
+ }
3174
+ current = current.getParent();
3175
+ }
3176
+ }
3177
+
3178
+ return headingNode
3179
+ }
3180
+
2538
3181
  #moveToOrCreateNextLine(topLevelElement) {
2539
3182
  const nextSibling = topLevelElement.getNextSibling();
2540
3183
 
@@ -2563,6 +3206,8 @@ class Selection {
2563
3206
  }
2564
3207
 
2565
3208
  #selectDecoratorNodeBeforeDeletion(backwards) {
3209
+ if (backwards && this.#removeEmptyListItem()) return true
3210
+
2566
3211
  const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor;
2567
3212
  if (!$isDecoratorNode(node)) return false
2568
3213
 
@@ -2574,6 +3219,38 @@ class Selection {
2574
3219
  return Boolean(selection)
2575
3220
  }
2576
3221
 
3222
+ // When backspace is pressed on an empty list item that has siblings,
3223
+ // remove the empty item and place the cursor appropriately. Without this,
3224
+ // Lexical's default collapseAtStart converts the empty item into a paragraph
3225
+ // above the list, causing the cursor to jump away from the list content.
3226
+ //
3227
+ // This only applies when there IS a next sibling — if the empty item is the
3228
+ // last one in the list, Lexical's default (convert to paragraph) provides
3229
+ // the standard "exit list" behavior.
3230
+ #removeEmptyListItem() {
3231
+ const selection = $getSelection();
3232
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
3233
+
3234
+ const anchorNode = selection.anchor.getNode();
3235
+ const listItem = $getNearestNodeOfType(anchorNode, ListItemNode);
3236
+ if (!listItem) return false
3237
+
3238
+ if (!$isListItemStructurallyEmpty(listItem)) return false
3239
+
3240
+ const nextSibling = listItem.getNextSibling();
3241
+ if (!nextSibling) return false
3242
+
3243
+ const previousSibling = listItem.getPreviousSibling();
3244
+ if (previousSibling) {
3245
+ previousSibling.selectEnd();
3246
+ } else {
3247
+ nextSibling.selectStart();
3248
+ }
3249
+
3250
+ listItem.remove();
3251
+ return true
3252
+ }
3253
+
2577
3254
  // When the cursor is inside a list item, collapse the list item into a
2578
3255
  // paragraph instead of selecting the decorator. This lets the user
2579
3256
  // delete a list that immediately follows an attachment without the
@@ -2834,33 +3511,6 @@ function sanitize(html) {
2834
3511
  return DOMPurify.sanitize(html, buildConfig())
2835
3512
  }
2836
3513
 
2837
- function dasherize(value) {
2838
- return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
2839
- }
2840
-
2841
- function isUrl(string) {
2842
- try {
2843
- new URL(string);
2844
- return true
2845
- } catch {
2846
- return false
2847
- }
2848
- }
2849
-
2850
- function normalizeFilteredText(string) {
2851
- return string
2852
- .toLowerCase()
2853
- .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
2854
- }
2855
-
2856
- function filterMatches(text, potentialMatch) {
2857
- return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))
2858
- }
2859
-
2860
- function upcaseFirst(string) {
2861
- return string.charAt(0).toUpperCase() + string.slice(1)
2862
- }
2863
-
2864
3514
  class EditorConfiguration {
2865
3515
  #editorElement
2866
3516
  #config
@@ -2903,537 +3553,39 @@ class EditorConfiguration {
2903
3553
  }
2904
3554
  }
2905
3555
 
2906
- class CustomActionTextAttachmentNode extends DecoratorNode {
3556
+ async function loadFileIntoImage(file, image) {
3557
+ return new Promise((resolve) => {
3558
+ const reader = new FileReader();
3559
+
3560
+ image.addEventListener("load", () => {
3561
+ resolve(image);
3562
+ });
3563
+
3564
+ reader.onload = (event) => {
3565
+ image.src = event.target.result || null;
3566
+ };
3567
+
3568
+ reader.readAsDataURL(file);
3569
+ })
3570
+ }
3571
+
3572
+ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
2907
3573
  static getType() {
2908
- return "custom_action_text_attachment"
3574
+ return "action_text_attachment_upload"
2909
3575
  }
2910
3576
 
2911
3577
  static clone(node) {
2912
- return new CustomActionTextAttachmentNode({ ...node }, node.__key)
3578
+ return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
2913
3579
  }
2914
3580
 
2915
3581
  static importJSON(serializedNode) {
2916
- return new CustomActionTextAttachmentNode({ ...serializedNode })
3582
+ return new ActionTextAttachmentUploadNode({ ...serializedNode })
2917
3583
  }
2918
3584
 
3585
+ // Should never run since this is a transient node. Defined to remove console warning.
2919
3586
  static importDOM() {
2920
-
2921
- return {
2922
- [this.TAG_NAME]: (element) => {
2923
- if (!element.getAttribute("content")) {
2924
- return null
2925
- }
2926
-
2927
- return {
2928
- conversion: (attachment) => {
2929
- // Preserve initial space if present since Lexical removes it
2930
- const nodes = [];
2931
- const previousSibling = attachment.previousSibling;
2932
- if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
2933
- nodes.push($createTextNode(" "));
2934
- }
2935
-
2936
- nodes.push(new CustomActionTextAttachmentNode({
2937
- sgid: attachment.getAttribute("sgid"),
2938
- innerHtml: parseAttachmentContent(attachment.getAttribute("content")),
2939
- contentType: attachment.getAttribute("content-type")
2940
- }));
2941
-
2942
- nodes.push($createTextNode("\u2060"));
2943
-
2944
- return { node: nodes }
2945
- },
2946
- priority: 2
2947
- }
2948
- }
2949
- }
2950
- }
2951
-
2952
- static get TAG_NAME() {
2953
- return Lexxy.global.get("attachmentTagName")
2954
- }
2955
-
2956
- constructor({ tagName, sgid, contentType, innerHtml }, key) {
2957
- super(key);
2958
-
2959
- const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
2960
-
2961
- this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
2962
- this.sgid = sgid;
2963
- this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
2964
- this.innerHtml = innerHtml;
2965
- }
2966
-
2967
- createDOM() {
2968
- const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
2969
-
2970
- figure.insertAdjacentHTML("beforeend", this.innerHtml);
2971
-
2972
- const deleteButton = createElement("lexxy-node-delete-button");
2973
- figure.appendChild(deleteButton);
2974
-
2975
- return figure
2976
- }
2977
-
2978
- updateDOM() {
2979
- return false
2980
- }
2981
-
2982
- getTextContent() {
2983
- return "\ufeff"
2984
- }
2985
-
2986
- getReadableTextContent() {
2987
- return this.createDOM().textContent.trim() || `[${this.contentType}]`
2988
- }
2989
-
2990
- isInline() {
2991
- return true
2992
- }
2993
-
2994
- exportDOM() {
2995
- const attachment = createElement(this.tagName, {
2996
- sgid: this.sgid,
2997
- content: JSON.stringify(this.innerHtml),
2998
- "content-type": this.contentType
2999
- });
3000
-
3001
- return { element: attachment }
3002
- }
3003
-
3004
- exportJSON() {
3005
- return {
3006
- type: "custom_action_text_attachment",
3007
- version: 1,
3008
- tagName: this.tagName,
3009
- sgid: this.sgid,
3010
- contentType: this.contentType,
3011
- innerHtml: this.innerHtml
3012
- }
3013
- }
3014
-
3015
- decorate() {
3016
- return null
3017
- }
3018
-
3019
- }
3020
-
3021
- class FormatEscaper {
3022
- constructor(editorElement) {
3023
- this.editorElement = editorElement;
3024
- this.editor = editorElement.editor;
3025
- }
3026
-
3027
- monitor() {
3028
- this.editor.registerCommand(
3029
- KEY_ENTER_COMMAND,
3030
- (event) => this.#handleEnterKey(event),
3031
- COMMAND_PRIORITY_HIGH
3032
- );
3033
-
3034
- this.editor.registerCommand(
3035
- KEY_ARROW_DOWN_COMMAND,
3036
- (event) => this.#handleArrowDownInCodeBlock(event),
3037
- COMMAND_PRIORITY_NORMAL
3038
- );
3039
- }
3040
-
3041
- #handleEnterKey(event) {
3042
- const selection = $getSelection();
3043
- if (!$isRangeSelection(selection)) return false
3044
-
3045
- if (this.#handleCodeBlocks(event, selection)) return true
3046
-
3047
- const anchorNode = selection.anchor.getNode();
3048
-
3049
- if (!this.#isInsideBlockquote(anchorNode)) return false
3050
-
3051
- return this.#handleLists(event, anchorNode)
3052
- || this.#handleBlockquotes(event, anchorNode)
3053
- }
3054
-
3055
- #handleLists(event, anchorNode) {
3056
- if (this.#shouldEscapeFromEmptyListItem(anchorNode) || this.#shouldEscapeFromEmptyParagraphInListItem(anchorNode)) {
3057
- event.preventDefault();
3058
- this.#escapeFromList(anchorNode);
3059
- return true
3060
- }
3061
-
3062
- return false
3063
- }
3064
-
3065
- #handleBlockquotes(event, anchorNode) {
3066
- if (this.#shouldEscapeFromEmptyParagraphInBlockquote(anchorNode)) {
3067
- event.preventDefault();
3068
- this.#escapeFromBlockquote(anchorNode);
3069
- return true
3070
- }
3071
-
3072
- return false
3073
- }
3074
-
3075
- #isInsideBlockquote(node) {
3076
- let currentNode = node;
3077
-
3078
- while (currentNode) {
3079
- if ($isQuoteNode(currentNode)) {
3080
- return true
3081
- }
3082
- currentNode = currentNode.getParent();
3083
- }
3084
-
3085
- return false
3086
- }
3087
-
3088
- #shouldEscapeFromEmptyListItem(node) {
3089
- const listItem = this.#getListItemNode(node);
3090
- if (!listItem) return false
3091
-
3092
- return this.#isNodeEmpty(listItem)
3093
- }
3094
-
3095
- #shouldEscapeFromEmptyParagraphInListItem(node) {
3096
- const paragraph = this.#getParagraphNode(node);
3097
- if (!paragraph) return false
3098
-
3099
- if (!this.#isNodeEmpty(paragraph)) return false
3100
-
3101
- const parent = paragraph.getParent();
3102
- return parent && $isListItemNode(parent)
3103
- }
3104
-
3105
- #isNodeEmpty(node) {
3106
- if (node.getTextContent().trim() !== "") return false
3107
-
3108
- const children = node.getChildren();
3109
- if (children.length === 0) return true
3110
-
3111
- return children.every(child => {
3112
- if ($isLineBreakNode(child)) return true
3113
- return this.#isNodeEmpty(child)
3114
- })
3115
- }
3116
-
3117
- #getListItemNode(node) {
3118
- let currentNode = node;
3119
-
3120
- while (currentNode) {
3121
- if ($isListItemNode(currentNode)) {
3122
- return currentNode
3123
- }
3124
- currentNode = currentNode.getParent();
3125
- }
3126
-
3127
- return null
3128
- }
3129
-
3130
- #escapeFromList(anchorNode) {
3131
- const listItem = this.#getListItemNode(anchorNode);
3132
- if (!listItem) return
3133
-
3134
- const parentList = listItem.getParent();
3135
- if (!parentList || !$isListNode(parentList)) return
3136
-
3137
- const blockquote = parentList.getParent();
3138
- const isInBlockquote = blockquote && $isQuoteNode(blockquote);
3139
-
3140
- if (isInBlockquote) {
3141
- const listItemsAfter = this.#getListItemSiblingsAfter(listItem);
3142
- const nonEmptyListItems = listItemsAfter.filter(item => !this.#isNodeEmpty(item));
3143
-
3144
- if (nonEmptyListItems.length > 0) {
3145
- this.#splitBlockquoteWithList(blockquote, parentList, listItem, nonEmptyListItems);
3146
- return
3147
- }
3148
- }
3149
-
3150
- const paragraph = $createParagraphNode();
3151
- parentList.insertAfter(paragraph);
3152
-
3153
- listItem.remove();
3154
- paragraph.selectStart();
3155
- }
3156
-
3157
- #shouldEscapeFromEmptyParagraphInBlockquote(node) {
3158
- const paragraph = this.#getParagraphNode(node);
3159
- if (!paragraph) return false
3160
-
3161
- if (!this.#isNodeEmpty(paragraph)) return false
3162
-
3163
- const parent = paragraph.getParent();
3164
- return parent && $isQuoteNode(parent)
3165
- }
3166
-
3167
- #getParagraphNode(node) {
3168
- let currentNode = node;
3169
-
3170
- while (currentNode) {
3171
- if ($isParagraphNode(currentNode)) {
3172
- return currentNode
3173
- }
3174
- currentNode = currentNode.getParent();
3175
- }
3176
-
3177
- return null
3178
- }
3179
-
3180
- #escapeFromBlockquote(anchorNode) {
3181
- const paragraph = this.#getParagraphNode(anchorNode);
3182
- if (!paragraph) return
3183
-
3184
- const blockquote = paragraph.getParent();
3185
- if (!blockquote || !$isQuoteNode(blockquote)) return
3186
-
3187
- const siblingsAfter = this.#getSiblingsAfter(paragraph);
3188
- const nonEmptySiblings = siblingsAfter.filter(sibling => !this.#isNodeEmpty(sibling));
3189
-
3190
- if (nonEmptySiblings.length > 0) {
3191
- this.#splitBlockquote(blockquote, paragraph, nonEmptySiblings);
3192
- } else {
3193
- const newParagraph = $createParagraphNode();
3194
- blockquote.insertAfter(newParagraph);
3195
- paragraph.remove();
3196
- newParagraph.selectStart();
3197
- }
3198
- }
3199
-
3200
- #getSiblingsAfter(node) {
3201
- const siblings = [];
3202
- let sibling = node.getNextSibling();
3203
-
3204
- while (sibling) {
3205
- siblings.push(sibling);
3206
- sibling = sibling.getNextSibling();
3207
- }
3208
-
3209
- return siblings
3210
- }
3211
-
3212
- #getListItemSiblingsAfter(listItem) {
3213
- const siblings = [];
3214
- let sibling = listItem.getNextSibling();
3215
-
3216
- while (sibling) {
3217
- if ($isListItemNode(sibling)) {
3218
- siblings.push(sibling);
3219
- }
3220
- sibling = sibling.getNextSibling();
3221
- }
3222
-
3223
- return siblings
3224
- }
3225
-
3226
- #splitBlockquoteWithList(blockquote, parentList, emptyListItem, listItemsAfter) {
3227
- const blockquoteSiblingsAfterList = this.#getSiblingsAfter(parentList);
3228
- const nonEmptyBlockquoteSiblings = blockquoteSiblingsAfterList.filter(sibling => !this.#isNodeEmpty(sibling));
3229
-
3230
- const middleParagraph = $createParagraphNode();
3231
- blockquote.insertAfter(middleParagraph);
3232
-
3233
- const newList = $createListNode(parentList.getListType());
3234
-
3235
- const newBlockquote = $createQuoteNode();
3236
- middleParagraph.insertAfter(newBlockquote);
3237
- newBlockquote.append(newList);
3238
-
3239
- listItemsAfter.forEach(item => {
3240
- newList.append(item);
3241
- });
3242
-
3243
- nonEmptyBlockquoteSiblings.forEach(sibling => {
3244
- newBlockquote.append(sibling);
3245
- });
3246
-
3247
- emptyListItem.remove();
3248
-
3249
- this.#removeTrailingEmptyListItems(parentList);
3250
- this.#removeTrailingEmptyNodes(newBlockquote);
3251
-
3252
- if (parentList.getChildrenSize() === 0) {
3253
- parentList.remove();
3254
-
3255
- if (blockquote.getChildrenSize() === 0) {
3256
- blockquote.remove();
3257
- }
3258
- } else {
3259
- this.#removeTrailingEmptyNodes(blockquote);
3260
- }
3261
-
3262
- middleParagraph.selectStart();
3263
- }
3264
-
3265
- #removeTrailingEmptyListItems(list) {
3266
- const items = list.getChildren();
3267
- for (let i = items.length - 1; i >= 0; i--) {
3268
- const item = items[i];
3269
- if ($isListItemNode(item) && this.#isNodeEmpty(item)) {
3270
- item.remove();
3271
- } else {
3272
- break
3273
- }
3274
- }
3275
- }
3276
-
3277
- #removeTrailingEmptyNodes(blockquote) {
3278
- const children = blockquote.getChildren();
3279
- for (let i = children.length - 1; i >= 0; i--) {
3280
- const child = children[i];
3281
- if (this.#isNodeEmpty(child)) {
3282
- child.remove();
3283
- } else {
3284
- break
3285
- }
3286
- }
3287
- }
3288
-
3289
- #splitBlockquote(blockquote, emptyParagraph, siblingsAfter) {
3290
- const newParagraph = $createParagraphNode();
3291
- blockquote.insertAfter(newParagraph);
3292
-
3293
- const newBlockquote = $createQuoteNode();
3294
- newParagraph.insertAfter(newBlockquote);
3295
-
3296
- siblingsAfter.forEach(sibling => {
3297
- newBlockquote.append(sibling);
3298
- });
3299
-
3300
- emptyParagraph.remove();
3301
-
3302
- this.#removeTrailingEmptyNodes(blockquote);
3303
- this.#removeTrailingEmptyNodes(newBlockquote);
3304
-
3305
- newParagraph.selectStart();
3306
- }
3307
-
3308
- // Code blocks
3309
-
3310
- #handleCodeBlocks(event, selection) {
3311
- if (!selection.isCollapsed()) return false
3312
-
3313
- const codeNode = this.#getCodeNodeFromSelection(selection);
3314
- if (!codeNode) return false
3315
-
3316
- if (this.#isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode)) {
3317
- event?.preventDefault();
3318
- this.#exitCodeBlock(codeNode);
3319
- return true
3320
- }
3321
-
3322
- return false
3323
- }
3324
-
3325
- #handleArrowDownInCodeBlock(event) {
3326
- const selection = $getSelection();
3327
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
3328
-
3329
- const codeNode = this.#getCodeNodeFromSelection(selection);
3330
- if (!codeNode) return false
3331
-
3332
- if (this.#isCursorOnLastLineOfCodeBlock(selection, codeNode) && !codeNode.getNextSibling()) {
3333
- event?.preventDefault();
3334
- const paragraph = $createParagraphNode();
3335
- codeNode.insertAfter(paragraph);
3336
- paragraph.selectStart();
3337
- return true
3338
- }
3339
-
3340
- return false
3341
- }
3342
-
3343
- #getCodeNodeFromSelection(selection) {
3344
- const anchorNode = selection.anchor.getNode();
3345
- return $getNearestNodeOfType(anchorNode, CodeNode) || ($isCodeNode(anchorNode) ? anchorNode : null)
3346
- }
3347
-
3348
- #isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode) {
3349
- const children = codeNode.getChildren();
3350
- if (children.length === 0) return true
3351
-
3352
- const anchorNode = selection.anchor.getNode();
3353
- const anchorOffset = selection.anchor.offset;
3354
-
3355
- // Chromium: cursor on the CodeNode element after the last child (a line break)
3356
- if ($isCodeNode(anchorNode) && anchorOffset === children.length) {
3357
- return $isLineBreakNode(children[children.length - 1])
3358
- }
3359
-
3360
- // Firefox: cursor on an empty text node that follows a line break at the end
3361
- if ($isTextNode(anchorNode) && anchorNode.getTextContentSize() === 0 && anchorOffset === 0) {
3362
- const previousSibling = anchorNode.getPreviousSibling();
3363
- return $isLineBreakNode(previousSibling) && anchorNode.getNextSibling() === null
3364
- }
3365
-
3366
- return false
3367
- }
3368
-
3369
- #isCursorOnLastLineOfCodeBlock(selection, codeNode) {
3370
- const anchorNode = selection.anchor.getNode();
3371
- const children = codeNode.getChildren();
3372
- if (children.length === 0) return true
3373
-
3374
- const lastChild = children[children.length - 1];
3375
-
3376
- if ($isCodeNode(anchorNode) && selection.anchor.offset === children.length) return true
3377
- if (anchorNode === lastChild) return true
3378
-
3379
- const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
3380
- if (lastLineBreakIndex === -1) return true
3381
-
3382
- const anchorIndex = children.indexOf(anchorNode);
3383
- return anchorIndex > lastLineBreakIndex
3384
- }
3385
-
3386
- #exitCodeBlock(codeNode) {
3387
- const children = codeNode.getChildren();
3388
- const lastChild = children[children.length - 1];
3389
-
3390
- if ($isTextNode(lastChild) && lastChild.getTextContentSize() === 0) {
3391
- const previousSibling = lastChild.getPreviousSibling();
3392
- lastChild.remove();
3393
- if ($isLineBreakNode(previousSibling)) previousSibling.remove();
3394
- } else if ($isLineBreakNode(lastChild)) {
3395
- lastChild.remove();
3396
- }
3397
-
3398
- const paragraph = $createParagraphNode();
3399
- codeNode.insertAfter(paragraph);
3400
- paragraph.selectStart();
3401
- }
3402
- }
3403
-
3404
- async function loadFileIntoImage(file, image) {
3405
- return new Promise((resolve) => {
3406
- const reader = new FileReader();
3407
-
3408
- image.addEventListener("load", () => {
3409
- resolve(image);
3410
- });
3411
-
3412
- reader.onload = (event) => {
3413
- image.src = event.target.result || null;
3414
- };
3415
-
3416
- reader.readAsDataURL(file);
3417
- })
3418
- }
3419
-
3420
- class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3421
- static getType() {
3422
- return "action_text_attachment_upload"
3423
- }
3424
-
3425
- static clone(node) {
3426
- return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
3427
- }
3428
-
3429
- static importJSON(serializedNode) {
3430
- return new ActionTextAttachmentUploadNode({ ...serializedNode })
3431
- }
3432
-
3433
- // Should never run since this is a transient node. Defined to remove console warning.
3434
- static importDOM() {
3435
- return null
3436
- }
3587
+ return null
3588
+ }
3437
3589
 
3438
3590
  constructor(node, key) {
3439
3591
  const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node;
@@ -3622,14 +3774,11 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3622
3774
  const editorHasFocus = this.#editorHasFocus;
3623
3775
 
3624
3776
  this.editor.update(() => {
3625
- const shouldTransferNodeSelection = editorHasFocus && this.isSelected();
3626
-
3627
3777
  const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
3628
3778
  this.replace(replacementNode);
3629
3779
 
3630
- if (shouldTransferNodeSelection) {
3631
- const nodeSelection = $createNodeSelectionWith(replacementNode);
3632
- $setSelection(nodeSelection);
3780
+ if (editorHasFocus && $isRootOrShadowRoot(replacementNode.getParent())) {
3781
+ replacementNode.selectNext();
3633
3782
  }
3634
3783
  }, { tag: this.#backgroundUpdateTags });
3635
3784
  }
@@ -4002,7 +4151,6 @@ class Contents {
4002
4151
  this.editorElement = editorElement;
4003
4152
  this.editor = editorElement.editor;
4004
4153
 
4005
- new FormatEscaper(editorElement).monitor();
4006
4154
  }
4007
4155
 
4008
4156
  insertHtml(html, { tag } = {}) {
@@ -4011,6 +4159,7 @@ class Contents {
4011
4159
 
4012
4160
  insertDOM(doc, { tag } = {}) {
4013
4161
  this.#unwrapPlaceholderAnchors(doc);
4162
+ if (tag === PASTE_TAG) this.#stripTableCellColorStyles(doc);
4014
4163
 
4015
4164
  this.editor.update(() => {
4016
4165
  const selection = $getSelection();
@@ -4048,127 +4197,77 @@ class Contents {
4048
4197
  this.#insertLineBelowIfLastNode(node);
4049
4198
  }
4050
4199
 
4051
- insertNodeWrappingEachSelectedLine(newNodeFn) {
4052
- this.editor.update(() => {
4053
- const selection = $getSelection();
4054
- if (!$isRangeSelection(selection)) return
4055
-
4056
- const selectedNodes = selection.extract();
4057
-
4058
- selectedNodes.forEach((node) => {
4059
- const parent = node.getParent();
4060
- if (!parent) { return }
4061
-
4062
- const topLevelElement = node.getTopLevelElementOrThrow();
4063
- const wrappingNode = newNodeFn();
4064
- wrappingNode.append(...topLevelElement.getChildren());
4065
- topLevelElement.replace(wrappingNode);
4066
- });
4067
- });
4068
- }
4069
-
4070
- toggleNodeWrappingAllSelectedLines(isFormatAppliedFn, newNodeFn) {
4071
- this.editor.update(() => {
4072
- const selection = $getSelection();
4073
- if (!$isRangeSelection(selection)) return
4074
-
4075
- const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
4076
-
4077
- // Check if format is already applied
4078
- if (isFormatAppliedFn(topLevelElement)) {
4079
- this.removeFormattingFromSelectedLines();
4080
- } else {
4081
- this.#insertNodeWrappingAllSelectedLines(newNodeFn);
4082
- }
4083
- });
4084
- }
4085
-
4086
- toggleNodeWrappingAllSelectedNodes(isFormatAppliedFn, newNodeFn) {
4087
- this.editor.update(() => {
4088
- const selection = $getSelection();
4089
- if (!$isRangeSelection(selection)) return
4090
-
4091
- const topLevelElement = selection.anchor.getNode().getTopLevelElement();
4092
-
4093
- // Check if format is already applied
4094
- if (topLevelElement && isFormatAppliedFn(topLevelElement)) {
4095
- this.#unwrap(topLevelElement);
4096
- } else {
4097
- this.#insertNodeWrappingAllSelectedNodes(newNodeFn);
4098
- }
4099
- });
4100
- }
4101
-
4102
- removeFormattingFromSelectedLines() {
4103
- this.editor.update(() => {
4104
- const selection = $getSelection();
4105
- if (!$isRangeSelection(selection)) return
4200
+ applyParagraphFormat() {
4201
+ const selection = $getSelection();
4202
+ if (!$isRangeSelection(selection)) return
4106
4203
 
4107
- const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
4108
- const paragraph = $createParagraphNode();
4109
- paragraph.append(...topLevelElement.getChildren());
4110
- topLevelElement.replace(paragraph);
4111
- });
4204
+ $setBlocksType(selection, () => $createParagraphNode());
4112
4205
  }
4113
4206
 
4114
- hasSelectedText() {
4115
- let result = false;
4207
+ applyHeadingFormat(tag) {
4208
+ const selection = $getSelection();
4209
+ if (!$isRangeSelection(selection)) return
4116
4210
 
4117
- this.editor.read(() => {
4118
- const selection = $getSelection();
4119
- result = $isRangeSelection(selection) && !selection.isCollapsed();
4120
- });
4211
+ $setBlocksType(selection, () => $createHeadingNode(tag));
4212
+ }
4121
4213
 
4122
- return result
4214
+ #applyCodeBlockFormat() {
4215
+ const selection = $getSelection();
4216
+ if (!$isRangeSelection(selection)) return
4217
+
4218
+ $setBlocksType(selection, () => $createCodeNode("plain"));
4123
4219
  }
4124
4220
 
4125
- wrapSelectedSoftBreakLines(newNodeFn) {
4126
- let paragraphKey = null;
4127
- let selectedLineRange = null;
4221
+ toggleCodeBlock() {
4222
+ const selection = $getSelection();
4223
+ if (!$isRangeSelection(selection)) return
4128
4224
 
4129
- this.editor.getEditorState().read(() => {
4130
- const selection = $getSelection();
4131
- if (!$isRangeSelection(selection) || selection.isCollapsed()) return
4225
+ if (this.#insertNodeIfRoot($createCodeNode("plain"))) return
4132
4226
 
4133
- const paragraph = this.#getSelectedParagraphWithSoftLineBreaks(selection);
4134
- if (!paragraph) return
4227
+ const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
4228
+
4229
+ if (topLevelElement && !$isCodeNode(topLevelElement)) {
4230
+ this.#applyCodeBlockFormat();
4231
+ } else {
4232
+ this.applyParagraphFormat();
4233
+ }
4234
+ }
4135
4235
 
4136
- const lines = this.#splitParagraphIntoLines(paragraph);
4137
- selectedLineRange = this.#getSelectedLineRange(lines, selection);
4236
+ toggleBlockquote() {
4237
+ const selection = $getSelection();
4238
+ if (!$isRangeSelection(selection)) return
4138
4239
 
4139
- if (!selectedLineRange) return
4240
+ if (this.#insertNodeIfRoot($createQuoteNode())) return
4140
4241
 
4141
- const { start, end } = selectedLineRange;
4142
- if (start === 0 && end === lines.length - 1) return
4242
+ const topLevelElements = this.#topLevelElementsInSelection(selection);
4143
4243
 
4144
- paragraphKey = paragraph.getKey();
4145
- });
4244
+ const allQuoted = topLevelElements.length > 0 && topLevelElements.every($isQuoteNode);
4146
4245
 
4147
- if (!paragraphKey || !selectedLineRange) return false
4246
+ if (allQuoted) {
4247
+ topLevelElements.forEach(node => this.#unwrap(node));
4248
+ } else {
4249
+ topLevelElements.filter($isQuoteNode).forEach(node => this.#unwrap(node));
4148
4250
 
4149
- this.editor.update(() => {
4150
- const paragraph = $getNodeByKey(paragraphKey);
4151
- if (!paragraph || !$isParagraphNode(paragraph)) return
4251
+ this.#splitParagraphsAtLineBreaks(selection);
4152
4252
 
4153
- const lines = this.#splitParagraphIntoLines(paragraph);
4154
- this.#replaceParagraphWithWrappedSelectedLines(paragraph, lines, selectedLineRange, newNodeFn);
4155
- });
4253
+ const elements = this.#topLevelElementsInSelection(selection);
4254
+ if (elements.length === 0) return
4156
4255
 
4157
- return true
4256
+ const blockquote = $createQuoteNode();
4257
+ elements[0].insertBefore(blockquote);
4258
+ elements.forEach((element) => blockquote.append(element));
4259
+ }
4158
4260
  }
4159
4261
 
4160
- unwrapSelectedListItems() {
4161
- this.editor.update(() => {
4162
- const selection = $getSelection();
4163
- if (!$isRangeSelection(selection)) return
4262
+ hasSelectedText() {
4263
+ let result = false;
4164
4264
 
4165
- const { listItems, parentLists } = this.#collectSelectedListItems(selection);
4166
- if (listItems.size > 0) {
4167
- const newParagraphs = this.#convertListItemsToParagraphs(listItems);
4168
- this.#removeEmptyParentLists(parentLists);
4169
- this.#selectNewParagraphs(newParagraphs);
4170
- }
4265
+ this.editor.read(() => {
4266
+ const selection = $getSelection();
4267
+ result = $isRangeSelection(selection) && !selection.isCollapsed();
4171
4268
  });
4269
+
4270
+ return result
4172
4271
  }
4173
4272
 
4174
4273
  createLink(url) {
@@ -4250,503 +4349,197 @@ class Contents {
4250
4349
  replaceTextBackUntil(stringToReplace, replacementNodes) {
4251
4350
  replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ];
4252
4351
 
4253
- const selection = $getSelection();
4254
- const { anchorNode, offset } = this.#getTextAnchorData();
4255
- if (!anchorNode) return
4256
-
4257
- const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
4258
- if (lastIndex === -1) return
4259
-
4260
- this.#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes);
4261
- }
4262
-
4263
- createParagraphAfterNode(node, text) {
4264
- const newParagraph = $createParagraphNode();
4265
- node.insertAfter(newParagraph);
4266
- newParagraph.selectStart();
4267
-
4268
- // Insert the typed text
4269
- if (text) {
4270
- newParagraph.append($createTextNode(text));
4271
- newParagraph.select(1, 1); // Place cursor after the text
4272
- }
4273
- }
4274
-
4275
- createParagraphBeforeNode(node, text) {
4276
- const newParagraph = $createParagraphNode();
4277
- node.insertBefore(newParagraph);
4278
- newParagraph.selectStart();
4279
-
4280
- // Insert the typed text
4281
- if (text) {
4282
- newParagraph.append($createTextNode(text));
4283
- newParagraph.select(1, 1); // Place cursor after the text
4284
- }
4285
- }
4286
-
4287
- uploadFiles(files, { selectLast } = {}) {
4288
- if (!this.editorElement.supportsAttachments) {
4289
- console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
4290
- return
4291
- }
4292
- const validFiles = Array.from(files).filter(this.#shouldUploadFile.bind(this));
4293
-
4294
- this.editor.update(() => {
4295
- const uploader = Uploader.for(this.editorElement, validFiles);
4296
- uploader.$uploadFiles();
4297
-
4298
- if (selectLast && uploader.nodes?.length) {
4299
- const lastNode = uploader.nodes.at(-1);
4300
- lastNode.selectEnd();
4301
- this.#normalizeSelectionInShadowRoot();
4302
- }
4303
- });
4304
- }
4305
-
4306
- replaceNodeWithHTML(nodeKey, html, options = {}) {
4307
- this.editor.update(() => {
4308
- const node = $getNodeByKey(nodeKey);
4309
- if (!node) return
4310
-
4311
- const selection = $getSelection();
4312
- let wasSelected = false;
4313
-
4314
- if ($isRangeSelection(selection)) {
4315
- const selectedNodes = selection.getNodes();
4316
- wasSelected = selectedNodes.includes(node) || selectedNodes.some(n => n.getParent() === node);
4317
-
4318
- if (wasSelected) {
4319
- $setSelection(null);
4320
- }
4321
- }
4322
-
4323
- const replacementNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
4324
- node.replace(replacementNode);
4325
-
4326
- if (wasSelected) {
4327
- replacementNode.selectEnd();
4328
- }
4329
- });
4330
- }
4331
-
4332
- insertHTMLBelowNode(nodeKey, html, options = {}) {
4333
- this.editor.update(() => {
4334
- const node = $getNodeByKey(nodeKey);
4335
- if (!node) return
4336
-
4337
- const previousNode = node.getTopLevelElement() || node;
4338
-
4339
- const newNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
4340
- previousNode.insertAfter(newNode);
4341
- });
4342
- }
4343
-
4344
- #insertUploadNodes(nodes) {
4345
- if (nodes.every($isActionTextAttachmentNode)) {
4346
- const uploader = Uploader.for(this.editorElement, []);
4347
- uploader.nodes = nodes;
4348
- uploader.$insertUploadNodes();
4349
- return true
4350
- }
4351
- }
4352
-
4353
- #insertLineBelowIfLastNode(node) {
4354
- this.editor.update(() => {
4355
- const nextSibling = node.getNextSibling();
4356
- if (!nextSibling) {
4357
- const newParagraph = $createParagraphNode();
4358
- node.insertAfter(newParagraph);
4359
- newParagraph.selectStart();
4360
- }
4361
- });
4362
- }
4363
-
4364
- #unwrap(node) {
4365
- const children = node.getChildren();
4366
-
4367
- if (children.length == 0) {
4368
- node.insertBefore($createParagraphNode());
4369
- } else {
4370
- children.forEach((child) => {
4371
- if ($isTextNode(child) && child.getTextContent().trim() !== "") {
4372
- const newParagraph = $createParagraphNode();
4373
- newParagraph.append(child);
4374
- node.insertBefore(newParagraph);
4375
- } else if (!$isLineBreakNode(child)) {
4376
- node.insertBefore(child);
4377
- }
4378
- });
4379
- }
4380
-
4381
- node.remove();
4382
- }
4383
-
4384
- // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
4385
- // from rendered views where mentions and interactive elements are wrapped in
4386
- // <a href="#"> tags. Unwrap them so their text content pastes as plain text
4387
- // and real links are preserved.
4388
- #unwrapPlaceholderAnchors(doc) {
4389
- for (const anchor of doc.querySelectorAll("a")) {
4390
- const href = anchor.getAttribute("href") || "";
4391
- if (href === "" || href === "#") {
4392
- anchor.replaceWith(...anchor.childNodes);
4393
- }
4394
- }
4395
- }
4396
-
4397
- #insertNodeWrappingAllSelectedNodes(newNodeFn) {
4398
- this.editor.update(() => {
4399
- const selection = $getSelection();
4400
- if (!$isRangeSelection(selection)) return
4401
-
4402
- const selectedNodes = selection.extract();
4403
- if (selectedNodes.length === 0) {
4404
- return
4405
- }
4406
-
4407
- const topLevelElements = new Set();
4408
- selectedNodes.forEach((node) => {
4409
- const topLevel = node.getTopLevelElementOrThrow();
4410
- topLevelElements.add(topLevel);
4411
- });
4412
-
4413
- const elements = this.#withoutTrailingEmptyParagraphs(Array.from(topLevelElements));
4414
- if (elements.length === 0) {
4415
- this.#removeStandaloneEmptyParagraph();
4416
- this.insertAtCursor(newNodeFn());
4417
- return
4418
- }
4419
-
4420
- const wrappingNode = newNodeFn();
4421
- elements[0].insertBefore(wrappingNode);
4422
- elements.forEach((element) => {
4423
- wrappingNode.append(element);
4424
- });
4425
- });
4426
- }
4427
-
4428
- #withoutTrailingEmptyParagraphs(elements) {
4429
- let lastNonEmptyIndex = elements.length - 1;
4430
-
4431
- // Find the last non-empty paragraph
4432
- while (lastNonEmptyIndex >= 0) {
4433
- const element = elements[lastNonEmptyIndex];
4434
- if (!$isParagraphNode(element) || !this.#isElementEmpty(element)) {
4435
- break
4436
- }
4437
- lastNonEmptyIndex--;
4438
- }
4439
-
4440
- return elements.slice(0, lastNonEmptyIndex + 1)
4441
- }
4442
-
4443
- #isElementEmpty(element) {
4444
- // Check text content first
4445
- if (element.getTextContent().trim() !== "") return false
4446
-
4447
- // Check if it only contains line breaks
4448
- const children = element.getChildren();
4449
- return children.length === 0 || children.every(child => $isLineBreakNode(child))
4450
- }
4451
-
4452
- #removeStandaloneEmptyParagraph() {
4453
- const root = $getRoot();
4454
- if (root.getChildrenSize() === 1) {
4455
- const firstChild = root.getFirstChild();
4456
- if (firstChild && $isParagraphNode(firstChild) && this.#isElementEmpty(firstChild)) {
4457
- firstChild.remove();
4458
- }
4459
- }
4460
- }
4461
-
4462
- #insertNodeWrappingAllSelectedLines(newNodeFn) {
4463
- this.editor.update(() => {
4464
- const selection = $getSelection();
4465
- if (!$isRangeSelection(selection)) return
4466
-
4467
- if (selection.isCollapsed()) {
4468
- this.#wrapCurrentLine(selection, newNodeFn);
4469
- } else {
4470
- this.#wrapMultipleSelectedLines(selection, newNodeFn);
4471
- }
4472
- });
4473
- }
4474
-
4475
- #wrapCurrentLine(selection, newNodeFn) {
4476
- const anchorNode = selection.anchor.getNode();
4477
-
4478
- const topLevelElement = anchorNode.getTopLevelElementOrThrow();
4479
-
4480
- if (topLevelElement.getTextContent()) {
4481
- const wrappingNode = newNodeFn();
4482
- wrappingNode.append(...topLevelElement.getChildren());
4483
- topLevelElement.replace(wrappingNode);
4484
- } else {
4485
- selection.insertNodes([ newNodeFn() ]);
4486
- }
4487
- }
4488
-
4489
- #wrapMultipleSelectedLines(selection, newNodeFn) {
4490
- const selectedParagraphs = this.#extractSelectedParagraphs(selection);
4491
- if (selectedParagraphs.length === 0) return
4492
-
4493
- const { lineSet, nodesToDelete } = this.#extractUniqueLines(selectedParagraphs);
4494
- if (lineSet.size === 0) return
4495
-
4496
- const wrappingNode = this.#createWrappingNodeWithLines(newNodeFn, lineSet);
4497
- this.#replaceWithWrappingNode(selection, wrappingNode);
4498
- this.#removeNodes(nodesToDelete);
4499
- }
4500
-
4501
- #extractSelectedParagraphs(selection) {
4502
- const selectedNodes = selection.extract();
4503
- const selectedParagraphs = selectedNodes
4504
- .map((node) => this.#getParagraphFromNode(node))
4505
- .filter(Boolean);
4506
-
4507
- $setSelection(null);
4508
- return selectedParagraphs
4509
- }
4510
-
4511
- #getParagraphFromNode(node) {
4512
- if ($isParagraphNode(node)) return node
4513
- if ($isTextNode(node) && node.getParent() && $isParagraphNode(node.getParent())) {
4514
- return node.getParent()
4515
- }
4516
- return null
4517
- }
4518
-
4519
- #extractUniqueLines(selectedParagraphs) {
4520
- const lineSet = new Set();
4521
- const nodesToDelete = new Set();
4522
-
4523
- selectedParagraphs.forEach((paragraphNode) => {
4524
- const textContent = paragraphNode.getTextContent();
4525
- if (textContent) {
4526
- textContent.split("\n").forEach((line) => {
4527
- if (line.trim()) lineSet.add(line);
4528
- });
4529
- }
4530
- nodesToDelete.add(paragraphNode);
4531
- });
4532
-
4533
- return { lineSet, nodesToDelete }
4534
- }
4535
-
4536
- #createWrappingNodeWithLines(newNodeFn, lineSet) {
4537
- const wrappingNode = newNodeFn();
4538
- const lines = Array.from(lineSet);
4539
-
4540
- lines.forEach((lineText, index) => {
4541
- wrappingNode.append($createTextNode(lineText));
4542
- if (index < lines.length - 1) {
4543
- wrappingNode.append($createLineBreakNode());
4544
- }
4545
- });
4546
-
4547
- return wrappingNode
4548
- }
4549
-
4550
- #replaceWithWrappingNode(selection, wrappingNode) {
4551
- const anchorNode = selection.anchor.getNode();
4552
- const parent = anchorNode.getParent();
4553
- if (parent) {
4554
- parent.replace(wrappingNode);
4555
- }
4556
- }
4557
-
4558
- #removeNodes(nodesToDelete) {
4559
- nodesToDelete.forEach((node) => node.remove());
4560
- }
4561
-
4562
- #getSelectedParagraphWithSoftLineBreaks(selection) {
4563
- const anchorParagraph = this.#getParagraphFromNode(selection.anchor.getNode());
4564
- const focusParagraph = this.#getParagraphFromNode(selection.focus.getNode());
4352
+ const selection = $getSelection();
4353
+ const { anchorNode, offset } = this.#getTextAnchorData();
4354
+ if (!anchorNode) return
4565
4355
 
4566
- if (!anchorParagraph || anchorParagraph !== focusParagraph) return null
4567
- if ($isQuoteNode(anchorParagraph.getParent())) return null
4356
+ const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
4357
+ if (lastIndex === -1) return
4568
4358
 
4569
- return this.#paragraphHasSoftLineBreaks(anchorParagraph) ? anchorParagraph : null
4359
+ this.#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes);
4570
4360
  }
4571
4361
 
4572
- #paragraphHasSoftLineBreaks(paragraph) {
4573
- return paragraph.getChildren().some((child) => $isLineBreakNode(child))
4574
- }
4362
+ uploadFiles(files, { selectLast } = {}) {
4363
+ if (!this.editorElement.supportsAttachments) {
4364
+ console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
4365
+ return
4366
+ }
4367
+ const validFiles = Array.from(files).filter(this.#shouldUploadFile.bind(this));
4575
4368
 
4576
- #splitParagraphIntoLines(paragraph) {
4577
- const lines = [ [] ];
4369
+ this.editor.update(() => {
4370
+ const uploader = Uploader.for(this.editorElement, validFiles);
4371
+ uploader.$uploadFiles();
4578
4372
 
4579
- paragraph.getChildren().forEach((child) => {
4580
- if ($isLineBreakNode(child)) {
4581
- lines.push([]);
4582
- } else {
4583
- lines[lines.length - 1].push(child);
4373
+ if (selectLast && uploader.nodes?.length) {
4374
+ const lastNode = uploader.nodes.at(-1);
4375
+ lastNode.selectEnd();
4376
+ this.#normalizeSelectionInShadowRoot();
4584
4377
  }
4585
4378
  });
4586
-
4587
- return lines
4588
4379
  }
4589
4380
 
4590
- #getSelectedLineRange(lines, selection) {
4591
- const selectedNodeKeys = new Set(
4592
- selection.getNodes().map((node) => node.getKey())
4593
- );
4594
-
4595
- selectedNodeKeys.add(selection.anchor.getNode().getKey());
4596
- selectedNodeKeys.add(selection.focus.getNode().getKey());
4597
-
4598
- const selectedLineIndexes = lines
4599
- .map((lineNodes, index) => {
4600
- return lineNodes.some((node) => selectedNodeKeys.has(node.getKey())) ? index : null
4601
- })
4602
- .filter((index) => index !== null);
4381
+ replaceNodeWithHTML(nodeKey, html, options = {}) {
4382
+ this.editor.update(() => {
4383
+ const node = $getNodeByKey(nodeKey);
4384
+ if (!node) return
4603
4385
 
4604
- if (selectedLineIndexes.length === 0) return null
4386
+ const selection = $getSelection();
4387
+ let wasSelected = false;
4605
4388
 
4606
- return {
4607
- start: selectedLineIndexes[0],
4608
- end: selectedLineIndexes[selectedLineIndexes.length - 1]
4609
- }
4610
- }
4389
+ if ($isRangeSelection(selection)) {
4390
+ const selectedNodes = selection.getNodes();
4391
+ wasSelected = selectedNodes.includes(node) || selectedNodes.some(n => n.getParent() === node);
4611
4392
 
4612
- #replaceParagraphWithWrappedSelectedLines(paragraph, lines, { start, end }, newNodeFn) {
4613
- const insertedNodes = [];
4393
+ if (wasSelected) {
4394
+ $setSelection(null);
4395
+ }
4396
+ }
4614
4397
 
4615
- this.#appendParagraphsForLines(insertedNodes, lines.slice(0, start));
4398
+ const replacementNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
4399
+ node.replace(replacementNode);
4616
4400
 
4617
- const wrappingNode = newNodeFn();
4618
- lines.slice(start, end + 1).forEach((lineNodes) => {
4619
- wrappingNode.append(this.#createParagraphFromLine(lineNodes));
4401
+ if (wasSelected) {
4402
+ replacementNode.selectEnd();
4403
+ }
4620
4404
  });
4621
- insertedNodes.push(wrappingNode);
4405
+ }
4622
4406
 
4623
- this.#appendParagraphsForLines(insertedNodes, lines.slice(end + 1));
4407
+ insertHTMLBelowNode(nodeKey, html, options = {}) {
4408
+ this.editor.update(() => {
4409
+ const node = $getNodeByKey(nodeKey);
4410
+ if (!node) return
4624
4411
 
4625
- let previousNode = null;
4626
- insertedNodes.forEach((node) => {
4627
- if (previousNode) {
4628
- previousNode.insertAfter(node);
4629
- } else {
4630
- paragraph.insertBefore(node);
4631
- }
4412
+ const previousNode = node.getTopLevelElement() || node;
4632
4413
 
4633
- previousNode = node;
4414
+ const newNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
4415
+ previousNode.insertAfter(newNode);
4634
4416
  });
4635
-
4636
- paragraph.remove();
4637
4417
  }
4638
4418
 
4639
- #appendParagraphsForLines(insertedNodes, lines) {
4640
- lines.forEach((lineNodes) => {
4641
- insertedNodes.push(this.#createParagraphFromLine(lineNodes));
4642
- });
4643
- }
4419
+ #insertNodeIfRoot(node) {
4420
+ const selection = $getSelection();
4421
+ if (!$isRangeSelection(selection)) return false
4644
4422
 
4645
- #createParagraphFromLine(lineNodes) {
4646
- const paragraph = $createParagraphNode();
4423
+ const anchorNode = selection.anchor.getNode();
4424
+ if ($isRootOrShadowRoot(anchorNode)) {
4425
+ anchorNode.append(node);
4426
+ node.selectEnd();
4647
4427
 
4648
- if (lineNodes.length === 0) {
4649
- paragraph.append($createLineBreakNode());
4650
- } else {
4651
- paragraph.append(...lineNodes);
4428
+ return true
4652
4429
  }
4653
4430
 
4654
- return paragraph
4431
+ return false
4655
4432
  }
4656
4433
 
4657
- #collectSelectedListItems(selection) {
4658
- const nodes = selection.getNodes();
4659
- const listItems = new Set();
4660
- const parentLists = new Set();
4434
+ #splitParagraphsAtLineBreaks(selection) {
4435
+ const anchorKey = selection.anchor.getNode().getKey();
4436
+ const focusKey = selection.focus.getNode().getKey();
4437
+ const topLevelElements = this.#topLevelElementsInSelection(selection);
4661
4438
 
4662
- for (const node of nodes) {
4663
- const listItem = $getNearestNodeOfType(node, ListItemNode);
4664
- if (listItem) {
4665
- listItems.add(listItem);
4666
- const parentList = listItem.getParent();
4667
- if (parentList && $isListNode(parentList)) {
4668
- parentLists.add(parentList);
4669
- }
4670
- }
4671
- }
4439
+ for (const element of topLevelElements) {
4440
+ if (!$isParagraphNode(element)) continue
4672
4441
 
4673
- return { listItems, parentLists }
4674
- }
4442
+ const children = element.getChildren();
4443
+ if (!children.some($isLineBreakNode)) continue
4675
4444
 
4676
- #convertListItemsToParagraphs(listItems) {
4677
- const newParagraphs = [];
4445
+ // Check whether this paragraph needs splitting: skip only if neither
4446
+ // selection endpoint is inside it (meaning it's a middle paragraph
4447
+ // fully between anchor and focus with no partial lines to split off).
4448
+ const hasEndpoint = children.some(child =>
4449
+ child.getKey() === anchorKey || child.getKey() === focusKey
4450
+ );
4451
+ if (!hasEndpoint) continue
4678
4452
 
4679
- for (const listItem of listItems) {
4680
- const paragraph = this.#convertListItemToParagraph(listItem);
4681
- if (paragraph) {
4682
- newParagraphs.push(paragraph);
4453
+ const groups = [ [] ];
4454
+ for (const child of children) {
4455
+ if ($isLineBreakNode(child)) {
4456
+ groups.push([]);
4457
+ child.remove();
4458
+ } else {
4459
+ groups[groups.length - 1].push(child);
4460
+ }
4683
4461
  }
4684
- }
4685
4462
 
4686
- return newParagraphs
4463
+ for (const group of groups) {
4464
+ if (group.length === 0) continue
4465
+ const paragraph = $createParagraphNode();
4466
+ group.forEach(child => paragraph.append(child));
4467
+ element.insertBefore(paragraph);
4468
+ }
4469
+ if (groups.some(group => group.length > 0)) element.remove();
4470
+ }
4687
4471
  }
4688
4472
 
4689
- #convertListItemToParagraph(listItem) {
4690
- const parentList = listItem.getParent();
4691
- if (!parentList || !$isListNode(parentList)) return null
4692
-
4693
- const paragraph = $createParagraphNode();
4694
- const sublists = this.#extractSublistsAndContent(listItem, paragraph);
4695
-
4696
- listItem.insertAfter(paragraph);
4697
- this.#insertSublists(paragraph, sublists);
4698
- listItem.remove();
4699
-
4700
- return paragraph
4473
+ #topLevelElementsInSelection(selection) {
4474
+ const elements = new Set();
4475
+ for (const node of selection.getNodes()) {
4476
+ const topLevel = node.getTopLevelElement();
4477
+ if (topLevel) elements.add(topLevel);
4478
+ }
4479
+ return Array.from(elements)
4701
4480
  }
4702
4481
 
4703
- #extractSublistsAndContent(listItem, paragraph) {
4704
- const sublists = [];
4482
+ #insertUploadNodes(nodes) {
4483
+ if (nodes.every($isActionTextAttachmentNode)) {
4484
+ const uploader = Uploader.for(this.editorElement, []);
4485
+ uploader.nodes = nodes;
4486
+ uploader.$insertUploadNodes();
4487
+ return true
4488
+ }
4489
+ }
4705
4490
 
4706
- listItem.getChildren().forEach((child) => {
4707
- if ($isListNode(child)) {
4708
- sublists.push(child);
4709
- } else {
4710
- paragraph.append(child);
4491
+ #insertLineBelowIfLastNode(node) {
4492
+ this.editor.update(() => {
4493
+ const nextSibling = node.getNextSibling();
4494
+ if (!nextSibling) {
4495
+ const newParagraph = $createParagraphNode();
4496
+ node.insertAfter(newParagraph);
4497
+ newParagraph.selectStart();
4711
4498
  }
4712
4499
  });
4713
-
4714
- return sublists
4715
4500
  }
4716
4501
 
4717
- #insertSublists(paragraph, sublists) {
4718
- sublists.forEach((sublist) => {
4719
- paragraph.insertAfter(sublist);
4720
- });
4721
- }
4502
+ #unwrap(node) {
4503
+ const children = node.getChildren();
4722
4504
 
4723
- #removeEmptyParentLists(parentLists) {
4724
- for (const parentList of parentLists) {
4725
- if ($isListNode(parentList) && parentList.getChildrenSize() === 0) {
4726
- parentList.remove();
4727
- }
4505
+ if (children.length == 0) {
4506
+ node.insertBefore($createParagraphNode());
4507
+ } else {
4508
+ children.forEach((child) => {
4509
+ if ($isTextNode(child) && child.getTextContent().trim() !== "") {
4510
+ const newParagraph = $createParagraphNode();
4511
+ newParagraph.append(child);
4512
+ node.insertBefore(newParagraph);
4513
+ } else if (!$isLineBreakNode(child)) {
4514
+ node.insertBefore(child);
4515
+ }
4516
+ });
4728
4517
  }
4729
- }
4730
4518
 
4731
- #selectNewParagraphs(newParagraphs) {
4732
- if (newParagraphs.length === 0) return
4733
-
4734
- const firstParagraph = newParagraphs[0];
4735
- const lastParagraph = newParagraphs[newParagraphs.length - 1];
4519
+ node.remove();
4520
+ }
4736
4521
 
4737
- if (newParagraphs.length === 1) {
4738
- firstParagraph.selectEnd();
4739
- } else {
4740
- this.#selectParagraphRange(firstParagraph, lastParagraph);
4522
+ // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
4523
+ // from rendered views where mentions and interactive elements are wrapped in
4524
+ // <a href="#"> tags. Unwrap them so their text content pastes as plain text
4525
+ // and real links are preserved.
4526
+ #unwrapPlaceholderAnchors(doc) {
4527
+ for (const anchor of doc.querySelectorAll("a")) {
4528
+ const href = anchor.getAttribute("href") || "";
4529
+ if (href === "" || href === "#") {
4530
+ anchor.replaceWith(...anchor.childNodes);
4531
+ }
4741
4532
  }
4742
4533
  }
4743
4534
 
4744
- #selectParagraphRange(firstParagraph, lastParagraph) {
4745
- firstParagraph.selectStart();
4746
- const currentSelection = $getSelection();
4747
- if (currentSelection && $isRangeSelection(currentSelection)) {
4748
- currentSelection.anchor.set(firstParagraph.getKey(), 0, "element");
4749
- currentSelection.focus.set(lastParagraph.getKey(), lastParagraph.getChildrenSize(), "element");
4535
+ // Table cells copied from a page inherit the source theme's inline color
4536
+ // styles (e.g. dark-mode backgrounds). Strip them so pasted tables adopt
4537
+ // the current theme instead of carrying stale colors.
4538
+ #stripTableCellColorStyles(doc) {
4539
+ for (const cell of doc.querySelectorAll("td, th")) {
4540
+ cell.style.removeProperty("background-color");
4541
+ cell.style.removeProperty("background");
4542
+ cell.style.removeProperty("color");
4750
4543
  }
4751
4544
  }
4752
4545
 
@@ -4773,9 +4566,8 @@ class Contents {
4773
4566
  const textBeforeString = fullText.slice(0, lastIndex);
4774
4567
  const textAfterCursor = fullText.slice(offset);
4775
4568
 
4776
- const trailingSpacer = this.#hasInlineDecoratorNode(replacementNodes) ? "\u2060" : " ";
4777
4569
  const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString);
4778
- const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || trailingSpacer);
4570
+ const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || " ");
4779
4571
 
4780
4572
  anchorNode.replace(textNodeBefore);
4781
4573
 
@@ -4787,10 +4579,6 @@ class Contents {
4787
4579
  textNodeAfter.select(cursorOffset, cursorOffset);
4788
4580
  }
4789
4581
 
4790
- #hasInlineDecoratorNode(nodes) {
4791
- return nodes.some(node => node instanceof CustomActionTextAttachmentNode && node.isInline())
4792
- }
4793
-
4794
4582
  #cloneTextNodeFormatting(anchorNode, selection, text) {
4795
4583
  const parent = anchorNode.getParent();
4796
4584
  const fallbackFormat = parent?.getTextFormat?.() || 0;
@@ -4887,7 +4675,9 @@ class Clipboard {
4887
4675
  return true
4888
4676
  }
4889
4677
 
4890
- return this.#handlePastedFiles(clipboardData)
4678
+ const handled = this.#handlePastedFiles(clipboardData);
4679
+ if (handled) event.preventDefault();
4680
+ return handled
4891
4681
  }
4892
4682
 
4893
4683
  #isPlainTextOrURLPasted(clipboardData) {
@@ -4985,14 +4775,21 @@ class Clipboard {
4985
4775
  return true
4986
4776
  }
4987
4777
 
4988
- if (html) {
4778
+ if (html && !this.#isLexicalClipboardData(clipboardData)) {
4989
4779
  this.contents.insertHtml(html, { tag: PASTE_TAG });
4990
4780
  return true
4991
4781
  }
4992
4782
 
4993
- this.#uploadFilesPreservingScroll(files);
4783
+ if (files.length) {
4784
+ this.#uploadFilesPreservingScroll(files);
4785
+ return true
4786
+ }
4994
4787
 
4995
- return true
4788
+ return false
4789
+ }
4790
+
4791
+ #isLexicalClipboardData(clipboardData) {
4792
+ return Array.from(clipboardData.types).includes("application/x-lexical-editor")
4996
4793
  }
4997
4794
 
4998
4795
  #isCopiedImageHTML(html) {
@@ -5164,7 +4961,27 @@ class ProvisionalParagraphNode extends ParagraphNode {
5164
4961
  // https://github.com/facebook/lexical/blob/f1e4f66014377b1f2595aec2b0ee17f5b7ef4dfc/packages/lexical/src/LexicalNode.ts#L646
5165
4962
  isSelected(selection = null) {
5166
4963
  const targetSelection = selection || $getSelection();
5167
- return targetSelection?.getNodes().some(node => node.is(this) || this.isParentOf(node))
4964
+ if (!targetSelection) return false
4965
+
4966
+ if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
4967
+
4968
+ // A collapsed range selection on the parent element at an offset adjacent to
4969
+ // this node means the caret is visually at this paragraph's position. Treat it
4970
+ // as selected so the paragraph is visible and the caret renders correctly.
4971
+ //
4972
+ // Both the offset matching our index (cursor just before us) and index + 1
4973
+ // (cursor just after us) count, because the provisional paragraph is an
4974
+ // invisible spacer: the browser resolves both offsets to the same visual spot.
4975
+ if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
4976
+ const { anchor } = targetSelection;
4977
+ const parent = this.getParent();
4978
+ if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
4979
+ const index = this.getIndexWithinParent();
4980
+ return anchor.offset === index || anchor.offset === index + 1
4981
+ }
4982
+ }
4983
+
4984
+ return false
5168
4985
  }
5169
4986
 
5170
4987
  removeUnlessRequired(self = this.getLatest()) {
@@ -5914,6 +5731,178 @@ function $moveSelectionBeforeGallery(anchor) {
5914
5731
  return true
5915
5732
  }
5916
5733
 
5734
+ class EarlyEscapeCodeNode extends CodeNode {
5735
+ $config() {
5736
+ return this.config("early_escape_code", { extends: CodeNode })
5737
+ }
5738
+
5739
+ static $fromSelection(selection) {
5740
+ const anchorNode = selection.anchor.getNode();
5741
+ return $getNearestNodeOfType(anchorNode, EarlyEscapeCodeNode)
5742
+ || (anchorNode instanceof EarlyEscapeCodeNode ? anchorNode : null)
5743
+ }
5744
+
5745
+ insertNewAfter(selection, restoreSelection) {
5746
+ if (!selection.isCollapsed()) return super.insertNewAfter(selection, restoreSelection)
5747
+
5748
+ if (this.#isCursorOnEmptyLastLine(selection)) {
5749
+ $trimTrailingBlankNodes(this);
5750
+
5751
+ const paragraph = $createParagraphNode();
5752
+ this.insertAfter(paragraph);
5753
+ return paragraph
5754
+ }
5755
+
5756
+ return super.insertNewAfter(selection, restoreSelection)
5757
+ }
5758
+
5759
+ #isCursorOnEmptyLastLine(selection) {
5760
+ if (!$isCursorOnLastLine(selection)) return false
5761
+
5762
+ const textContent = this.getTextContent();
5763
+ return textContent === "" || textContent.endsWith("\n")
5764
+ }
5765
+
5766
+ }
5767
+
5768
+ class EarlyEscapeListItemNode extends ListItemNode {
5769
+ $config() {
5770
+ return this.config("early_escape_listitem", { extends: ListItemNode })
5771
+ }
5772
+
5773
+ insertNewAfter(selection, restoreSelection) {
5774
+ if (this.#shouldEscape(selection)) {
5775
+ return this.#escapeFromList()
5776
+ }
5777
+
5778
+ return super.insertNewAfter(selection, restoreSelection)
5779
+ }
5780
+
5781
+ #shouldEscape(selection) {
5782
+ if (!$getNearestNodeOfType(this, QuoteNode)) return false
5783
+ if ($isBlankNode(this)) return true
5784
+
5785
+ const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
5786
+ return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
5787
+ }
5788
+
5789
+ #escapeFromList() {
5790
+ const parentList = this.getParent();
5791
+ if (!parentList || !$isListNode(parentList)) return
5792
+
5793
+ const blockquote = parentList.getParent();
5794
+ const isInBlockquote = blockquote && $isQuoteNode(blockquote);
5795
+
5796
+ if (isInBlockquote) {
5797
+ const hasNonEmptyListItems = this.getNextSiblings().some(
5798
+ sibling => $isListItemNode(sibling) && !$isBlankNode(sibling)
5799
+ );
5800
+
5801
+ if (hasNonEmptyListItems) {
5802
+ return this.#splitBlockquoteWithList()
5803
+ }
5804
+ }
5805
+
5806
+ const paragraph = $createParagraphNode();
5807
+ parentList.insertAfter(paragraph);
5808
+
5809
+ this.remove();
5810
+ return paragraph
5811
+ }
5812
+
5813
+ #splitBlockquoteWithList() {
5814
+ const splitQuotes = $splitNode(this.getParent(), this.getIndexWithinParent());
5815
+ this.remove();
5816
+
5817
+ const middleParagraph = $createParagraphNode();
5818
+ splitQuotes[0].insertAfter(middleParagraph);
5819
+
5820
+ splitQuotes.forEach($trimTrailingBlankNodes);
5821
+
5822
+ return middleParagraph
5823
+ }
5824
+
5825
+ }
5826
+
5827
+ class FormatEscapeExtension extends LexxyExtension {
5828
+
5829
+ get enabled() {
5830
+ return this.editorElement.supportsRichText
5831
+ }
5832
+
5833
+ get lexicalExtension() {
5834
+ return defineExtension({
5835
+ name: "lexxy/format-escape",
5836
+ nodes: [
5837
+ EarlyEscapeCodeNode,
5838
+ { replace: CodeNode, with: (node) => new EarlyEscapeCodeNode(node.getLanguage()), withKlass: EarlyEscapeCodeNode },
5839
+ EarlyEscapeListItemNode,
5840
+ { replace: ListItemNode, with: () => new EarlyEscapeListItemNode(), withKlass: EarlyEscapeListItemNode },
5841
+ ],
5842
+ register(editor) {
5843
+ return mergeRegister(
5844
+ editor.registerCommand(
5845
+ INSERT_PARAGRAPH_COMMAND,
5846
+ () => $escapeFromBlockquote(),
5847
+ COMMAND_PRIORITY_HIGH
5848
+ ),
5849
+ editor.registerCommand(
5850
+ KEY_ARROW_DOWN_COMMAND,
5851
+ (event) => $handleArrowDownInCodeBlock(event),
5852
+ COMMAND_PRIORITY_NORMAL
5853
+ )
5854
+ )
5855
+ }
5856
+ })
5857
+ }
5858
+ }
5859
+
5860
+ function $escapeFromBlockquote() {
5861
+ const anchorNode = $getSelection().anchor.getNode();
5862
+
5863
+ const paragraph = $getNearestNodeOfType(anchorNode, ParagraphNode);
5864
+ if (!paragraph || !$isBlankNode(paragraph)) return false
5865
+
5866
+ const blockquote = paragraph.getParent();
5867
+ if (!blockquote || !$isQuoteNode(blockquote)) return false
5868
+
5869
+ const nonEmptySiblings = paragraph.getNextSiblings().filter(sibling => !$isBlankNode(sibling));
5870
+
5871
+ if (nonEmptySiblings.length > 0) {
5872
+ $splitQuoteNode(blockquote, paragraph);
5873
+ } else {
5874
+ blockquote.insertAfter(paragraph);
5875
+ paragraph.selectStart();
5876
+ }
5877
+
5878
+ return true
5879
+ }
5880
+
5881
+ function $splitQuoteNode(node, paragraph) {
5882
+ const splitQuotes = $splitNode(node, paragraph.getIndexWithinParent());
5883
+ splitQuotes[0].insertAfter(paragraph);
5884
+ splitQuotes.forEach($trimTrailingBlankNodes);
5885
+ paragraph.selectEnd();
5886
+ }
5887
+
5888
+ function $handleArrowDownInCodeBlock(event) {
5889
+ const selection = $getSelection();
5890
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
5891
+
5892
+ const codeNode = EarlyEscapeCodeNode.$fromSelection(selection);
5893
+ if (!codeNode) return false
5894
+
5895
+ if ($isCursorOnLastLine(selection) && !codeNode.getNextSibling()) {
5896
+ event?.preventDefault();
5897
+ const paragraph = $createParagraphNode();
5898
+ codeNode.insertAfter(paragraph);
5899
+ paragraph.selectEnd();
5900
+ return true
5901
+ }
5902
+
5903
+ return false
5904
+ }
5905
+
5917
5906
  class LexicalEditorElement extends HTMLElement {
5918
5907
  static formAssociated = true
5919
5908
  static debug = false
@@ -5974,7 +5963,7 @@ class LexicalEditorElement extends HTMLElement {
5974
5963
  }
5975
5964
 
5976
5965
  toString() {
5977
- if (!this.cachedStringValue) {
5966
+ if (this.cachedStringValue == null) {
5978
5967
  this.editor?.getEditorState().read(() => {
5979
5968
  this.cachedStringValue = $getReadableTextContent($getRoot());
5980
5969
  });
@@ -6004,7 +5993,8 @@ class LexicalEditorElement extends HTMLElement {
6004
5993
  HighlightExtension,
6005
5994
  TrixContentExtension,
6006
5995
  TablesExtension,
6007
- AttachmentsExtension
5996
+ AttachmentsExtension,
5997
+ FormatEscapeExtension
6008
5998
  ]
6009
5999
  }
6010
6000
 
@@ -6354,22 +6344,23 @@ class LexicalEditorElement extends HTMLElement {
6354
6344
  }
6355
6345
 
6356
6346
  #findOrCreateDefaultToolbar() {
6357
- const toolbarId = this.config.get("toolbar");
6358
- if (toolbarId && toolbarId !== true) {
6359
- return document.getElementById(toolbarId)
6347
+ const toolbarConfig = this.config.get("toolbar");
6348
+ if (typeof toolbarConfig === "string") {
6349
+ return document.getElementById(toolbarConfig)
6360
6350
  } else {
6361
6351
  return this.#createDefaultToolbar()
6362
6352
  }
6363
6353
  }
6364
6354
 
6365
6355
  get #hasToolbar() {
6366
- return this.supportsRichText && this.config.get("toolbar")
6356
+ return this.supportsRichText && !!this.config.get("toolbar")
6367
6357
  }
6368
6358
 
6369
6359
  #createDefaultToolbar() {
6370
6360
  const toolbar = createElement("lexxy-toolbar");
6371
6361
  toolbar.innerHTML = LexicalToolbarElement.defaultTemplate;
6372
6362
  toolbar.setAttribute("data-attachments", this.supportsAttachments); // Drives toolbar CSS styles
6363
+ toolbar.configure(this.config.get("toolbar"));
6373
6364
  this.prepend(toolbar);
6374
6365
  return toolbar
6375
6366
  }
@@ -6436,6 +6427,10 @@ function $getReadableTextContent(node) {
6436
6427
  const children = node.getChildren();
6437
6428
  for (let i = 0; i < children.length; i++) {
6438
6429
  const child = children[i];
6430
+ const previousChild = children[i - 1];
6431
+
6432
+ if (isAttachmentSpacerTextNode(child, previousChild, i, children.length)) continue
6433
+
6439
6434
  text += $getReadableTextContent(child);
6440
6435
  if ($isElementNode(child) && i !== children.length - 1 && !child.isInline()) {
6441
6436
  text += "\n\n";
@@ -6820,6 +6815,7 @@ class LexicalPromptElement extends HTMLElement {
6820
6815
  constructor() {
6821
6816
  super();
6822
6817
  this.keyListeners = [];
6818
+ this.showPopoverId = 0;
6823
6819
  }
6824
6820
 
6825
6821
  static observedAttributes = [ "connected" ]
@@ -6965,9 +6961,14 @@ class LexicalPromptElement extends HTMLElement {
6965
6961
  }
6966
6962
 
6967
6963
  async #showPopover() {
6964
+ const showId = ++this.showPopoverId;
6968
6965
  this.popoverElement ??= await this.#buildPopover();
6966
+ if (this.showPopoverId !== showId) return
6967
+
6969
6968
  this.#resetPopoverPosition();
6970
6969
  await this.#filterOptions();
6970
+ if (this.showPopoverId !== showId) return
6971
+
6971
6972
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", true);
6972
6973
  this.#selectFirstOption();
6973
6974
 
@@ -7078,6 +7079,7 @@ class LexicalPromptElement extends HTMLElement {
7078
7079
  }
7079
7080
 
7080
7081
  async #hidePopover() {
7082
+ this.showPopoverId++;
7081
7083
  this.#clearSelection();
7082
7084
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", false);
7083
7085
  this.#editorElement.removeEventListener("lexxy:change", this.#filterOptions);
@@ -7103,6 +7105,14 @@ class LexicalPromptElement extends HTMLElement {
7103
7105
 
7104
7106
  if (this.#editorContents.containsTextBackUntil(this.trigger)) {
7105
7107
  await this.#showFilteredOptions();
7108
+
7109
+ // Re-check after async operation — the trigger may have been consumed
7110
+ // (e.g. markdown heading shortcut converted "# " to h1 during the fetch)
7111
+ if (!this.#editorContents.containsTextBackUntil(this.trigger)) {
7112
+ this.#hidePopover();
7113
+ return
7114
+ }
7115
+
7106
7116
  await nextFrame();
7107
7117
  this.#positionPopover();
7108
7118
  } else {
@@ -7111,8 +7121,12 @@ class LexicalPromptElement extends HTMLElement {
7111
7121
  }
7112
7122
 
7113
7123
  async #showFilteredOptions() {
7124
+ const showId = this.showPopoverId;
7114
7125
  const filter = this.#editorContents.textBackUntil(this.trigger);
7115
7126
  const filteredListItems = await this.source.buildListItems(filter);
7127
+ if (this.showPopoverId !== showId) return
7128
+ if (!this.#editorContents.containsTextBackUntil(this.trigger)) return
7129
+
7116
7130
  this.popoverElement.innerHTML = "";
7117
7131
 
7118
7132
  if (filteredListItems.length > 0) {