@37signals/lexxy 0.8.2-beta → 0.8.6-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, 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();
@@ -632,7 +683,7 @@ class LexicalToolbarElement extends HTMLElement {
632
683
  }
633
684
 
634
685
  get #buttons() {
635
- return Array.from(this.querySelectorAll(":scope > button"))
686
+ return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow='true'])"))
636
687
  }
637
688
 
638
689
  get #focusableItems() {
@@ -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" 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>
@@ -713,9 +794,9 @@ class LexicalToolbarElement extends HTMLElement {
713
794
  <button class="lexxy-editor__toolbar-button" type="button" name="divider" data-command="insertHorizontalDivider" title="Insert a divider">
714
795
  ${ToolbarIcons.hr}
715
796
  </button>
716
-
797
+
717
798
  <div class="lexxy-editor__toolbar-spacer" role="separator"></div>
718
-
799
+
719
800
  <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo">
720
801
  ${ToolbarIcons.undo}
721
802
  </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,48 +1569,247 @@ 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
+ }
1290
1640
  }
1641
+
1642
+ for (const child of codeElement.childNodes) {
1643
+ walk(child);
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
+ }
1665
+
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
+ }));
1311
1813
 
1312
1814
  // Use skipTransforms to prevent the code highlighting system from
1313
1815
  // re-tokenizing and wiping out the style changes we apply.
@@ -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() {
@@ -1675,6 +2216,8 @@ class CommandDispatcher {
1675
2216
  }
1676
2217
 
1677
2218
  #handleDragEnter(event) {
2219
+ if (this.#isInternalDrag(event)) return
2220
+
1678
2221
  this.dragCounter++;
1679
2222
  if (this.dragCounter === 1) {
1680
2223
  this.#saveSelectionBeforeDrag();
@@ -1683,6 +2226,8 @@ class CommandDispatcher {
1683
2226
  }
1684
2227
 
1685
2228
  #handleDragLeave(event) {
2229
+ if (this.#isInternalDrag(event)) return
2230
+
1686
2231
  this.dragCounter--;
1687
2232
  if (this.dragCounter === 0) {
1688
2233
  this.#selectionBeforeDrag = null;
@@ -1691,10 +2236,14 @@ class CommandDispatcher {
1691
2236
  }
1692
2237
 
1693
2238
  #handleDragOver(event) {
2239
+ if (this.#isInternalDrag(event)) return
2240
+
1694
2241
  event.preventDefault();
1695
2242
  }
1696
2243
 
1697
2244
  #handleDrop(event) {
2245
+ if (this.#isInternalDrag(event)) return
2246
+
1698
2247
  event.preventDefault();
1699
2248
 
1700
2249
  this.dragCounter = 0;
@@ -1728,6 +2277,10 @@ class CommandDispatcher {
1728
2277
  this.#selectionBeforeDrag = null;
1729
2278
  }
1730
2279
 
2280
+ #isInternalDrag(event) {
2281
+ return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
2282
+ }
2283
+
1731
2284
  #handleTabKey(event) {
1732
2285
  if (this.selection.isInsideList) {
1733
2286
  return this.#handleTabForList(event)
@@ -1789,28 +2342,40 @@ function nextFrame() {
1789
2342
  return new Promise(requestAnimationFrame)
1790
2343
  }
1791
2344
 
1792
- function bytesToHumanSize(bytes) {
1793
- if (bytes === 0) return "0 B"
1794
- const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
1795
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
1796
- const value = bytes / Math.pow(1024, i);
1797
- return `${ value.toFixed(2) } ${ sizes[i] }`
1798
- }
1799
-
1800
- function extractFileName(string) {
1801
- return string.split("/").pop()
2345
+ function dasherize(value) {
2346
+ return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
1802
2347
  }
1803
2348
 
1804
- // Lexxy exports the content attribute as a JSON string (via JSON.stringify),
1805
- // but Trix/ActionText stores it as raw HTML. Try JSON first, fall back to raw.
1806
- function parseAttachmentContent(content) {
2349
+ function isUrl(string) {
1807
2350
  try {
1808
- return JSON.parse(content)
2351
+ new URL(string);
2352
+ return true
1809
2353
  } catch {
1810
- return content
2354
+ return false
1811
2355
  }
1812
2356
  }
1813
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
+
1814
2379
  class ActionTextAttachmentNode extends DecoratorNode {
1815
2380
  static getType() {
1816
2381
  return "action_text_attachment"
@@ -1891,7 +2456,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
1891
2456
  this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
1892
2457
  this.sgid = sgid;
1893
2458
  this.src = src;
1894
- this.previewable = previewable;
2459
+ this.previewable = parseBoolean(previewable);
1895
2460
  this.altText = altText || "";
1896
2461
  this.caption = caption || "";
1897
2462
  this.contentType = contentType || "";
@@ -1976,6 +2541,8 @@ class ActionTextAttachmentNode extends DecoratorNode {
1976
2541
 
1977
2542
  createAttachmentFigure() {
1978
2543
  const figure = createAttachmentFigure(this.contentType, this.isPreviewableAttachment, this.fileName);
2544
+ figure.draggable = true;
2545
+ figure.dataset.lexicalNodeKey = this.__key;
1979
2546
 
1980
2547
  const deleteButton = createElement("lexxy-node-delete-button");
1981
2548
  figure.appendChild(deleteButton);
@@ -1993,7 +2560,30 @@ class ActionTextAttachmentNode extends DecoratorNode {
1993
2560
 
1994
2561
  #createDOMForImage(options = {}) {
1995
2562
  const img = createElement("img", { src: this.src, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
1996
- return img
2563
+
2564
+ if (this.previewable && !this.isPreviewableImage) {
2565
+ img.onerror = () => this.#swapPreviewToFileDOM(img);
2566
+ }
2567
+
2568
+ const container = createElement("div", { className: "attachment__container" });
2569
+ container.appendChild(img);
2570
+ return container
2571
+ }
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());
1997
2587
  }
1998
2588
 
1999
2589
  get #imageDimensions() {
@@ -2093,6 +2683,7 @@ class Selection {
2093
2683
  this.#listenForNodeSelections();
2094
2684
  this.#processSelectionChangeCommands();
2095
2685
  this.#containEditorFocus();
2686
+ this.#clearStaleInlineCodeFormat();
2096
2687
  }
2097
2688
 
2098
2689
  set current(selection) {
@@ -2192,16 +2783,19 @@ class Selection {
2192
2783
 
2193
2784
  const topLevelElement = anchorNode.getTopLevelElementOrThrow();
2194
2785
  const listType = getListType(anchorNode);
2786
+ const headingNode = this.#getNearestHeadingNode(anchorNode);
2195
2787
 
2196
2788
  return {
2197
2789
  isBold: selection.hasFormat("bold"),
2198
2790
  isItalic: selection.hasFormat("italic"),
2199
2791
  isStrikethrough: selection.hasFormat("strikethrough"),
2792
+ isUnderline: selection.hasFormat("underline"),
2200
2793
  isHighlight: isSelectionHighlighted(selection),
2201
2794
  isInLink: $getNearestNodeOfType(anchorNode, LinkNode) !== null,
2202
2795
  isInQuote: $isQuoteNode(topLevelElement),
2203
- isInHeading: $isHeadingNode(topLevelElement),
2204
- isInCode: selection.hasFormat("code") || $getNearestNodeOfType(anchorNode, CodeNode) !== null,
2796
+ isInHeading: headingNode !== null,
2797
+ isInCode: this.#isInCode(selection, anchorNode),
2798
+ headingTag: headingNode?.getTag() ?? null,
2205
2799
  isInList: listType !== null,
2206
2800
  listType,
2207
2801
  isInTable: $getTableCellNodeFromLexicalNode(anchorNode) !== null
@@ -2286,8 +2880,12 @@ class Selection {
2286
2880
  if (!anchorNode) return null
2287
2881
 
2288
2882
  if ($isTextNode(anchorNode)) {
2289
- if (offset < anchorNode.getTextContentSize()) return null
2290
- return this.#getNextNodeFromTextEnd(anchorNode)
2883
+ if (offset === anchorNode.getTextContentSize()) return this.#getNextNodeFromTextEnd(anchorNode)
2884
+ if (this.#isCursorOnLastVisualLineOfBlock(anchorNode)) {
2885
+ const topLevelElement = anchorNode.getTopLevelElement();
2886
+ return topLevelElement ? topLevelElement.getNextSibling() : null
2887
+ }
2888
+ return null
2291
2889
  }
2292
2890
 
2293
2891
  if ($isElementNode(anchorNode)) {
@@ -2317,8 +2915,12 @@ class Selection {
2317
2915
  if (!anchorNode) return null
2318
2916
 
2319
2917
  if ($isTextNode(anchorNode)) {
2320
- if (offset > 0) return null
2321
- return this.#getPreviousNodeFromTextStart(anchorNode)
2918
+ if (offset === 0) return this.#getPreviousNodeFromTextStart(anchorNode)
2919
+ if (this.#isCursorOnFirstVisualLineOfBlock(anchorNode)) {
2920
+ const topLevelElement = anchorNode.getTopLevelElement();
2921
+ return topLevelElement ? topLevelElement.getPreviousSibling() : null
2922
+ }
2923
+ return null
2322
2924
  }
2323
2925
 
2324
2926
  if ($isElementNode(anchorNode)) {
@@ -2328,6 +2930,53 @@ class Selection {
2328
2930
  return this.#findPreviousSiblingUp(anchorNode)
2329
2931
  }
2330
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
+
2331
2980
  get #currentlySelectedKeys() {
2332
2981
  if (this.currentlySelectedKeys) { return this.currentlySelectedKeys }
2333
2982
 
@@ -2511,6 +3160,24 @@ class Selection {
2511
3160
  return anchorNode.getTopLevelElement()
2512
3161
  }
2513
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
+
2514
3181
  #moveToOrCreateNextLine(topLevelElement) {
2515
3182
  const nextSibling = topLevelElement.getNextSibling();
2516
3183
 
@@ -2539,10 +3206,12 @@ class Selection {
2539
3206
  }
2540
3207
 
2541
3208
  #selectDecoratorNodeBeforeDeletion(backwards) {
3209
+ if (backwards && this.#removeEmptyListItem()) return true
3210
+
2542
3211
  const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor;
2543
3212
  if (!$isDecoratorNode(node)) return false
2544
3213
 
2545
- if (this.#collapseListItemToParagraph()) return true
3214
+ if (this.#collapseListItemToParagraph(node)) return true
2546
3215
 
2547
3216
  this.#removeEmptyElementAnchorNode();
2548
3217
 
@@ -2550,15 +3219,51 @@ class Selection {
2550
3219
  return Boolean(selection)
2551
3220
  }
2552
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
+
2553
3254
  // When the cursor is inside a list item, collapse the list item into a
2554
3255
  // paragraph instead of selecting the decorator. This lets the user
2555
3256
  // delete a list that immediately follows an attachment without the
2556
- // attachment becoming selected.
2557
- #collapseListItemToParagraph() {
3257
+ // attachment becoming selected. Only applies when the decorator is
3258
+ // outside the list item (e.g. a block attachment before the list),
3259
+ // not when it's an inline mention inside the list item.
3260
+ #collapseListItemToParagraph(decoratorNode) {
2558
3261
  const anchorNode = $getSelection()?.anchor?.getNode();
2559
3262
  const listItem = anchorNode && $getNearestNodeOfType(anchorNode, ListItemNode);
2560
3263
  if (!listItem) return false
2561
3264
 
3265
+ if (listItem.isParentOf(decoratorNode)) return false
3266
+
2562
3267
  const listNode = $getNearestNodeOfType(listItem, ListNode);
2563
3268
  if (!listNode) return false
2564
3269
 
@@ -2743,53 +3448,83 @@ class Selection {
2743
3448
  }
2744
3449
  return current ? current.getPreviousSibling() : null
2745
3450
  }
2746
- }
2747
-
2748
- function sanitize(html) {
2749
- return DOMPurify.sanitize(html, buildConfig())
2750
- }
2751
3451
 
2752
- function dasherize(value) {
2753
- return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
2754
- }
3452
+ #isCursorOnFirstVisualLineOfBlock(anchorNode) {
3453
+ return this.#isCursorOnEdgeLineOfBlock(anchorNode, "first")
3454
+ }
2755
3455
 
2756
- function isUrl(string) {
2757
- try {
2758
- new URL(string);
2759
- return true
2760
- } catch {
2761
- return false
3456
+ #isCursorOnLastVisualLineOfBlock(anchorNode) {
3457
+ return this.#isCursorOnEdgeLineOfBlock(anchorNode, "last")
2762
3458
  }
2763
- }
2764
3459
 
2765
- function normalizeFilteredText(string) {
2766
- return string
2767
- .toLowerCase()
2768
- .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
2769
- }
3460
+ // Check whether the cursor sits on the first or last visual line of its
3461
+ // top-level block by comparing the Y position of the cursor with the Y
3462
+ // position of the block's start (first line) or end (last line).
3463
+ #isCursorOnEdgeLineOfBlock(anchorNode, edge) {
3464
+ const topLevelElement = anchorNode.getTopLevelElement();
3465
+ if (!topLevelElement) return false
2770
3466
 
2771
- function filterMatches(text, potentialMatch) {
2772
- return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))
2773
- }
3467
+ const domElement = this.editor.getElementByKey(topLevelElement.getKey());
3468
+ if (!domElement) return false
2774
3469
 
2775
- function upcaseFirst(string) {
2776
- return string.charAt(0).toUpperCase() + string.slice(1)
2777
- }
3470
+ const nativeSelection = window.getSelection();
3471
+ if (!nativeSelection?.rangeCount) return false
2778
3472
 
2779
- class EditorConfiguration {
2780
- #editorElement
2781
- #config
3473
+ const cursorRect = this.#getReliableRectFromRange(nativeSelection.getRangeAt(0));
3474
+ if (!cursorRect || this.#isRectUnreliable(cursorRect)) return false
2782
3475
 
2783
- constructor(editorElement) {
2784
- this.#editorElement = editorElement;
2785
- this.#config = new Configuration(
2786
- Lexxy.presets.get("default"),
2787
- Lexxy.presets.get(editorElement.preset),
2788
- this.#overrides
2789
- );
3476
+ const edgeRect = this.#getEdgeCharRect(domElement, edge);
3477
+ if (!edgeRect || this.#isRectUnreliable(edgeRect)) return false
3478
+
3479
+ const tolerance = edgeRect.height > 0 ? edgeRect.height * 0.5 : 5;
3480
+ return Math.abs(cursorRect.top - edgeRect.top) < tolerance
2790
3481
  }
2791
3482
 
2792
- get(path) {
3483
+ // Get a reliable bounding rect for the first or last character in a DOM
3484
+ // element by creating a non-collapsed range around it.
3485
+ #getEdgeCharRect(element, edge) {
3486
+ const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
3487
+ let textNode;
3488
+
3489
+ if (edge === "first") {
3490
+ textNode = walker.nextNode();
3491
+ } else {
3492
+ while (walker.nextNode()) textNode = walker.currentNode;
3493
+ }
3494
+
3495
+ if (!textNode || textNode.length === 0) return null
3496
+
3497
+ const range = document.createRange();
3498
+ if (edge === "first") {
3499
+ range.setStart(textNode, 0);
3500
+ range.setEnd(textNode, 1);
3501
+ } else {
3502
+ range.setStart(textNode, textNode.length - 1);
3503
+ range.setEnd(textNode, textNode.length);
3504
+ }
3505
+
3506
+ return range.getBoundingClientRect()
3507
+ }
3508
+ }
3509
+
3510
+ function sanitize(html) {
3511
+ return DOMPurify.sanitize(html, buildConfig())
3512
+ }
3513
+
3514
+ class EditorConfiguration {
3515
+ #editorElement
3516
+ #config
3517
+
3518
+ constructor(editorElement) {
3519
+ this.#editorElement = editorElement;
3520
+ this.#config = new Configuration(
3521
+ Lexxy.presets.get("default"),
3522
+ Lexxy.presets.get(editorElement.preset),
3523
+ this.#overrides
3524
+ );
3525
+ }
3526
+
3527
+ get(path) {
2793
3528
  return this.#config.get(path)
2794
3529
  }
2795
3530
 
@@ -2818,504 +3553,6 @@ class EditorConfiguration {
2818
3553
  }
2819
3554
  }
2820
3555
 
2821
- class CustomActionTextAttachmentNode extends DecoratorNode {
2822
- static getType() {
2823
- return "custom_action_text_attachment"
2824
- }
2825
-
2826
- static clone(node) {
2827
- return new CustomActionTextAttachmentNode({ ...node }, node.__key)
2828
- }
2829
-
2830
- static importJSON(serializedNode) {
2831
- return new CustomActionTextAttachmentNode({ ...serializedNode })
2832
- }
2833
-
2834
- static importDOM() {
2835
-
2836
- return {
2837
- [this.TAG_NAME]: (element) => {
2838
- if (!element.getAttribute("content")) {
2839
- return null
2840
- }
2841
-
2842
- return {
2843
- conversion: (attachment) => {
2844
- // Preserve initial space if present since Lexical removes it
2845
- const nodes = [];
2846
- const previousSibling = attachment.previousSibling;
2847
- if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
2848
- nodes.push($createTextNode(" "));
2849
- }
2850
-
2851
- nodes.push(new CustomActionTextAttachmentNode({
2852
- sgid: attachment.getAttribute("sgid"),
2853
- innerHtml: parseAttachmentContent(attachment.getAttribute("content")),
2854
- contentType: attachment.getAttribute("content-type")
2855
- }));
2856
-
2857
- nodes.push($createTextNode("\u2060"));
2858
-
2859
- return { node: nodes }
2860
- },
2861
- priority: 2
2862
- }
2863
- }
2864
- }
2865
- }
2866
-
2867
- static get TAG_NAME() {
2868
- return Lexxy.global.get("attachmentTagName")
2869
- }
2870
-
2871
- constructor({ tagName, sgid, contentType, innerHtml }, key) {
2872
- super(key);
2873
-
2874
- const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
2875
-
2876
- this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
2877
- this.sgid = sgid;
2878
- this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
2879
- this.innerHtml = innerHtml;
2880
- }
2881
-
2882
- createDOM() {
2883
- const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
2884
-
2885
- figure.insertAdjacentHTML("beforeend", this.innerHtml);
2886
-
2887
- const deleteButton = createElement("lexxy-node-delete-button");
2888
- figure.appendChild(deleteButton);
2889
-
2890
- return figure
2891
- }
2892
-
2893
- updateDOM() {
2894
- return false
2895
- }
2896
-
2897
- getTextContent() {
2898
- return "\ufeff"
2899
- }
2900
-
2901
- getReadableTextContent() {
2902
- return this.createDOM().textContent.trim() || `[${this.contentType}]`
2903
- }
2904
-
2905
- isInline() {
2906
- return true
2907
- }
2908
-
2909
- exportDOM() {
2910
- const attachment = createElement(this.tagName, {
2911
- sgid: this.sgid,
2912
- content: JSON.stringify(this.innerHtml),
2913
- "content-type": this.contentType
2914
- });
2915
-
2916
- return { element: attachment }
2917
- }
2918
-
2919
- exportJSON() {
2920
- return {
2921
- type: "custom_action_text_attachment",
2922
- version: 1,
2923
- tagName: this.tagName,
2924
- sgid: this.sgid,
2925
- contentType: this.contentType,
2926
- innerHtml: this.innerHtml
2927
- }
2928
- }
2929
-
2930
- decorate() {
2931
- return null
2932
- }
2933
-
2934
- }
2935
-
2936
- class FormatEscaper {
2937
- constructor(editorElement) {
2938
- this.editorElement = editorElement;
2939
- this.editor = editorElement.editor;
2940
- }
2941
-
2942
- monitor() {
2943
- this.editor.registerCommand(
2944
- KEY_ENTER_COMMAND,
2945
- (event) => this.#handleEnterKey(event),
2946
- COMMAND_PRIORITY_HIGH
2947
- );
2948
-
2949
- this.editor.registerCommand(
2950
- KEY_ARROW_DOWN_COMMAND,
2951
- (event) => this.#handleArrowDownInCodeBlock(event),
2952
- COMMAND_PRIORITY_NORMAL
2953
- );
2954
- }
2955
-
2956
- #handleEnterKey(event) {
2957
- const selection = $getSelection();
2958
- if (!$isRangeSelection(selection)) return false
2959
-
2960
- if (this.#handleCodeBlocks(event, selection)) return true
2961
-
2962
- const anchorNode = selection.anchor.getNode();
2963
-
2964
- if (!this.#isInsideBlockquote(anchorNode)) return false
2965
-
2966
- return this.#handleLists(event, anchorNode)
2967
- || this.#handleBlockquotes(event, anchorNode)
2968
- }
2969
-
2970
- #handleLists(event, anchorNode) {
2971
- if (this.#shouldEscapeFromEmptyListItem(anchorNode) || this.#shouldEscapeFromEmptyParagraphInListItem(anchorNode)) {
2972
- event.preventDefault();
2973
- this.#escapeFromList(anchorNode);
2974
- return true
2975
- }
2976
-
2977
- return false
2978
- }
2979
-
2980
- #handleBlockquotes(event, anchorNode) {
2981
- if (this.#shouldEscapeFromEmptyParagraphInBlockquote(anchorNode)) {
2982
- event.preventDefault();
2983
- this.#escapeFromBlockquote(anchorNode);
2984
- return true
2985
- }
2986
-
2987
- return false
2988
- }
2989
-
2990
- #isInsideBlockquote(node) {
2991
- let currentNode = node;
2992
-
2993
- while (currentNode) {
2994
- if ($isQuoteNode(currentNode)) {
2995
- return true
2996
- }
2997
- currentNode = currentNode.getParent();
2998
- }
2999
-
3000
- return false
3001
- }
3002
-
3003
- #shouldEscapeFromEmptyListItem(node) {
3004
- const listItem = this.#getListItemNode(node);
3005
- if (!listItem) return false
3006
-
3007
- return this.#isNodeEmpty(listItem)
3008
- }
3009
-
3010
- #shouldEscapeFromEmptyParagraphInListItem(node) {
3011
- const paragraph = this.#getParagraphNode(node);
3012
- if (!paragraph) return false
3013
-
3014
- if (!this.#isNodeEmpty(paragraph)) return false
3015
-
3016
- const parent = paragraph.getParent();
3017
- return parent && $isListItemNode(parent)
3018
- }
3019
-
3020
- #isNodeEmpty(node) {
3021
- if (node.getTextContent().trim() !== "") return false
3022
-
3023
- const children = node.getChildren();
3024
- if (children.length === 0) return true
3025
-
3026
- return children.every(child => {
3027
- if ($isLineBreakNode(child)) return true
3028
- return this.#isNodeEmpty(child)
3029
- })
3030
- }
3031
-
3032
- #getListItemNode(node) {
3033
- let currentNode = node;
3034
-
3035
- while (currentNode) {
3036
- if ($isListItemNode(currentNode)) {
3037
- return currentNode
3038
- }
3039
- currentNode = currentNode.getParent();
3040
- }
3041
-
3042
- return null
3043
- }
3044
-
3045
- #escapeFromList(anchorNode) {
3046
- const listItem = this.#getListItemNode(anchorNode);
3047
- if (!listItem) return
3048
-
3049
- const parentList = listItem.getParent();
3050
- if (!parentList || !$isListNode(parentList)) return
3051
-
3052
- const blockquote = parentList.getParent();
3053
- const isInBlockquote = blockquote && $isQuoteNode(blockquote);
3054
-
3055
- if (isInBlockquote) {
3056
- const listItemsAfter = this.#getListItemSiblingsAfter(listItem);
3057
- const nonEmptyListItems = listItemsAfter.filter(item => !this.#isNodeEmpty(item));
3058
-
3059
- if (nonEmptyListItems.length > 0) {
3060
- this.#splitBlockquoteWithList(blockquote, parentList, listItem, nonEmptyListItems);
3061
- return
3062
- }
3063
- }
3064
-
3065
- const paragraph = $createParagraphNode();
3066
- parentList.insertAfter(paragraph);
3067
-
3068
- listItem.remove();
3069
- paragraph.selectStart();
3070
- }
3071
-
3072
- #shouldEscapeFromEmptyParagraphInBlockquote(node) {
3073
- const paragraph = this.#getParagraphNode(node);
3074
- if (!paragraph) return false
3075
-
3076
- if (!this.#isNodeEmpty(paragraph)) return false
3077
-
3078
- const parent = paragraph.getParent();
3079
- return parent && $isQuoteNode(parent)
3080
- }
3081
-
3082
- #getParagraphNode(node) {
3083
- let currentNode = node;
3084
-
3085
- while (currentNode) {
3086
- if ($isParagraphNode(currentNode)) {
3087
- return currentNode
3088
- }
3089
- currentNode = currentNode.getParent();
3090
- }
3091
-
3092
- return null
3093
- }
3094
-
3095
- #escapeFromBlockquote(anchorNode) {
3096
- const paragraph = this.#getParagraphNode(anchorNode);
3097
- if (!paragraph) return
3098
-
3099
- const blockquote = paragraph.getParent();
3100
- if (!blockquote || !$isQuoteNode(blockquote)) return
3101
-
3102
- const siblingsAfter = this.#getSiblingsAfter(paragraph);
3103
- const nonEmptySiblings = siblingsAfter.filter(sibling => !this.#isNodeEmpty(sibling));
3104
-
3105
- if (nonEmptySiblings.length > 0) {
3106
- this.#splitBlockquote(blockquote, paragraph, nonEmptySiblings);
3107
- } else {
3108
- const newParagraph = $createParagraphNode();
3109
- blockquote.insertAfter(newParagraph);
3110
- paragraph.remove();
3111
- newParagraph.selectStart();
3112
- }
3113
- }
3114
-
3115
- #getSiblingsAfter(node) {
3116
- const siblings = [];
3117
- let sibling = node.getNextSibling();
3118
-
3119
- while (sibling) {
3120
- siblings.push(sibling);
3121
- sibling = sibling.getNextSibling();
3122
- }
3123
-
3124
- return siblings
3125
- }
3126
-
3127
- #getListItemSiblingsAfter(listItem) {
3128
- const siblings = [];
3129
- let sibling = listItem.getNextSibling();
3130
-
3131
- while (sibling) {
3132
- if ($isListItemNode(sibling)) {
3133
- siblings.push(sibling);
3134
- }
3135
- sibling = sibling.getNextSibling();
3136
- }
3137
-
3138
- return siblings
3139
- }
3140
-
3141
- #splitBlockquoteWithList(blockquote, parentList, emptyListItem, listItemsAfter) {
3142
- const blockquoteSiblingsAfterList = this.#getSiblingsAfter(parentList);
3143
- const nonEmptyBlockquoteSiblings = blockquoteSiblingsAfterList.filter(sibling => !this.#isNodeEmpty(sibling));
3144
-
3145
- const middleParagraph = $createParagraphNode();
3146
- blockquote.insertAfter(middleParagraph);
3147
-
3148
- const newList = $createListNode(parentList.getListType());
3149
-
3150
- const newBlockquote = $createQuoteNode();
3151
- middleParagraph.insertAfter(newBlockquote);
3152
- newBlockquote.append(newList);
3153
-
3154
- listItemsAfter.forEach(item => {
3155
- newList.append(item);
3156
- });
3157
-
3158
- nonEmptyBlockquoteSiblings.forEach(sibling => {
3159
- newBlockquote.append(sibling);
3160
- });
3161
-
3162
- emptyListItem.remove();
3163
-
3164
- this.#removeTrailingEmptyListItems(parentList);
3165
- this.#removeTrailingEmptyNodes(newBlockquote);
3166
-
3167
- if (parentList.getChildrenSize() === 0) {
3168
- parentList.remove();
3169
-
3170
- if (blockquote.getChildrenSize() === 0) {
3171
- blockquote.remove();
3172
- }
3173
- } else {
3174
- this.#removeTrailingEmptyNodes(blockquote);
3175
- }
3176
-
3177
- middleParagraph.selectStart();
3178
- }
3179
-
3180
- #removeTrailingEmptyListItems(list) {
3181
- const items = list.getChildren();
3182
- for (let i = items.length - 1; i >= 0; i--) {
3183
- const item = items[i];
3184
- if ($isListItemNode(item) && this.#isNodeEmpty(item)) {
3185
- item.remove();
3186
- } else {
3187
- break
3188
- }
3189
- }
3190
- }
3191
-
3192
- #removeTrailingEmptyNodes(blockquote) {
3193
- const children = blockquote.getChildren();
3194
- for (let i = children.length - 1; i >= 0; i--) {
3195
- const child = children[i];
3196
- if (this.#isNodeEmpty(child)) {
3197
- child.remove();
3198
- } else {
3199
- break
3200
- }
3201
- }
3202
- }
3203
-
3204
- #splitBlockquote(blockquote, emptyParagraph, siblingsAfter) {
3205
- const newParagraph = $createParagraphNode();
3206
- blockquote.insertAfter(newParagraph);
3207
-
3208
- const newBlockquote = $createQuoteNode();
3209
- newParagraph.insertAfter(newBlockquote);
3210
-
3211
- siblingsAfter.forEach(sibling => {
3212
- newBlockquote.append(sibling);
3213
- });
3214
-
3215
- emptyParagraph.remove();
3216
-
3217
- this.#removeTrailingEmptyNodes(blockquote);
3218
- this.#removeTrailingEmptyNodes(newBlockquote);
3219
-
3220
- newParagraph.selectStart();
3221
- }
3222
-
3223
- // Code blocks
3224
-
3225
- #handleCodeBlocks(event, selection) {
3226
- if (!selection.isCollapsed()) return false
3227
-
3228
- const codeNode = this.#getCodeNodeFromSelection(selection);
3229
- if (!codeNode) return false
3230
-
3231
- if (this.#isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode)) {
3232
- event?.preventDefault();
3233
- this.#exitCodeBlock(codeNode);
3234
- return true
3235
- }
3236
-
3237
- return false
3238
- }
3239
-
3240
- #handleArrowDownInCodeBlock(event) {
3241
- const selection = $getSelection();
3242
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
3243
-
3244
- const codeNode = this.#getCodeNodeFromSelection(selection);
3245
- if (!codeNode) return false
3246
-
3247
- if (this.#isCursorOnLastLineOfCodeBlock(selection, codeNode) && !codeNode.getNextSibling()) {
3248
- event?.preventDefault();
3249
- const paragraph = $createParagraphNode();
3250
- codeNode.insertAfter(paragraph);
3251
- paragraph.selectStart();
3252
- return true
3253
- }
3254
-
3255
- return false
3256
- }
3257
-
3258
- #getCodeNodeFromSelection(selection) {
3259
- const anchorNode = selection.anchor.getNode();
3260
- return $getNearestNodeOfType(anchorNode, CodeNode) || ($isCodeNode(anchorNode) ? anchorNode : null)
3261
- }
3262
-
3263
- #isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode) {
3264
- const children = codeNode.getChildren();
3265
- if (children.length === 0) return true
3266
-
3267
- const anchorNode = selection.anchor.getNode();
3268
- const anchorOffset = selection.anchor.offset;
3269
-
3270
- // Chromium: cursor on the CodeNode element after the last child (a line break)
3271
- if ($isCodeNode(anchorNode) && anchorOffset === children.length) {
3272
- return $isLineBreakNode(children[children.length - 1])
3273
- }
3274
-
3275
- // Firefox: cursor on an empty text node that follows a line break at the end
3276
- if ($isTextNode(anchorNode) && anchorNode.getTextContentSize() === 0 && anchorOffset === 0) {
3277
- const previousSibling = anchorNode.getPreviousSibling();
3278
- return $isLineBreakNode(previousSibling) && anchorNode.getNextSibling() === null
3279
- }
3280
-
3281
- return false
3282
- }
3283
-
3284
- #isCursorOnLastLineOfCodeBlock(selection, codeNode) {
3285
- const anchorNode = selection.anchor.getNode();
3286
- const children = codeNode.getChildren();
3287
- if (children.length === 0) return true
3288
-
3289
- const lastChild = children[children.length - 1];
3290
-
3291
- if ($isCodeNode(anchorNode) && selection.anchor.offset === children.length) return true
3292
- if (anchorNode === lastChild) return true
3293
-
3294
- const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
3295
- if (lastLineBreakIndex === -1) return true
3296
-
3297
- const anchorIndex = children.indexOf(anchorNode);
3298
- return anchorIndex > lastLineBreakIndex
3299
- }
3300
-
3301
- #exitCodeBlock(codeNode) {
3302
- const children = codeNode.getChildren();
3303
- const lastChild = children[children.length - 1];
3304
-
3305
- if ($isTextNode(lastChild) && lastChild.getTextContentSize() === 0) {
3306
- const previousSibling = lastChild.getPreviousSibling();
3307
- lastChild.remove();
3308
- if ($isLineBreakNode(previousSibling)) previousSibling.remove();
3309
- } else if ($isLineBreakNode(lastChild)) {
3310
- lastChild.remove();
3311
- }
3312
-
3313
- const paragraph = $createParagraphNode();
3314
- codeNode.insertAfter(paragraph);
3315
- paragraph.selectStart();
3316
- }
3317
- }
3318
-
3319
3556
  async function loadFileIntoImage(file, image) {
3320
3557
  return new Promise((resolve) => {
3321
3558
  const reader = new FileReader();
@@ -3537,14 +3774,11 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3537
3774
  const editorHasFocus = this.#editorHasFocus;
3538
3775
 
3539
3776
  this.editor.update(() => {
3540
- const shouldTransferNodeSelection = editorHasFocus && this.isSelected();
3541
-
3542
3777
  const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
3543
3778
  this.replace(replacementNode);
3544
3779
 
3545
- if (shouldTransferNodeSelection) {
3546
- const nodeSelection = $createNodeSelectionWith(replacementNode);
3547
- $setSelection(nodeSelection);
3780
+ if (editorHasFocus && $isRootOrShadowRoot(replacementNode.getParent())) {
3781
+ replacementNode.selectNext();
3548
3782
  }
3549
3783
  }, { tag: this.#backgroundUpdateTags });
3550
3784
  }
@@ -3636,14 +3870,12 @@ class ImageGalleryNode extends ElementNode {
3636
3870
  static importDOM() {
3637
3871
  return {
3638
3872
  div: (element) => {
3639
- const containsAttachment = element.querySelector(`:scope > :is(${this.#attachmentTags.join()})`);
3640
- if (!containsAttachment) return null
3873
+ if (!this.#isGalleryElement(element)) return null
3641
3874
 
3642
3875
  return {
3643
3876
  conversion: () => {
3644
3877
  return {
3645
- node: $createImageGalleryNode(),
3646
- after: children => children
3878
+ node: $createImageGalleryNode()
3647
3879
  }
3648
3880
  },
3649
3881
  priority: 2
@@ -3660,6 +3892,13 @@ class ImageGalleryNode extends ElementNode {
3660
3892
  return $isActionTextAttachmentNode(node) && node.isPreviewableImage
3661
3893
  }
3662
3894
 
3895
+ static #isGalleryElement(element) {
3896
+ const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`);
3897
+ return element.textContent.trim() === ""
3898
+ && attachmentChildren.length > 0
3899
+ && element.children.length === attachmentChildren.length
3900
+ }
3901
+
3663
3902
  static get #attachmentTags() {
3664
3903
  return Object.keys(ActionTextAttachmentNode.importDOM())
3665
3904
  }
@@ -3912,7 +4151,6 @@ class Contents {
3912
4151
  this.editorElement = editorElement;
3913
4152
  this.editor = editorElement.editor;
3914
4153
 
3915
- new FormatEscaper(editorElement).monitor();
3916
4154
  }
3917
4155
 
3918
4156
  insertHtml(html, { tag } = {}) {
@@ -3921,6 +4159,7 @@ class Contents {
3921
4159
 
3922
4160
  insertDOM(doc, { tag } = {}) {
3923
4161
  this.#unwrapPlaceholderAnchors(doc);
4162
+ if (tag === PASTE_TAG) this.#stripTableCellColorStyles(doc);
3924
4163
 
3925
4164
  this.editor.update(() => {
3926
4165
  const selection = $getSelection();
@@ -3958,127 +4197,77 @@ class Contents {
3958
4197
  this.#insertLineBelowIfLastNode(node);
3959
4198
  }
3960
4199
 
3961
- insertNodeWrappingEachSelectedLine(newNodeFn) {
3962
- this.editor.update(() => {
3963
- const selection = $getSelection();
3964
- if (!$isRangeSelection(selection)) return
3965
-
3966
- const selectedNodes = selection.extract();
3967
-
3968
- selectedNodes.forEach((node) => {
3969
- const parent = node.getParent();
3970
- if (!parent) { return }
3971
-
3972
- const topLevelElement = node.getTopLevelElementOrThrow();
3973
- const wrappingNode = newNodeFn();
3974
- wrappingNode.append(...topLevelElement.getChildren());
3975
- topLevelElement.replace(wrappingNode);
3976
- });
3977
- });
3978
- }
3979
-
3980
- toggleNodeWrappingAllSelectedLines(isFormatAppliedFn, newNodeFn) {
3981
- this.editor.update(() => {
3982
- const selection = $getSelection();
3983
- if (!$isRangeSelection(selection)) return
3984
-
3985
- const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
3986
-
3987
- // Check if format is already applied
3988
- if (isFormatAppliedFn(topLevelElement)) {
3989
- this.removeFormattingFromSelectedLines();
3990
- } else {
3991
- this.#insertNodeWrappingAllSelectedLines(newNodeFn);
3992
- }
3993
- });
3994
- }
3995
-
3996
- toggleNodeWrappingAllSelectedNodes(isFormatAppliedFn, newNodeFn) {
3997
- this.editor.update(() => {
3998
- const selection = $getSelection();
3999
- if (!$isRangeSelection(selection)) return
4000
-
4001
- const topLevelElement = selection.anchor.getNode().getTopLevelElement();
4002
-
4003
- // Check if format is already applied
4004
- if (topLevelElement && isFormatAppliedFn(topLevelElement)) {
4005
- this.#unwrap(topLevelElement);
4006
- } else {
4007
- this.#insertNodeWrappingAllSelectedNodes(newNodeFn);
4008
- }
4009
- });
4010
- }
4011
-
4012
- removeFormattingFromSelectedLines() {
4013
- this.editor.update(() => {
4014
- const selection = $getSelection();
4015
- if (!$isRangeSelection(selection)) return
4016
-
4017
- const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
4018
- const paragraph = $createParagraphNode();
4019
- paragraph.append(...topLevelElement.getChildren());
4020
- topLevelElement.replace(paragraph);
4021
- });
4200
+ applyParagraphFormat() {
4201
+ const selection = $getSelection();
4202
+ if (!$isRangeSelection(selection)) return
4203
+
4204
+ $setBlocksType(selection, () => $createParagraphNode());
4022
4205
  }
4023
4206
 
4024
- hasSelectedText() {
4025
- let result = false;
4207
+ applyHeadingFormat(tag) {
4208
+ const selection = $getSelection();
4209
+ if (!$isRangeSelection(selection)) return
4026
4210
 
4027
- this.editor.read(() => {
4028
- const selection = $getSelection();
4029
- result = $isRangeSelection(selection) && !selection.isCollapsed();
4030
- });
4211
+ $setBlocksType(selection, () => $createHeadingNode(tag));
4212
+ }
4031
4213
 
4032
- return result
4214
+ #applyCodeBlockFormat() {
4215
+ const selection = $getSelection();
4216
+ if (!$isRangeSelection(selection)) return
4217
+
4218
+ $setBlocksType(selection, () => $createCodeNode("plain"));
4033
4219
  }
4034
4220
 
4035
- wrapSelectedSoftBreakLines(newNodeFn) {
4036
- let paragraphKey = null;
4037
- let selectedLineRange = null;
4221
+ toggleCodeBlock() {
4222
+ const selection = $getSelection();
4223
+ if (!$isRangeSelection(selection)) return
4224
+
4225
+ if (this.#insertNodeIfRoot($createCodeNode("plain"))) return
4038
4226
 
4039
- this.editor.getEditorState().read(() => {
4040
- const selection = $getSelection();
4041
- if (!$isRangeSelection(selection) || selection.isCollapsed()) return
4227
+ const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
4042
4228
 
4043
- const paragraph = this.#getSelectedParagraphWithSoftLineBreaks(selection);
4044
- if (!paragraph) return
4229
+ if (topLevelElement && !$isCodeNode(topLevelElement)) {
4230
+ this.#applyCodeBlockFormat();
4231
+ } else {
4232
+ this.applyParagraphFormat();
4233
+ }
4234
+ }
4045
4235
 
4046
- const lines = this.#splitParagraphIntoLines(paragraph);
4047
- selectedLineRange = this.#getSelectedLineRange(lines, selection);
4236
+ toggleBlockquote() {
4237
+ const selection = $getSelection();
4238
+ if (!$isRangeSelection(selection)) return
4048
4239
 
4049
- if (!selectedLineRange) return
4240
+ if (this.#insertNodeIfRoot($createQuoteNode())) return
4050
4241
 
4051
- const { start, end } = selectedLineRange;
4052
- if (start === 0 && end === lines.length - 1) return
4242
+ const topLevelElements = this.#topLevelElementsInSelection(selection);
4053
4243
 
4054
- paragraphKey = paragraph.getKey();
4055
- });
4244
+ const allQuoted = topLevelElements.length > 0 && topLevelElements.every($isQuoteNode);
4056
4245
 
4057
- 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));
4058
4250
 
4059
- this.editor.update(() => {
4060
- const paragraph = $getNodeByKey(paragraphKey);
4061
- if (!paragraph || !$isParagraphNode(paragraph)) return
4251
+ this.#splitParagraphsAtLineBreaks(selection);
4062
4252
 
4063
- const lines = this.#splitParagraphIntoLines(paragraph);
4064
- this.#replaceParagraphWithWrappedSelectedLines(paragraph, lines, selectedLineRange, newNodeFn);
4065
- });
4253
+ const elements = this.#topLevelElementsInSelection(selection);
4254
+ if (elements.length === 0) return
4066
4255
 
4067
- return true
4256
+ const blockquote = $createQuoteNode();
4257
+ elements[0].insertBefore(blockquote);
4258
+ elements.forEach((element) => blockquote.append(element));
4259
+ }
4068
4260
  }
4069
4261
 
4070
- unwrapSelectedListItems() {
4071
- this.editor.update(() => {
4072
- const selection = $getSelection();
4073
- if (!$isRangeSelection(selection)) return
4262
+ hasSelectedText() {
4263
+ let result = false;
4074
4264
 
4075
- const { listItems, parentLists } = this.#collectSelectedListItems(selection);
4076
- if (listItems.size > 0) {
4077
- const newParagraphs = this.#convertListItemsToParagraphs(listItems);
4078
- this.#removeEmptyParentLists(parentLists);
4079
- this.#selectNewParagraphs(newParagraphs);
4080
- }
4265
+ this.editor.read(() => {
4266
+ const selection = $getSelection();
4267
+ result = $isRangeSelection(selection) && !selection.isCollapsed();
4081
4268
  });
4269
+
4270
+ return result
4082
4271
  }
4083
4272
 
4084
4273
  createLink(url) {
@@ -4170,30 +4359,6 @@ class Contents {
4170
4359
  this.#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes);
4171
4360
  }
4172
4361
 
4173
- createParagraphAfterNode(node, text) {
4174
- const newParagraph = $createParagraphNode();
4175
- node.insertAfter(newParagraph);
4176
- newParagraph.selectStart();
4177
-
4178
- // Insert the typed text
4179
- if (text) {
4180
- newParagraph.append($createTextNode(text));
4181
- newParagraph.select(1, 1); // Place cursor after the text
4182
- }
4183
- }
4184
-
4185
- createParagraphBeforeNode(node, text) {
4186
- const newParagraph = $createParagraphNode();
4187
- node.insertBefore(newParagraph);
4188
- newParagraph.selectStart();
4189
-
4190
- // Insert the typed text
4191
- if (text) {
4192
- newParagraph.append($createTextNode(text));
4193
- newParagraph.select(1, 1); // Place cursor after the text
4194
- }
4195
- }
4196
-
4197
4362
  uploadFiles(files, { selectLast } = {}) {
4198
4363
  if (!this.editorElement.supportsAttachments) {
4199
4364
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
@@ -4251,6 +4416,62 @@ class Contents {
4251
4416
  });
4252
4417
  }
4253
4418
 
4419
+ #insertNodeIfRoot(node) {
4420
+ const selection = $getSelection();
4421
+ if (!$isRangeSelection(selection)) return false
4422
+
4423
+ const anchorNode = selection.anchor.getNode();
4424
+ if ($isRootOrShadowRoot(anchorNode)) {
4425
+ anchorNode.append(node);
4426
+ node.selectEnd();
4427
+
4428
+ return true
4429
+ }
4430
+
4431
+ return false
4432
+ }
4433
+
4434
+ #splitParagraphsAtLineBreaks(selection) {
4435
+ const selectedNodeKeys = new Set(selection.getNodes().map(n => n.getKey()));
4436
+ const topLevelElements = this.#topLevelElementsInSelection(selection);
4437
+
4438
+ for (const element of topLevelElements) {
4439
+ if (!$isParagraphNode(element)) continue
4440
+
4441
+ const children = element.getChildren();
4442
+ if (!children.some($isLineBreakNode)) continue
4443
+
4444
+ const groups = [ [] ];
4445
+ for (const child of children) {
4446
+ if ($isLineBreakNode(child)) {
4447
+ groups.push([]);
4448
+ child.remove();
4449
+ } else {
4450
+ groups[groups.length - 1].push(child);
4451
+ }
4452
+ }
4453
+
4454
+ if (groups.every(group => group.some(child => selectedNodeKeys.has(child.getKey())))) continue
4455
+
4456
+ for (const group of groups) {
4457
+ if (group.length === 0) continue
4458
+ const paragraph = $createParagraphNode();
4459
+ group.forEach(child => paragraph.append(child));
4460
+ element.insertBefore(paragraph);
4461
+ }
4462
+ if (groups.some(group => group.length > 0)) element.remove();
4463
+ }
4464
+ }
4465
+
4466
+ #topLevelElementsInSelection(selection) {
4467
+ const elements = new Set();
4468
+ for (const node of selection.getNodes()) {
4469
+ const topLevel = node.getTopLevelElement();
4470
+ if (topLevel) elements.add(topLevel);
4471
+ }
4472
+ return Array.from(elements)
4473
+ }
4474
+
4254
4475
  #insertUploadNodes(nodes) {
4255
4476
  if (nodes.every($isActionTextAttachmentNode)) {
4256
4477
  const uploader = Uploader.for(this.editorElement, []);
@@ -4304,359 +4525,14 @@ class Contents {
4304
4525
  }
4305
4526
  }
4306
4527
 
4307
- #insertNodeWrappingAllSelectedNodes(newNodeFn) {
4308
- this.editor.update(() => {
4309
- const selection = $getSelection();
4310
- if (!$isRangeSelection(selection)) return
4311
-
4312
- const selectedNodes = selection.extract();
4313
- if (selectedNodes.length === 0) {
4314
- return
4315
- }
4316
-
4317
- const topLevelElements = new Set();
4318
- selectedNodes.forEach((node) => {
4319
- const topLevel = node.getTopLevelElementOrThrow();
4320
- topLevelElements.add(topLevel);
4321
- });
4322
-
4323
- const elements = this.#withoutTrailingEmptyParagraphs(Array.from(topLevelElements));
4324
- if (elements.length === 0) {
4325
- this.#removeStandaloneEmptyParagraph();
4326
- this.insertAtCursor(newNodeFn());
4327
- return
4328
- }
4329
-
4330
- const wrappingNode = newNodeFn();
4331
- elements[0].insertBefore(wrappingNode);
4332
- elements.forEach((element) => {
4333
- wrappingNode.append(element);
4334
- });
4335
- });
4336
- }
4337
-
4338
- #withoutTrailingEmptyParagraphs(elements) {
4339
- let lastNonEmptyIndex = elements.length - 1;
4340
-
4341
- // Find the last non-empty paragraph
4342
- while (lastNonEmptyIndex >= 0) {
4343
- const element = elements[lastNonEmptyIndex];
4344
- if (!$isParagraphNode(element) || !this.#isElementEmpty(element)) {
4345
- break
4346
- }
4347
- lastNonEmptyIndex--;
4348
- }
4349
-
4350
- return elements.slice(0, lastNonEmptyIndex + 1)
4351
- }
4352
-
4353
- #isElementEmpty(element) {
4354
- // Check text content first
4355
- if (element.getTextContent().trim() !== "") return false
4356
-
4357
- // Check if it only contains line breaks
4358
- const children = element.getChildren();
4359
- return children.length === 0 || children.every(child => $isLineBreakNode(child))
4360
- }
4361
-
4362
- #removeStandaloneEmptyParagraph() {
4363
- const root = $getRoot();
4364
- if (root.getChildrenSize() === 1) {
4365
- const firstChild = root.getFirstChild();
4366
- if (firstChild && $isParagraphNode(firstChild) && this.#isElementEmpty(firstChild)) {
4367
- firstChild.remove();
4368
- }
4369
- }
4370
- }
4371
-
4372
- #insertNodeWrappingAllSelectedLines(newNodeFn) {
4373
- this.editor.update(() => {
4374
- const selection = $getSelection();
4375
- if (!$isRangeSelection(selection)) return
4376
-
4377
- if (selection.isCollapsed()) {
4378
- this.#wrapCurrentLine(selection, newNodeFn);
4379
- } else {
4380
- this.#wrapMultipleSelectedLines(selection, newNodeFn);
4381
- }
4382
- });
4383
- }
4384
-
4385
- #wrapCurrentLine(selection, newNodeFn) {
4386
- const anchorNode = selection.anchor.getNode();
4387
-
4388
- const topLevelElement = anchorNode.getTopLevelElementOrThrow();
4389
-
4390
- if (topLevelElement.getTextContent()) {
4391
- const wrappingNode = newNodeFn();
4392
- wrappingNode.append(...topLevelElement.getChildren());
4393
- topLevelElement.replace(wrappingNode);
4394
- } else {
4395
- selection.insertNodes([ newNodeFn() ]);
4396
- }
4397
- }
4398
-
4399
- #wrapMultipleSelectedLines(selection, newNodeFn) {
4400
- const selectedParagraphs = this.#extractSelectedParagraphs(selection);
4401
- if (selectedParagraphs.length === 0) return
4402
-
4403
- const { lineSet, nodesToDelete } = this.#extractUniqueLines(selectedParagraphs);
4404
- if (lineSet.size === 0) return
4405
-
4406
- const wrappingNode = this.#createWrappingNodeWithLines(newNodeFn, lineSet);
4407
- this.#replaceWithWrappingNode(selection, wrappingNode);
4408
- this.#removeNodes(nodesToDelete);
4409
- }
4410
-
4411
- #extractSelectedParagraphs(selection) {
4412
- const selectedNodes = selection.extract();
4413
- const selectedParagraphs = selectedNodes
4414
- .map((node) => this.#getParagraphFromNode(node))
4415
- .filter(Boolean);
4416
-
4417
- $setSelection(null);
4418
- return selectedParagraphs
4419
- }
4420
-
4421
- #getParagraphFromNode(node) {
4422
- if ($isParagraphNode(node)) return node
4423
- if ($isTextNode(node) && node.getParent() && $isParagraphNode(node.getParent())) {
4424
- return node.getParent()
4425
- }
4426
- return null
4427
- }
4428
-
4429
- #extractUniqueLines(selectedParagraphs) {
4430
- const lineSet = new Set();
4431
- const nodesToDelete = new Set();
4432
-
4433
- selectedParagraphs.forEach((paragraphNode) => {
4434
- const textContent = paragraphNode.getTextContent();
4435
- if (textContent) {
4436
- textContent.split("\n").forEach((line) => {
4437
- if (line.trim()) lineSet.add(line);
4438
- });
4439
- }
4440
- nodesToDelete.add(paragraphNode);
4441
- });
4442
-
4443
- return { lineSet, nodesToDelete }
4444
- }
4445
-
4446
- #createWrappingNodeWithLines(newNodeFn, lineSet) {
4447
- const wrappingNode = newNodeFn();
4448
- const lines = Array.from(lineSet);
4449
-
4450
- lines.forEach((lineText, index) => {
4451
- wrappingNode.append($createTextNode(lineText));
4452
- if (index < lines.length - 1) {
4453
- wrappingNode.append($createLineBreakNode());
4454
- }
4455
- });
4456
-
4457
- return wrappingNode
4458
- }
4459
-
4460
- #replaceWithWrappingNode(selection, wrappingNode) {
4461
- const anchorNode = selection.anchor.getNode();
4462
- const parent = anchorNode.getParent();
4463
- if (parent) {
4464
- parent.replace(wrappingNode);
4465
- }
4466
- }
4467
-
4468
- #removeNodes(nodesToDelete) {
4469
- nodesToDelete.forEach((node) => node.remove());
4470
- }
4471
-
4472
- #getSelectedParagraphWithSoftLineBreaks(selection) {
4473
- const anchorParagraph = this.#getParagraphFromNode(selection.anchor.getNode());
4474
- const focusParagraph = this.#getParagraphFromNode(selection.focus.getNode());
4475
-
4476
- if (!anchorParagraph || anchorParagraph !== focusParagraph) return null
4477
- if ($isQuoteNode(anchorParagraph.getParent())) return null
4478
-
4479
- return this.#paragraphHasSoftLineBreaks(anchorParagraph) ? anchorParagraph : null
4480
- }
4481
-
4482
- #paragraphHasSoftLineBreaks(paragraph) {
4483
- return paragraph.getChildren().some((child) => $isLineBreakNode(child))
4484
- }
4485
-
4486
- #splitParagraphIntoLines(paragraph) {
4487
- const lines = [ [] ];
4488
-
4489
- paragraph.getChildren().forEach((child) => {
4490
- if ($isLineBreakNode(child)) {
4491
- lines.push([]);
4492
- } else {
4493
- lines[lines.length - 1].push(child);
4494
- }
4495
- });
4496
-
4497
- return lines
4498
- }
4499
-
4500
- #getSelectedLineRange(lines, selection) {
4501
- const selectedNodeKeys = new Set(
4502
- selection.getNodes().map((node) => node.getKey())
4503
- );
4504
-
4505
- selectedNodeKeys.add(selection.anchor.getNode().getKey());
4506
- selectedNodeKeys.add(selection.focus.getNode().getKey());
4507
-
4508
- const selectedLineIndexes = lines
4509
- .map((lineNodes, index) => {
4510
- return lineNodes.some((node) => selectedNodeKeys.has(node.getKey())) ? index : null
4511
- })
4512
- .filter((index) => index !== null);
4513
-
4514
- if (selectedLineIndexes.length === 0) return null
4515
-
4516
- return {
4517
- start: selectedLineIndexes[0],
4518
- end: selectedLineIndexes[selectedLineIndexes.length - 1]
4519
- }
4520
- }
4521
-
4522
- #replaceParagraphWithWrappedSelectedLines(paragraph, lines, { start, end }, newNodeFn) {
4523
- const insertedNodes = [];
4524
-
4525
- this.#appendParagraphsForLines(insertedNodes, lines.slice(0, start));
4526
-
4527
- const wrappingNode = newNodeFn();
4528
- lines.slice(start, end + 1).forEach((lineNodes) => {
4529
- wrappingNode.append(this.#createParagraphFromLine(lineNodes));
4530
- });
4531
- insertedNodes.push(wrappingNode);
4532
-
4533
- this.#appendParagraphsForLines(insertedNodes, lines.slice(end + 1));
4534
-
4535
- let previousNode = null;
4536
- insertedNodes.forEach((node) => {
4537
- if (previousNode) {
4538
- previousNode.insertAfter(node);
4539
- } else {
4540
- paragraph.insertBefore(node);
4541
- }
4542
-
4543
- previousNode = node;
4544
- });
4545
-
4546
- paragraph.remove();
4547
- }
4548
-
4549
- #appendParagraphsForLines(insertedNodes, lines) {
4550
- lines.forEach((lineNodes) => {
4551
- insertedNodes.push(this.#createParagraphFromLine(lineNodes));
4552
- });
4553
- }
4554
-
4555
- #createParagraphFromLine(lineNodes) {
4556
- const paragraph = $createParagraphNode();
4557
-
4558
- if (lineNodes.length === 0) {
4559
- paragraph.append($createLineBreakNode());
4560
- } else {
4561
- paragraph.append(...lineNodes);
4562
- }
4563
-
4564
- return paragraph
4565
- }
4566
-
4567
- #collectSelectedListItems(selection) {
4568
- const nodes = selection.getNodes();
4569
- const listItems = new Set();
4570
- const parentLists = new Set();
4571
-
4572
- for (const node of nodes) {
4573
- const listItem = $getNearestNodeOfType(node, ListItemNode);
4574
- if (listItem) {
4575
- listItems.add(listItem);
4576
- const parentList = listItem.getParent();
4577
- if (parentList && $isListNode(parentList)) {
4578
- parentLists.add(parentList);
4579
- }
4580
- }
4581
- }
4582
-
4583
- return { listItems, parentLists }
4584
- }
4585
-
4586
- #convertListItemsToParagraphs(listItems) {
4587
- const newParagraphs = [];
4588
-
4589
- for (const listItem of listItems) {
4590
- const paragraph = this.#convertListItemToParagraph(listItem);
4591
- if (paragraph) {
4592
- newParagraphs.push(paragraph);
4593
- }
4594
- }
4595
-
4596
- return newParagraphs
4597
- }
4598
-
4599
- #convertListItemToParagraph(listItem) {
4600
- const parentList = listItem.getParent();
4601
- if (!parentList || !$isListNode(parentList)) return null
4602
-
4603
- const paragraph = $createParagraphNode();
4604
- const sublists = this.#extractSublistsAndContent(listItem, paragraph);
4605
-
4606
- listItem.insertAfter(paragraph);
4607
- this.#insertSublists(paragraph, sublists);
4608
- listItem.remove();
4609
-
4610
- return paragraph
4611
- }
4612
-
4613
- #extractSublistsAndContent(listItem, paragraph) {
4614
- const sublists = [];
4615
-
4616
- listItem.getChildren().forEach((child) => {
4617
- if ($isListNode(child)) {
4618
- sublists.push(child);
4619
- } else {
4620
- paragraph.append(child);
4621
- }
4622
- });
4623
-
4624
- return sublists
4625
- }
4626
-
4627
- #insertSublists(paragraph, sublists) {
4628
- sublists.forEach((sublist) => {
4629
- paragraph.insertAfter(sublist);
4630
- });
4631
- }
4632
-
4633
- #removeEmptyParentLists(parentLists) {
4634
- for (const parentList of parentLists) {
4635
- if ($isListNode(parentList) && parentList.getChildrenSize() === 0) {
4636
- parentList.remove();
4637
- }
4638
- }
4639
- }
4640
-
4641
- #selectNewParagraphs(newParagraphs) {
4642
- if (newParagraphs.length === 0) return
4643
-
4644
- const firstParagraph = newParagraphs[0];
4645
- const lastParagraph = newParagraphs[newParagraphs.length - 1];
4646
-
4647
- if (newParagraphs.length === 1) {
4648
- firstParagraph.selectEnd();
4649
- } else {
4650
- this.#selectParagraphRange(firstParagraph, lastParagraph);
4651
- }
4652
- }
4653
-
4654
- #selectParagraphRange(firstParagraph, lastParagraph) {
4655
- firstParagraph.selectStart();
4656
- const currentSelection = $getSelection();
4657
- if (currentSelection && $isRangeSelection(currentSelection)) {
4658
- currentSelection.anchor.set(firstParagraph.getKey(), 0, "element");
4659
- currentSelection.focus.set(lastParagraph.getKey(), lastParagraph.getChildrenSize(), "element");
4528
+ // Table cells copied from a page inherit the source theme's inline color
4529
+ // styles (e.g. dark-mode backgrounds). Strip them so pasted tables adopt
4530
+ // the current theme instead of carrying stale colors.
4531
+ #stripTableCellColorStyles(doc) {
4532
+ for (const cell of doc.querySelectorAll("td, th")) {
4533
+ cell.style.removeProperty("background-color");
4534
+ cell.style.removeProperty("background");
4535
+ cell.style.removeProperty("color");
4660
4536
  }
4661
4537
  }
4662
4538
 
@@ -4683,9 +4559,8 @@ class Contents {
4683
4559
  const textBeforeString = fullText.slice(0, lastIndex);
4684
4560
  const textAfterCursor = fullText.slice(offset);
4685
4561
 
4686
- const trailingSpacer = this.#hasInlineDecoratorNode(replacementNodes) ? "\u2060" : " ";
4687
4562
  const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString);
4688
- const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || trailingSpacer);
4563
+ const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || " ");
4689
4564
 
4690
4565
  anchorNode.replace(textNodeBefore);
4691
4566
 
@@ -4697,10 +4572,6 @@ class Contents {
4697
4572
  textNodeAfter.select(cursorOffset, cursorOffset);
4698
4573
  }
4699
4574
 
4700
- #hasInlineDecoratorNode(nodes) {
4701
- return nodes.some(node => node instanceof CustomActionTextAttachmentNode && node.isInline())
4702
- }
4703
-
4704
4575
  #cloneTextNodeFormatting(anchorNode, selection, text) {
4705
4576
  const parent = anchorNode.getParent();
4706
4577
  const fallbackFormat = parent?.getTextFormat?.() || 0;
@@ -5074,7 +4945,27 @@ class ProvisionalParagraphNode extends ParagraphNode {
5074
4945
  // https://github.com/facebook/lexical/blob/f1e4f66014377b1f2595aec2b0ee17f5b7ef4dfc/packages/lexical/src/LexicalNode.ts#L646
5075
4946
  isSelected(selection = null) {
5076
4947
  const targetSelection = selection || $getSelection();
5077
- return targetSelection?.getNodes().some(node => node.is(this) || this.isParentOf(node))
4948
+ if (!targetSelection) return false
4949
+
4950
+ if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
4951
+
4952
+ // A collapsed range selection on the parent element at an offset adjacent to
4953
+ // this node means the caret is visually at this paragraph's position. Treat it
4954
+ // as selected so the paragraph is visible and the caret renders correctly.
4955
+ //
4956
+ // Both the offset matching our index (cursor just before us) and index + 1
4957
+ // (cursor just after us) count, because the provisional paragraph is an
4958
+ // invisible spacer: the browser resolves both offsets to the same visual spot.
4959
+ if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
4960
+ const { anchor } = targetSelection;
4961
+ const parent = this.getParent();
4962
+ if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
4963
+ const index = this.getIndexWithinParent();
4964
+ return anchor.offset === index || anchor.offset === index + 1
4965
+ }
4966
+ }
4967
+
4968
+ return false
5078
4969
  }
5079
4970
 
5080
4971
  removeUnlessRequired(self = this.getLatest()) {
@@ -5355,6 +5246,365 @@ class TablesExtension extends LexxyExtension {
5355
5246
  }
5356
5247
  }
5357
5248
 
5249
+ const MIME_TYPE = "application/x-lexxy-node-key";
5250
+
5251
+ class AttachmentDragAndDrop {
5252
+ #editor
5253
+ #draggedNodeKey = null
5254
+ #rafId = null
5255
+ #draggingRafId = null
5256
+ #cleanupFns = []
5257
+
5258
+ constructor(editor) {
5259
+ this.#editor = editor;
5260
+
5261
+ // Register Lexical commands at HIGH priority to intercept before the
5262
+ // base @lexical/rich-text handlers (which return true and consume the events).
5263
+ this.#cleanupFns.push(
5264
+ editor.registerCommand(DRAGSTART_COMMAND, (event) => this.#handleDragStart(event), COMMAND_PRIORITY_HIGH),
5265
+ editor.registerCommand(DROP_COMMAND, (event) => this.#handleDrop(event), COMMAND_PRIORITY_HIGH),
5266
+ );
5267
+
5268
+ // Use a root listener to register DOM-level dragover/dragend handlers
5269
+ // (these events need throttled rAF handling that works better as DOM listeners).
5270
+ const unregister = editor.registerRootListener((root, prevRoot) => {
5271
+ if (prevRoot) {
5272
+ prevRoot.removeEventListener("dragover", this.#onDragOver);
5273
+ prevRoot.removeEventListener("dragend", this.#onDragEnd);
5274
+ }
5275
+ if (root) {
5276
+ root.addEventListener("dragover", this.#onDragOver);
5277
+ root.addEventListener("dragend", this.#onDragEnd);
5278
+ }
5279
+ });
5280
+ this.#cleanupFns.push(unregister);
5281
+ }
5282
+
5283
+ destroy() {
5284
+ this.#cleanup();
5285
+ for (const fn of this.#cleanupFns) fn();
5286
+ this.#cleanupFns = [];
5287
+ }
5288
+
5289
+ // -- Event handlers --------------------------------------------------------
5290
+
5291
+ #handleDragStart(event) {
5292
+ if (event.target.closest("textarea")) return false
5293
+
5294
+ const figure = event.target.closest("figure.attachment[data-lexical-node-key]");
5295
+ if (!figure) return false
5296
+
5297
+ this.#draggedNodeKey = figure.dataset.lexicalNodeKey;
5298
+ event.dataTransfer.setData(MIME_TYPE, this.#draggedNodeKey);
5299
+ event.dataTransfer.effectAllowed = "move";
5300
+
5301
+ // Add dragging class after a tick so it doesn't affect the drag image
5302
+ this.#draggingRafId = requestAnimationFrame(() => {
5303
+ this.#draggingRafId = null;
5304
+ figure.classList.add("lexxy-dragging");
5305
+ });
5306
+
5307
+ return true
5308
+ }
5309
+
5310
+ #onDragOver = (event) => {
5311
+ if (!this.#draggedNodeKey) return
5312
+
5313
+ event.preventDefault();
5314
+ event.dataTransfer.dropEffect = "move";
5315
+
5316
+ if (!this.#rafId) {
5317
+ this.#rafId = requestAnimationFrame(() => {
5318
+ this.#rafId = null;
5319
+ this.#updateDropTarget(event);
5320
+ });
5321
+ }
5322
+ }
5323
+
5324
+ #handleDrop(event) {
5325
+ if (!this.#draggedNodeKey) return false
5326
+
5327
+ event.preventDefault();
5328
+
5329
+ const target = this.#resolveDropTarget(event);
5330
+ const draggedKey = this.#draggedNodeKey;
5331
+ this.#cleanup();
5332
+
5333
+ if (target) {
5334
+ this.#performDrop(draggedKey, target);
5335
+ }
5336
+ return true
5337
+ }
5338
+
5339
+ #onDragEnd = () => {
5340
+ this.#cleanup();
5341
+ }
5342
+
5343
+ // -- Drop target resolution -----------------------------------------------
5344
+
5345
+ #updateDropTarget(event) {
5346
+ this.#clearDropIndicators();
5347
+
5348
+ const target = this.#resolveDropTarget(event);
5349
+ if (!target) return
5350
+
5351
+ if (target.type === "gallery" || target.type === "gallery-reorder") {
5352
+ target.element.classList.add(`lexxy-drop-target--gallery-${target.position}`);
5353
+ } else if (target.type === "list-item") {
5354
+ target.element.classList.add(`lexxy-drop-target--list-${target.position}`);
5355
+ } else {
5356
+ target.element.classList.add(`lexxy-drop-target--block-${target.position}`);
5357
+ }
5358
+ }
5359
+
5360
+ #resolveDropTarget(event) {
5361
+ const element = document.elementFromPoint(event.clientX, event.clientY);
5362
+ if (!element) return null
5363
+
5364
+ const rootElement = this.#editor.getRootElement();
5365
+ if (!rootElement || !rootElement.contains(element)) return null
5366
+
5367
+ // Check if hovering over a previewable image (for gallery merge or reorder)
5368
+ const targetFigure = element.closest("figure.attachment--preview[data-lexical-node-key]");
5369
+ if (targetFigure && targetFigure.dataset.lexicalNodeKey !== this.#draggedNodeKey) {
5370
+ const targetGallery = targetFigure.closest(".attachment-gallery");
5371
+ if (targetGallery) {
5372
+ // If the dragged image is in the same gallery, this is a reorder
5373
+ const draggedFigure = rootElement.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
5374
+ if (draggedFigure && targetGallery.contains(draggedFigure)) {
5375
+ const position = this.#computeHorizontalPosition(targetFigure, event.clientX);
5376
+ return { type: "gallery-reorder", element: targetFigure, nodeKey: targetFigure.dataset.lexicalNodeKey, position }
5377
+ }
5378
+ }
5379
+ const position = this.#computeHorizontalPosition(targetFigure, event.clientX);
5380
+ return { type: "gallery", element: targetFigure, nodeKey: targetFigure.dataset.lexicalNodeKey, position }
5381
+ }
5382
+
5383
+ // Hovering over the dragged image itself inside a gallery — treat as no-op
5384
+ // to prevent fallthrough to the block handler, which would eject it from the gallery.
5385
+ if (targetFigure && targetFigure.closest(".attachment-gallery")) return null
5386
+
5387
+ // Check if hovering over a gallery's empty space (for reorder within gallery)
5388
+ const targetGallery = element.closest(".attachment-gallery");
5389
+ if (targetGallery) {
5390
+ let galleryFigure = element.closest("figure.attachment[data-lexical-node-key]");
5391
+ if (!galleryFigure) {
5392
+ galleryFigure = this.#findNearestFigureInGallery(targetGallery, event.clientX);
5393
+ }
5394
+ if (galleryFigure && galleryFigure.dataset.lexicalNodeKey !== this.#draggedNodeKey) {
5395
+ const position = this.#computeHorizontalPosition(galleryFigure, event.clientX);
5396
+ return { type: "gallery-reorder", element: galleryFigure, nodeKey: galleryFigure.dataset.lexicalNodeKey, position }
5397
+ }
5398
+ // Nearest figure is the dragged image — no-op to avoid block handler fallthrough
5399
+ if (galleryFigure) return null
5400
+ }
5401
+
5402
+ // Check if hovering over a list item (for list splitting)
5403
+ const listItem = element.closest("li");
5404
+ if (listItem && rootElement.contains(listItem)) {
5405
+ const position = this.#computeVerticalPosition(listItem, event.clientY);
5406
+ return { type: "list-item", element: listItem, position }
5407
+ }
5408
+
5409
+ // Otherwise, find nearest block-level element for between-block insertion.
5410
+ // Normalize so each gap has exactly one indicator: prefer "after" on the
5411
+ // previous sibling, falling back to "before" only for the first block.
5412
+ const block = this.#findNearestBlock(element, rootElement, event.clientY);
5413
+ if (!block) return null
5414
+
5415
+ const position = this.#computeVerticalPosition(block, event.clientY);
5416
+ if (position === "before" && block.previousElementSibling) {
5417
+ return { type: "block", element: block.previousElementSibling, position: "after" }
5418
+ }
5419
+ return { type: "block", element: block, position }
5420
+ }
5421
+
5422
+ #findNearestBlock(element, rootElement, clientY) {
5423
+ let current = element;
5424
+ while (current && current !== rootElement) {
5425
+ if (current.parentElement === rootElement) return current
5426
+ current = current.parentElement;
5427
+ }
5428
+
5429
+ // elementFromPoint landed on the root itself (e.g. a margin gap between
5430
+ // blocks). Fall back to the nearest child by vertical distance.
5431
+ let nearest = null;
5432
+ let minDistance = Infinity;
5433
+ for (const child of rootElement.children) {
5434
+ const rect = child.getBoundingClientRect();
5435
+ const distance = Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom));
5436
+ if (distance < minDistance) {
5437
+ minDistance = distance;
5438
+ nearest = child;
5439
+ }
5440
+ }
5441
+ return nearest
5442
+ }
5443
+
5444
+ #computeVerticalPosition(element, clientY) {
5445
+ const rect = element.getBoundingClientRect();
5446
+ return clientY < rect.top + rect.height / 2 ? "before" : "after"
5447
+ }
5448
+
5449
+ #computeHorizontalPosition(element, clientX) {
5450
+ const rect = element.getBoundingClientRect();
5451
+ return clientX < rect.left + rect.width / 2 ? "before" : "after"
5452
+ }
5453
+
5454
+ #findNearestFigureInGallery(gallery, clientX) {
5455
+ const figures = gallery.querySelectorAll("figure.attachment[data-lexical-node-key]");
5456
+ let nearest = null;
5457
+ let minDistance = Infinity;
5458
+ for (const figure of figures) {
5459
+ const rect = figure.getBoundingClientRect();
5460
+ const center = rect.left + rect.width / 2;
5461
+ const distance = Math.abs(clientX - center);
5462
+ if (distance < minDistance) {
5463
+ minDistance = distance;
5464
+ nearest = figure;
5465
+ }
5466
+ }
5467
+ return nearest
5468
+ }
5469
+
5470
+ // -- Drop indicator --------------------------------------------------------
5471
+
5472
+ static #DROP_CLASSES = [
5473
+ "lexxy-drop-target--gallery-before", "lexxy-drop-target--gallery-after",
5474
+ "lexxy-drop-target--list-before", "lexxy-drop-target--list-after",
5475
+ "lexxy-drop-target--block-before", "lexxy-drop-target--block-after",
5476
+ ]
5477
+
5478
+ #clearDropIndicators() {
5479
+ const rootElement = this.#editor.getRootElement();
5480
+ if (!rootElement) return
5481
+
5482
+ for (const el of rootElement.querySelectorAll("[class*='lexxy-drop-target--']")) {
5483
+ el.classList.remove(...AttachmentDragAndDrop.#DROP_CLASSES);
5484
+ }
5485
+ }
5486
+
5487
+ // -- Node operations -------------------------------------------------------
5488
+
5489
+ #performDrop(draggedKey, target) {
5490
+ const draggedNode = $getNodeByKey(draggedKey);
5491
+ if (!draggedNode || !$isActionTextAttachmentNode(draggedNode)) return
5492
+
5493
+ if (target.type === "gallery") {
5494
+ this.#dropOntoImage(draggedNode, target.nodeKey, target.position);
5495
+ } else if (target.type === "gallery-reorder") {
5496
+ this.#reorderInGallery(draggedNode, target.nodeKey, target.position);
5497
+ } else if (target.type === "list-item") {
5498
+ this.#dropIntoList(draggedNode, target);
5499
+ } else {
5500
+ this.#dropBetweenBlocks(draggedNode, target);
5501
+ }
5502
+
5503
+ // Clear selection to prevent a second history entry. Lexical dispatches
5504
+ // SELECTION_CHANGE_COMMAND during commit for non-range selections, which
5505
+ // creates a separate update. Null selection avoids that dispatch entirely
5506
+ // and also prevents Firefox's follow-up selectionchange from dirtying nodes.
5507
+ $setSelection(null);
5508
+ }
5509
+
5510
+ #dropOntoImage(draggedNode, targetKey, position) {
5511
+ const targetNode = $getNodeByKey(targetKey);
5512
+ if (!targetNode || !$isActionTextAttachmentNode(targetNode)) return
5513
+ if (draggedNode.is(targetNode)) return
5514
+
5515
+ draggedNode.remove();
5516
+
5517
+ const gallery = $findOrCreateGalleryForImage(targetNode);
5518
+ if (gallery) {
5519
+ if (position === "before") {
5520
+ targetNode.insertBefore(draggedNode);
5521
+ } else {
5522
+ targetNode.insertAfter(draggedNode);
5523
+ }
5524
+ }
5525
+ }
5526
+
5527
+ #reorderInGallery(draggedNode, targetKey, position) {
5528
+ const targetNode = $getNodeByKey(targetKey);
5529
+ if (!targetNode || draggedNode.is(targetNode)) return
5530
+
5531
+ draggedNode.remove();
5532
+
5533
+ if (position === "before") {
5534
+ targetNode.insertBefore(draggedNode);
5535
+ } else {
5536
+ targetNode.insertAfter(draggedNode);
5537
+ }
5538
+ }
5539
+
5540
+ #dropIntoList(draggedNode, target) {
5541
+ const listItemNode = $getNearestNodeFromDOMNode(target.element);
5542
+ if (!listItemNode || !$isListItemNode(listItemNode)) return
5543
+
5544
+ const listNode = listItemNode.getParent();
5545
+ if (!listNode || !$isListNode(listNode)) return
5546
+
5547
+ const children = listNode.getChildren();
5548
+ const index = children.indexOf(listItemNode);
5549
+ if (index === -1) return
5550
+
5551
+ const splitIndex = target.position === "before" ? index : index + 1;
5552
+
5553
+ draggedNode.remove();
5554
+
5555
+ if (splitIndex === 0) {
5556
+ listNode.insertBefore(draggedNode);
5557
+ } else if (splitIndex >= children.length) {
5558
+ listNode.insertAfter(draggedNode);
5559
+ } else {
5560
+ const [ , listAfter ] = $splitNode(listNode, splitIndex);
5561
+ listAfter.insertBefore(draggedNode);
5562
+ }
5563
+ }
5564
+
5565
+ #dropBetweenBlocks(draggedNode, target) {
5566
+ const targetNode = $getNearestNodeFromDOMNode(target.element);
5567
+ if (!targetNode) return
5568
+
5569
+ const topLevelTarget = targetNode.getTopLevelElement?.() || targetNode;
5570
+ if (draggedNode.is(topLevelTarget)) return
5571
+
5572
+ draggedNode.remove();
5573
+
5574
+ if (target.position === "before") {
5575
+ topLevelTarget.insertBefore(draggedNode);
5576
+ } else {
5577
+ topLevelTarget.insertAfter(draggedNode);
5578
+ }
5579
+ }
5580
+
5581
+ // -- Lifecycle helpers -----------------------------------------------------
5582
+
5583
+ #cleanup() {
5584
+ this.#clearDropIndicators();
5585
+
5586
+ if (this.#draggedNodeKey) {
5587
+ const rootElement = this.#editor.getRootElement();
5588
+ if (rootElement) {
5589
+ const figure = rootElement.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
5590
+ figure?.classList.remove("lexxy-dragging");
5591
+ }
5592
+ }
5593
+
5594
+ this.#draggedNodeKey = null;
5595
+
5596
+ if (this.#rafId) {
5597
+ cancelAnimationFrame(this.#rafId);
5598
+ this.#rafId = null;
5599
+ }
5600
+
5601
+ if (this.#draggingRafId) {
5602
+ cancelAnimationFrame(this.#draggingRafId);
5603
+ this.#draggingRafId = null;
5604
+ }
5605
+ }
5606
+ }
5607
+
5358
5608
  class AttachmentsExtension extends LexxyExtension {
5359
5609
  get enabled() {
5360
5610
  return this.editorElement.supportsAttachments
@@ -5369,14 +5619,37 @@ class AttachmentsExtension extends LexxyExtension {
5369
5619
  ImageGalleryNode
5370
5620
  ],
5371
5621
  register(editor) {
5622
+ const dragAndDrop = new AttachmentDragAndDrop(editor);
5623
+
5372
5624
  return mergeRegister(
5373
- editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL)
5625
+ editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
5626
+ editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
5627
+ () => dragAndDrop.destroy()
5374
5628
  )
5375
5629
  }
5376
5630
  })
5377
5631
  }
5378
5632
  }
5379
5633
 
5634
+ // Decorator nodes can be wrapped in a Paragraph Node by Lexical when contained in a <div>
5635
+ // We remove them, splitting the node as needed
5636
+ function $extractAttachmentFromParagraph(attachmentNode) {
5637
+ const parentNode = attachmentNode.getParent();
5638
+ if (!$isParagraphNode(parentNode)) return
5639
+
5640
+ if (parentNode.getChildrenSize() === 1) {
5641
+ parentNode.replace(attachmentNode);
5642
+ } else {
5643
+ const index = attachmentNode.getIndexWithinParent();
5644
+ const [ topParagraph, bottomParagraph ] = $splitNode(parentNode, index);
5645
+ topParagraph.insertAfter(attachmentNode);
5646
+
5647
+ for (const p of [ topParagraph, bottomParagraph ]) {
5648
+ if (p.isEmpty()) p.remove();
5649
+ }
5650
+ }
5651
+ }
5652
+
5380
5653
  function $collapseIntoGallery(backwards) {
5381
5654
  const anchor = $getSelection()?.anchor;
5382
5655
  if (!anchor) return false
@@ -5442,6 +5715,178 @@ function $moveSelectionBeforeGallery(anchor) {
5442
5715
  return true
5443
5716
  }
5444
5717
 
5718
+ class EarlyEscapeCodeNode extends CodeNode {
5719
+ $config() {
5720
+ return this.config("early_escape_code", { extends: CodeNode })
5721
+ }
5722
+
5723
+ static $fromSelection(selection) {
5724
+ const anchorNode = selection.anchor.getNode();
5725
+ return $getNearestNodeOfType(anchorNode, EarlyEscapeCodeNode)
5726
+ || (anchorNode instanceof EarlyEscapeCodeNode ? anchorNode : null)
5727
+ }
5728
+
5729
+ insertNewAfter(selection, restoreSelection) {
5730
+ if (!selection.isCollapsed()) return super.insertNewAfter(selection, restoreSelection)
5731
+
5732
+ if (this.#isCursorOnEmptyLastLine(selection)) {
5733
+ $trimTrailingBlankNodes(this);
5734
+
5735
+ const paragraph = $createParagraphNode();
5736
+ this.insertAfter(paragraph);
5737
+ return paragraph
5738
+ }
5739
+
5740
+ return super.insertNewAfter(selection, restoreSelection)
5741
+ }
5742
+
5743
+ #isCursorOnEmptyLastLine(selection) {
5744
+ if (!$isCursorOnLastLine(selection)) return false
5745
+
5746
+ const textContent = this.getTextContent();
5747
+ return textContent === "" || textContent.endsWith("\n")
5748
+ }
5749
+
5750
+ }
5751
+
5752
+ class EarlyEscapeListItemNode extends ListItemNode {
5753
+ $config() {
5754
+ return this.config("early_escape_listitem", { extends: ListItemNode })
5755
+ }
5756
+
5757
+ insertNewAfter(selection, restoreSelection) {
5758
+ if (this.#shouldEscape(selection)) {
5759
+ return this.#escapeFromList()
5760
+ }
5761
+
5762
+ return super.insertNewAfter(selection, restoreSelection)
5763
+ }
5764
+
5765
+ #shouldEscape(selection) {
5766
+ if (!$getNearestNodeOfType(this, QuoteNode)) return false
5767
+ if ($isBlankNode(this)) return true
5768
+
5769
+ const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
5770
+ return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
5771
+ }
5772
+
5773
+ #escapeFromList() {
5774
+ const parentList = this.getParent();
5775
+ if (!parentList || !$isListNode(parentList)) return
5776
+
5777
+ const blockquote = parentList.getParent();
5778
+ const isInBlockquote = blockquote && $isQuoteNode(blockquote);
5779
+
5780
+ if (isInBlockquote) {
5781
+ const hasNonEmptyListItems = this.getNextSiblings().some(
5782
+ sibling => $isListItemNode(sibling) && !$isBlankNode(sibling)
5783
+ );
5784
+
5785
+ if (hasNonEmptyListItems) {
5786
+ return this.#splitBlockquoteWithList()
5787
+ }
5788
+ }
5789
+
5790
+ const paragraph = $createParagraphNode();
5791
+ parentList.insertAfter(paragraph);
5792
+
5793
+ this.remove();
5794
+ return paragraph
5795
+ }
5796
+
5797
+ #splitBlockquoteWithList() {
5798
+ const splitQuotes = $splitNode(this.getParent(), this.getIndexWithinParent());
5799
+ this.remove();
5800
+
5801
+ const middleParagraph = $createParagraphNode();
5802
+ splitQuotes[0].insertAfter(middleParagraph);
5803
+
5804
+ splitQuotes.forEach($trimTrailingBlankNodes);
5805
+
5806
+ return middleParagraph
5807
+ }
5808
+
5809
+ }
5810
+
5811
+ class FormatEscapeExtension extends LexxyExtension {
5812
+
5813
+ get enabled() {
5814
+ return this.editorElement.supportsRichText
5815
+ }
5816
+
5817
+ get lexicalExtension() {
5818
+ return defineExtension({
5819
+ name: "lexxy/format-escape",
5820
+ nodes: [
5821
+ EarlyEscapeCodeNode,
5822
+ { replace: CodeNode, with: (node) => new EarlyEscapeCodeNode(node.getLanguage()), withKlass: EarlyEscapeCodeNode },
5823
+ EarlyEscapeListItemNode,
5824
+ { replace: ListItemNode, with: () => new EarlyEscapeListItemNode(), withKlass: EarlyEscapeListItemNode },
5825
+ ],
5826
+ register(editor) {
5827
+ return mergeRegister(
5828
+ editor.registerCommand(
5829
+ INSERT_PARAGRAPH_COMMAND,
5830
+ () => $escapeFromBlockquote(),
5831
+ COMMAND_PRIORITY_HIGH
5832
+ ),
5833
+ editor.registerCommand(
5834
+ KEY_ARROW_DOWN_COMMAND,
5835
+ (event) => $handleArrowDownInCodeBlock(event),
5836
+ COMMAND_PRIORITY_NORMAL
5837
+ )
5838
+ )
5839
+ }
5840
+ })
5841
+ }
5842
+ }
5843
+
5844
+ function $escapeFromBlockquote() {
5845
+ const anchorNode = $getSelection().anchor.getNode();
5846
+
5847
+ const paragraph = $getNearestNodeOfType(anchorNode, ParagraphNode);
5848
+ if (!paragraph || !$isBlankNode(paragraph)) return false
5849
+
5850
+ const blockquote = paragraph.getParent();
5851
+ if (!blockquote || !$isQuoteNode(blockquote)) return false
5852
+
5853
+ const nonEmptySiblings = paragraph.getNextSiblings().filter(sibling => !$isBlankNode(sibling));
5854
+
5855
+ if (nonEmptySiblings.length > 0) {
5856
+ $splitQuoteNode(blockquote, paragraph);
5857
+ } else {
5858
+ blockquote.insertAfter(paragraph);
5859
+ paragraph.selectStart();
5860
+ }
5861
+
5862
+ return true
5863
+ }
5864
+
5865
+ function $splitQuoteNode(node, paragraph) {
5866
+ const splitQuotes = $splitNode(node, paragraph.getIndexWithinParent());
5867
+ splitQuotes[0].insertAfter(paragraph);
5868
+ splitQuotes.forEach($trimTrailingBlankNodes);
5869
+ paragraph.selectEnd();
5870
+ }
5871
+
5872
+ function $handleArrowDownInCodeBlock(event) {
5873
+ const selection = $getSelection();
5874
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
5875
+
5876
+ const codeNode = EarlyEscapeCodeNode.$fromSelection(selection);
5877
+ if (!codeNode) return false
5878
+
5879
+ if ($isCursorOnLastLine(selection) && !codeNode.getNextSibling()) {
5880
+ event?.preventDefault();
5881
+ const paragraph = $createParagraphNode();
5882
+ codeNode.insertAfter(paragraph);
5883
+ paragraph.selectEnd();
5884
+ return true
5885
+ }
5886
+
5887
+ return false
5888
+ }
5889
+
5445
5890
  class LexicalEditorElement extends HTMLElement {
5446
5891
  static formAssociated = true
5447
5892
  static debug = false
@@ -5502,7 +5947,7 @@ class LexicalEditorElement extends HTMLElement {
5502
5947
  }
5503
5948
 
5504
5949
  toString() {
5505
- if (!this.cachedStringValue) {
5950
+ if (this.cachedStringValue == null) {
5506
5951
  this.editor?.getEditorState().read(() => {
5507
5952
  this.cachedStringValue = $getReadableTextContent($getRoot());
5508
5953
  });
@@ -5532,7 +5977,8 @@ class LexicalEditorElement extends HTMLElement {
5532
5977
  HighlightExtension,
5533
5978
  TrixContentExtension,
5534
5979
  TablesExtension,
5535
- AttachmentsExtension
5980
+ AttachmentsExtension,
5981
+ FormatEscapeExtension
5536
5982
  ]
5537
5983
  }
5538
5984
 
@@ -5623,7 +6069,6 @@ class LexicalEditorElement extends HTMLElement {
5623
6069
  return nodes
5624
6070
  .filter(this.#isNotWhitespaceOnlyNode)
5625
6071
  .map(this.#wrapTextNode)
5626
- .map(this.#unwrapDecoratorNode)
5627
6072
  }
5628
6073
 
5629
6074
  // Whitespace-only text nodes (e.g. "\n" between block elements like <div>) and stray line break
@@ -5645,18 +6090,6 @@ class LexicalEditorElement extends HTMLElement {
5645
6090
  return paragraph
5646
6091
  }
5647
6092
 
5648
- // Custom decorator block elements such as action-text-attachments get wrapped into <p> automatically by Lexical.
5649
- // We unwrap those.
5650
- #unwrapDecoratorNode(node) {
5651
- if ($isParagraphNode(node) && node.getChildrenSize() === 1) {
5652
- const child = node.getFirstChild();
5653
- if ($isDecoratorNode(child) && !child.isInline()) {
5654
- return child
5655
- }
5656
- }
5657
- return node
5658
- }
5659
-
5660
6093
  #initialize() {
5661
6094
  this.#synchronizeWithChanges();
5662
6095
  this.#registerComponents();
@@ -5895,22 +6328,23 @@ class LexicalEditorElement extends HTMLElement {
5895
6328
  }
5896
6329
 
5897
6330
  #findOrCreateDefaultToolbar() {
5898
- const toolbarId = this.config.get("toolbar");
5899
- if (toolbarId && toolbarId !== true) {
5900
- return document.getElementById(toolbarId)
6331
+ const toolbarConfig = this.config.get("toolbar");
6332
+ if (typeof toolbarConfig === "string") {
6333
+ return document.getElementById(toolbarConfig)
5901
6334
  } else {
5902
6335
  return this.#createDefaultToolbar()
5903
6336
  }
5904
6337
  }
5905
6338
 
5906
6339
  get #hasToolbar() {
5907
- return this.supportsRichText && this.config.get("toolbar")
6340
+ return this.supportsRichText && !!this.config.get("toolbar")
5908
6341
  }
5909
6342
 
5910
6343
  #createDefaultToolbar() {
5911
6344
  const toolbar = createElement("lexxy-toolbar");
5912
6345
  toolbar.innerHTML = LexicalToolbarElement.defaultTemplate;
5913
6346
  toolbar.setAttribute("data-attachments", this.supportsAttachments); // Drives toolbar CSS styles
6347
+ toolbar.configure(this.config.get("toolbar"));
5914
6348
  this.prepend(toolbar);
5915
6349
  return toolbar
5916
6350
  }
@@ -5977,6 +6411,10 @@ function $getReadableTextContent(node) {
5977
6411
  const children = node.getChildren();
5978
6412
  for (let i = 0; i < children.length; i++) {
5979
6413
  const child = children[i];
6414
+ const previousChild = children[i - 1];
6415
+
6416
+ if (isAttachmentSpacerTextNode(child, previousChild, i, children.length)) continue
6417
+
5980
6418
  text += $getReadableTextContent(child);
5981
6419
  if ($isElementNode(child) && i !== children.length - 1 && !child.isInline()) {
5982
6420
  text += "\n\n";
@@ -6361,6 +6799,7 @@ class LexicalPromptElement extends HTMLElement {
6361
6799
  constructor() {
6362
6800
  super();
6363
6801
  this.keyListeners = [];
6802
+ this.showPopoverId = 0;
6364
6803
  }
6365
6804
 
6366
6805
  static observedAttributes = [ "connected" ]
@@ -6506,9 +6945,14 @@ class LexicalPromptElement extends HTMLElement {
6506
6945
  }
6507
6946
 
6508
6947
  async #showPopover() {
6948
+ const showId = ++this.showPopoverId;
6509
6949
  this.popoverElement ??= await this.#buildPopover();
6950
+ if (this.showPopoverId !== showId) return
6951
+
6510
6952
  this.#resetPopoverPosition();
6511
6953
  await this.#filterOptions();
6954
+ if (this.showPopoverId !== showId) return
6955
+
6512
6956
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", true);
6513
6957
  this.#selectFirstOption();
6514
6958
 
@@ -6619,6 +7063,7 @@ class LexicalPromptElement extends HTMLElement {
6619
7063
  }
6620
7064
 
6621
7065
  async #hidePopover() {
7066
+ this.showPopoverId++;
6622
7067
  this.#clearSelection();
6623
7068
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", false);
6624
7069
  this.#editorElement.removeEventListener("lexxy:change", this.#filterOptions);
@@ -6644,6 +7089,14 @@ class LexicalPromptElement extends HTMLElement {
6644
7089
 
6645
7090
  if (this.#editorContents.containsTextBackUntil(this.trigger)) {
6646
7091
  await this.#showFilteredOptions();
7092
+
7093
+ // Re-check after async operation — the trigger may have been consumed
7094
+ // (e.g. markdown heading shortcut converted "# " to h1 during the fetch)
7095
+ if (!this.#editorContents.containsTextBackUntil(this.trigger)) {
7096
+ this.#hidePopover();
7097
+ return
7098
+ }
7099
+
6647
7100
  await nextFrame();
6648
7101
  this.#positionPopover();
6649
7102
  } else {
@@ -6652,8 +7105,12 @@ class LexicalPromptElement extends HTMLElement {
6652
7105
  }
6653
7106
 
6654
7107
  async #showFilteredOptions() {
7108
+ const showId = this.showPopoverId;
6655
7109
  const filter = this.#editorContents.textBackUntil(this.trigger);
6656
7110
  const filteredListItems = await this.source.buildListItems(filter);
7111
+ if (this.showPopoverId !== showId) return
7112
+ if (!this.#editorContents.containsTextBackUntil(this.trigger)) return
7113
+
6657
7114
  this.popoverElement.innerHTML = "";
6658
7115
 
6659
7116
  if (filteredListItems.length > 0) {
@@ -6968,21 +7425,16 @@ class NodeDeleteButton extends HTMLElement {
6968
7425
  this.editor = this.editorElement.editor;
6969
7426
  this.classList.add("lexxy-floating-controls");
6970
7427
 
6971
- if (!this.deleteButton) {
7428
+ if (!this.querySelector(".lexxy-node-delete")) {
6972
7429
  this.#attachDeleteButton();
6973
7430
  }
6974
7431
  }
6975
7432
 
6976
7433
  disconnectedCallback() {
6977
- if (this.deleteButton && this.handleDeleteClick) {
6978
- this.deleteButton.removeEventListener("click", this.handleDeleteClick);
6979
- }
6980
-
6981
- this.handleDeleteClick = null;
6982
- this.deleteButton = null;
6983
7434
  this.editor = null;
6984
7435
  this.editorElement = null;
6985
7436
  }
7437
+
6986
7438
  #attachDeleteButton() {
6987
7439
  const container = createElement("div", { className: "lexxy-floating-controls__group" });
6988
7440