@37signals/lexxy 0.9.9-beta.preview5 → 0.9.10-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.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +1919 -1845
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -1,19 +1,19 @@
1
- import { isActiveAndVisible, createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
1
+ import { isActiveAndVisible, extractPlainTextFromHtml, createElement, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
2
2
  export { highlightCode } from './lexxy_helpers.esm.js';
3
3
  import DOMPurify from 'dompurify';
4
4
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $ensureForwardRangeSelection, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
5
- import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createParagraphNode, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, $createTextNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isParagraphNode, $splitNode, $getSiblingCaret, LineBreakNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, COMMAND_PRIORITY_EDITOR, $getEditor, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $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, $addUpdateTag, ElementNode, $getChildCaretAtIndex, $createLineBreakNode, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, $findMatchingParent, CLEAR_HISTORY_COMMAND, $onUpdate, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
5
+ import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isTextNode, $isParagraphNode, $splitNode, $getSiblingCaret, LineBreakNode, $createParagraphNode, TextNode, createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $getChildCaretAtIndex, $createLineBreakNode, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, $findMatchingParent, CLEAR_HISTORY_COMMAND, $onUpdate, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
6
6
  import { buildEditorFromExtensions } from '@lexical/extension';
7
7
  import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
8
8
  import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
9
- import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, $descendantsMatching, IS_APPLE } from '@lexical/utils';
9
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
10
10
  import { registerPlainText } from '@lexical/plain-text';
11
11
  import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
12
12
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
13
+ import { HistoryExtension } from '@lexical/history';
13
14
  import { $isCodeNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeHighlightNode, $createCodeHighlightNode, normalizeCodeLang, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
14
15
  import { TRANSFORMERS, registerMarkdownShortcuts } from '@lexical/markdown';
15
16
  import { INSERT_TABLE_COMMAND, $getTableCellNodeFromLexicalNode, TableCellNode, TableNode, TableRowNode, setScrollableTablesActive, registerTablePlugin, registerTableSelectionObserver, TableCellHeaderStates, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $findCellNode, $getElementForTableNode } from '@lexical/table';
16
- import { HistoryExtension } from '@lexical/history';
17
17
  import { marked } from 'marked';
18
18
  import { $insertDataTransferForRichText } from '@lexical/clipboard';
19
19
  import 'prismjs';
@@ -774,41 +774,185 @@ class LexicalToolbarElement extends HTMLElement {
774
774
  }
775
775
  }
776
776
 
777
- class HorizontalDividerNode extends DecoratorNode {
777
+ function deepMerge(target, source) {
778
+ const result = { ...target, ...source };
779
+ for (const [ key, value ] of Object.entries(source)) {
780
+ if (arePlainHashes(target[key], value)) {
781
+ result[key] = deepMerge(target[key], value);
782
+ }
783
+ }
784
+
785
+ return result
786
+ }
787
+
788
+ function arePlainHashes(...values) {
789
+ return values.every(value => value && value.constructor == Object)
790
+ }
791
+
792
+ class Configuration {
793
+ #tree = {}
794
+
795
+ constructor(...configs) {
796
+ this.merge(...configs);
797
+ }
798
+
799
+ merge(...configs) {
800
+ return this.#tree = configs.reduce(deepMerge, this.#tree)
801
+ }
802
+
803
+ get(path) {
804
+ const keys = path.split(".");
805
+ return keys.reduce((node, key) => node[key], this.#tree)
806
+ }
807
+ }
808
+
809
+ function range(from, to) {
810
+ return [ ...Array(1 + to - from).keys() ].map(i => i + from)
811
+ }
812
+
813
+ const global = new Configuration({
814
+ attachmentTagName: "action-text-attachment",
815
+ attachmentContentTypeNamespace: "actiontext",
816
+ authenticatedUploads: false,
817
+ extensions: []
818
+ });
819
+
820
+ const presets = new Configuration({
821
+ default: {
822
+ attachments: true,
823
+ markdown: true,
824
+ multiLine: true,
825
+ permittedAttachmentTypes: null,
826
+ richText: true,
827
+ toolbar: {
828
+ upload: "both"
829
+ },
830
+ highlight: {
831
+ buttons: {
832
+ color: range(1, 9).map(n => `var(--highlight-${n})`),
833
+ "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
834
+ },
835
+ permit: {
836
+ color: [],
837
+ "background-color": []
838
+ }
839
+ }
840
+ }
841
+ });
842
+
843
+ var Lexxy = {
844
+ global,
845
+ presets,
846
+ configure({ global: newGlobal, ...newPresets }) {
847
+ if (newGlobal) {
848
+ global.merge(newGlobal);
849
+ }
850
+ presets.merge(newPresets);
851
+ }
852
+ };
853
+
854
+ function setSanitizerConfig(allowedTags) {
855
+ DOMPurify.clearConfig();
856
+ DOMPurify.setConfig(buildConfig(allowedTags));
857
+ }
858
+
859
+ function sanitize(html) {
860
+ return DOMPurify.sanitize(html)
861
+ }
862
+
863
+ function bytesToHumanSize(bytes) {
864
+ if (bytes === 0) return "0 B"
865
+ const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
866
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
867
+ const value = bytes / Math.pow(1024, i);
868
+ return `${ value.toFixed(2) } ${ sizes[i] }`
869
+ }
870
+
871
+ function extractFileName(string) {
872
+ return string.split("/").pop()
873
+ }
874
+
875
+ // The content attribute is raw HTML (matching Trix/ActionText). Older Lexxy
876
+ // versions JSON-encoded it, so try JSON.parse first for backward compatibility.
877
+ function parseAttachmentContent(content) {
878
+ try {
879
+ return JSON.parse(content)
880
+ } catch {
881
+ return content
882
+ }
883
+ }
884
+
885
+ class CustomActionTextAttachmentNode extends DecoratorNode {
778
886
  static getType() {
779
- return "horizontal_divider"
887
+ return "custom_action_text_attachment"
780
888
  }
781
889
 
782
890
  static clone(node) {
783
- return new HorizontalDividerNode(node.__key)
891
+ return new CustomActionTextAttachmentNode({ ...node }, node.__key)
784
892
  }
785
893
 
786
894
  static importJSON(serializedNode) {
787
- return new HorizontalDividerNode()
895
+ return new CustomActionTextAttachmentNode({ ...serializedNode })
788
896
  }
789
897
 
790
898
  static importDOM() {
791
899
  return {
792
- "hr": (hr) => {
900
+ [this.TAG_NAME]: (element) => {
901
+ if (!element.getAttribute("content")) {
902
+ return null
903
+ }
904
+
793
905
  return {
794
- conversion: () => ({
795
- node: new HorizontalDividerNode()
796
- }),
797
- priority: 1
906
+ conversion: (attachment) => {
907
+ // Preserve initial space if present since Lexical removes it
908
+ const nodes = [];
909
+ const previousSibling = attachment.previousSibling;
910
+ if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
911
+ nodes.push($createTextNode(" "));
912
+ }
913
+
914
+ const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
915
+
916
+ nodes.push(new CustomActionTextAttachmentNode({
917
+ sgid: attachment.getAttribute("sgid"),
918
+ innerHtml,
919
+ plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
920
+ contentType: attachment.getAttribute("content-type")
921
+ }));
922
+
923
+ const nextSibling = attachment.nextSibling;
924
+ if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && /^\s/.test(nextSibling.textContent)) {
925
+ nodes.push($createTextNode(" "));
926
+ }
927
+
928
+ return { node: nodes }
929
+ },
930
+ priority: 2
798
931
  }
799
932
  }
800
933
  }
801
934
  }
802
935
 
803
- constructor(key) {
936
+ static get TAG_NAME() {
937
+ return Lexxy.global.get("attachmentTagName")
938
+ }
939
+
940
+ constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
804
941
  super(key);
942
+
943
+ const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
944
+
945
+ this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
946
+ this.sgid = sgid;
947
+ this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
948
+ this.innerHtml = innerHtml;
949
+ this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
805
950
  }
806
951
 
807
952
  createDOM() {
808
- const figure = createElement("figure", { className: "horizontal-divider" });
809
- const hr = createElement("hr");
953
+ const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
810
954
 
811
- figure.appendChild(hr);
955
+ figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
812
956
 
813
957
  const deleteButton = createElement("lexxy-node-delete-button");
814
958
  figure.appendChild(deleteButton);
@@ -817,26 +961,40 @@ class HorizontalDividerNode extends DecoratorNode {
817
961
  }
818
962
 
819
963
  updateDOM() {
820
- return true
964
+ return false
821
965
  }
822
966
 
823
967
  getTextContent() {
824
- return "┄\n\n"
968
+ return "\ufeff"
969
+ }
970
+
971
+ getReadableTextContent() {
972
+ return this.plainText || `[${this.contentType}]`
825
973
  }
826
974
 
827
975
  isInline() {
828
- return false
976
+ return true
829
977
  }
830
978
 
831
979
  exportDOM() {
832
- const hr = createElement("hr");
833
- return { element: hr }
980
+ const attachment = createElement(this.tagName, {
981
+ sgid: this.sgid,
982
+ content: this.innerHtml,
983
+ "content-type": this.contentType
984
+ });
985
+
986
+ return { element: attachment }
834
987
  }
835
988
 
836
989
  exportJSON() {
837
990
  return {
838
- type: "horizontal_divider",
839
- version: 1
991
+ type: "custom_action_text_attachment",
992
+ version: 1,
993
+ tagName: this.tagName,
994
+ sgid: this.sgid,
995
+ contentType: this.contentType,
996
+ innerHtml: this.innerHtml,
997
+ plainText: this.plainText
840
998
  }
841
999
  }
842
1000
 
@@ -845,407 +1003,439 @@ class HorizontalDividerNode extends DecoratorNode {
845
1003
  }
846
1004
  }
847
1005
 
848
- const HORIZONTAL_DIVIDER = {
849
- dependencies: [ HorizontalDividerNode ],
850
- export: (node) => {
851
- return node instanceof HorizontalDividerNode ? "---" : null
852
- },
853
- regExpStart: /^-{3,}\s?$/,
854
- replace: (parentNode, children, match, endMatch, linesInBetween, isImport) => {
855
- const hrNode = new HorizontalDividerNode();
856
- parentNode.replace(hrNode);
1006
+ function dasherize(value) {
1007
+ return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
1008
+ }
857
1009
 
858
- if (!isImport) {
859
- const paragraph = $createParagraphNode();
860
- hrNode.insertAfter(paragraph);
861
- paragraph.select();
862
- }
863
- },
864
- type: "multiline-element"
865
- };
1010
+ function isUrl(string) {
1011
+ try {
1012
+ new URL(string);
1013
+ return true
1014
+ } catch {
1015
+ return false
1016
+ }
1017
+ }
866
1018
 
867
- const PUNCTUATION_OR_SPACE = /[^\w]/;
1019
+ function normalizeFilteredText(string) {
1020
+ return string
1021
+ .toLowerCase()
1022
+ .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
1023
+ }
868
1024
 
869
- // Supplements Lexical's built-in registerMarkdownShortcuts to handle the case
870
- // where a user types a leading tag before text that already ends with a
871
- // trailing tag (e.g. typing ` before `hello`` or ** before **hello**).
872
- //
873
- // Lexical's markdown shortcut handler only triggers format transformations when
874
- // the closing tag is the character just typed. When the opening tag is typed
875
- // instead (e.g. typing ` before `hello`` to form ``hello``), the built-in
876
- // handler doesn't match because it looks backward from the cursor for an
877
- // opening tag, but the cursor is right after it.
878
- //
879
- // This listener detects that scenario for ALL text format transformers
880
- // (backtick, bold, italic, strikethrough, etc.) and applies the appropriate
881
- // format.
882
- function registerMarkdownLeadingTagHandler(editor, transformers) {
883
- const textFormatTransformers = transformers
884
- .filter(t => t.type === "text-format")
885
- .sort((a, b) => b.tag.length - a.tag.length); // Longer tags first
1025
+ function filterMatchPosition(text, potentialMatch) {
1026
+ const normalizedText = normalizeFilteredText(text);
1027
+ const normalizedMatch = normalizeFilteredText(potentialMatch);
886
1028
 
887
- return editor.registerUpdateListener(({ tags, dirtyLeaves, editorState, prevEditorState }) => {
888
- if (tags.has("historic") || tags.has("collaboration")) return
889
- if (editor.isComposing()) return
1029
+ if (!normalizedMatch) return 0
890
1030
 
891
- const selection = editorState.read($getSelection);
892
- const prevSelection = prevEditorState.read($getSelection);
1031
+ const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
1032
+ return match ? match.index : -1
1033
+ }
893
1034
 
894
- if (!$isRangeSelection(prevSelection) || !$isRangeSelection(selection) || !selection.isCollapsed()) return
1035
+ function upcaseFirst(string) {
1036
+ return string.charAt(0).toUpperCase() + string.slice(1)
1037
+ }
895
1038
 
896
- const anchorKey = selection.anchor.key;
897
- const anchorOffset = selection.anchor.offset;
1039
+ function escapeForRegExp(string) {
1040
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1041
+ }
898
1042
 
899
- if (!dirtyLeaves.has(anchorKey)) return
1043
+ // Parses a value that may arrive as a boolean or as a string (e.g. from DOM
1044
+ // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
1045
+ function parseBoolean(value) {
1046
+ if (typeof value === "string") return value === "true"
1047
+ return Boolean(value)
1048
+ }
900
1049
 
901
- const anchorNode = editorState.read(() => $getNodeByKey(anchorKey));
902
- if (!$isTextNode(anchorNode)) return
1050
+ class LexxyExtension {
1051
+ #editorElement
903
1052
 
904
- // Only trigger when cursor moved forward (typing)
905
- const prevOffset = prevSelection.anchor.key === anchorKey ? prevSelection.anchor.offset : 0;
906
- if (anchorOffset <= prevOffset) return
1053
+ constructor(editorElement) {
1054
+ this.#editorElement = editorElement;
1055
+ }
907
1056
 
908
- const textContent = editorState.read(() => anchorNode.getTextContent());
1057
+ get editorElement() {
1058
+ return this.#editorElement
1059
+ }
909
1060
 
910
- // Try each transformer, longest tags first
911
- for (const transformer of textFormatTransformers) {
912
- const tag = transformer.tag;
913
- const tagLen = tag.length;
1061
+ get editorConfig() {
1062
+ return this.#editorElement.config
1063
+ }
914
1064
 
915
- // The typed characters must end at the cursor position and form the opening tag
916
- const openTagStart = anchorOffset - tagLen;
917
- if (openTagStart < 0) continue
1065
+ // optional: defaults to true
1066
+ get enabled() {
1067
+ return true
1068
+ }
918
1069
 
919
- const candidateOpenTag = textContent.slice(openTagStart, anchorOffset);
920
- if (candidateOpenTag !== tag) continue
1070
+ get lexicalExtension() {
1071
+ return null
1072
+ }
921
1073
 
922
- // Disambiguate from longer tags: if the character before the opening tag
923
- // is the same as the tag character, this might be part of a longer tag
924
- // (e.g. seeing `*` when the user is actually typing `**`)
925
- const tagChar = tag[0];
926
- if (openTagStart > 0 && textContent[openTagStart - 1] === tagChar) continue
1074
+ get allowedElements() {
1075
+ return []
1076
+ }
927
1077
 
928
- // Check intraword constraint: if intraword is false, the character before
929
- // the opening tag must be a space, punctuation, or the start of the text
930
- if (transformer.intraword === false && openTagStart > 0) {
931
- const beforeChar = textContent[openTagStart - 1];
932
- if (beforeChar && !PUNCTUATION_OR_SPACE.test(beforeChar)) continue
933
- }
1078
+ initializeToolbar(_lexxyToolbar) {
934
1079
 
935
- // Search forward for a closing tag in the same text node
936
- const searchStart = anchorOffset;
937
- const closeTagIndex = textContent.indexOf(tag, searchStart);
938
- if (closeTagIndex < 0) continue
1080
+ }
1081
+ }
939
1082
 
940
- // Disambiguate closing tag from longer tags: if the character right after
941
- // the closing tag is the same as the tag character, skip
942
- // (e.g. `*hello**` — the first `*` at index 6 is part of `**`)
943
- if (textContent[closeTagIndex + tagLen] === tagChar) continue
1083
+ function $createNodeSelectionWith(...nodes) {
1084
+ const selection = $createNodeSelection();
1085
+ nodes.forEach(node => selection.add(node.getKey()));
1086
+ return selection
1087
+ }
944
1088
 
945
- // Also check if the character before the closing tag start is the same
946
- // tag character (e.g. the closing tag might be a suffix of a longer sequence)
947
- if (closeTagIndex > 0 && textContent[closeTagIndex - 1] === tagChar) continue
1089
+ function $isShadowRoot(node) {
1090
+ return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
1091
+ }
948
1092
 
949
- // There must be content between the tags (not just empty or whitespace-adjacent)
950
- const innerStart = anchorOffset;
951
- const innerEnd = closeTagIndex;
952
- if (innerEnd <= innerStart) continue
1093
+ function $makeSafeForRoot(node) {
1094
+ if ($isTextNode(node)) {
1095
+ return $wrapNodeInElement(node, $createParagraphNode)
1096
+ } else if (node.isParentRequired()) {
1097
+ const parent = node.createRequiredParent();
1098
+ return $wrapNodeInElement(node, parent)
1099
+ } else {
1100
+ return node
1101
+ }
1102
+ }
953
1103
 
954
- // No space immediately after opening tag
955
- if (textContent[innerStart] === " ") continue
1104
+ function getListType(node) {
1105
+ const list = $getNearestNodeOfType(node, ListNode);
1106
+ return list?.getListType() ?? null
1107
+ }
956
1108
 
957
- // No space immediately before closing tag
958
- if (textContent[innerEnd - 1] === " ") continue
1109
+ function isEditorFocused(editor) {
1110
+ const rootElement = editor.getRootElement();
1111
+ return rootElement !== null && rootElement.contains(document.activeElement)
1112
+ }
959
1113
 
960
- // Check intraword constraint for closing tag
961
- if (transformer.intraword === false) {
962
- const afterCloseChar = textContent[closeTagIndex + tagLen];
963
- if (afterCloseChar && !PUNCTUATION_OR_SPACE.test(afterCloseChar)) continue
964
- }
1114
+ function $isAtNodeEdge(point, atStart = null) {
1115
+ if (atStart === null) {
1116
+ return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
1117
+ } else {
1118
+ return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
1119
+ }
1120
+ }
965
1121
 
966
- editor.update(() => {
967
- const node = $getNodeByKey(anchorKey);
968
- if (!node || !$isTextNode(node)) return
1122
+ function $isAtNodeStart(point) {
1123
+ return point.offset === 0
1124
+ }
969
1125
 
970
- const parent = node.getParent();
971
- if (parent === null || $isCodeNode(parent)) return
1126
+ function extendTextNodeConversion(conversionName, ...callbacks) {
1127
+ return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
1128
+ ...conversionOutput,
1129
+ forChild: (lexicalNode, parentNode) => {
1130
+ const originalForChild = conversionOutput?.forChild ?? (x => x);
1131
+ let childNode = originalForChild(lexicalNode, parentNode);
972
1132
 
973
- $applyFormatFromLeadingTag(node, openTagStart, transformer);
974
- });
975
1133
 
976
- break // Only apply the first (longest) matching transformer
1134
+ if ($isTextNode(childNode)) {
1135
+ childNode = callbacks.reduce(
1136
+ (childNode, callback) => callback(childNode, element) ?? childNode,
1137
+ childNode
1138
+ );
1139
+ return childNode
1140
+ }
977
1141
  }
978
- })
1142
+ }))
979
1143
  }
980
1144
 
981
- function $applyFormatFromLeadingTag(anchorNode, openTagStart, transformer) {
982
- const tag = transformer.tag;
983
- const tagLen = tag.length;
984
- const textContent = anchorNode.getTextContent();
1145
+ function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
1146
+ return (element) => {
1147
+ const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
1148
+ if (!converter) return null
985
1149
 
986
- const innerStart = openTagStart + tagLen;
987
- const closeTagIndex = textContent.indexOf(tag, innerStart);
988
- if (closeTagIndex < 0) return
1150
+ const conversionOutput = converter.conversion(element);
1151
+ if (!conversionOutput) return conversionOutput
989
1152
 
990
- const inner = textContent.slice(innerStart, closeTagIndex);
991
- if (inner.length === 0) return
1153
+ return callback(conversionOutput, element) ?? conversionOutput
1154
+ }
1155
+ }
992
1156
 
993
- // Remove both tags and apply format
994
- const before = textContent.slice(0, openTagStart);
995
- const after = textContent.slice(closeTagIndex + tagLen);
1157
+ function $isCursorOnLastLine(selection) {
1158
+ const anchorNode = selection.anchor.getNode();
1159
+ const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
1160
+ const children = elementNode.getChildren();
1161
+ if (children.length === 0) return true
996
1162
 
997
- anchorNode.setTextContent(before + inner + after);
1163
+ const lastChild = children[children.length - 1];
998
1164
 
999
- const nextSelection = $createRangeSelection();
1000
- $setSelection(nextSelection);
1165
+ if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
1166
+ if (anchorNode === lastChild) return true
1001
1167
 
1002
- // Select the inner text to apply formatting
1003
- nextSelection.anchor.set(anchorNode.getKey(), openTagStart, "text");
1004
- nextSelection.focus.set(anchorNode.getKey(), openTagStart + inner.length, "text");
1168
+ const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
1169
+ if (lastLineBreakIndex === -1) return true
1005
1170
 
1006
- for (const format of transformer.format) {
1007
- if (!nextSelection.hasFormat(format)) {
1008
- nextSelection.formatText(format);
1009
- }
1010
- }
1171
+ const anchorIndex = children.indexOf(anchorNode);
1172
+ return anchorIndex > lastLineBreakIndex
1173
+ }
1011
1174
 
1012
- // Collapse selection to end of formatted text and clear the format
1013
- // so subsequent typing is plain text
1014
- nextSelection.anchor.set(nextSelection.focus.key, nextSelection.focus.offset, nextSelection.focus.type);
1175
+ function $isBlankNode(node) {
1176
+ if (node.getTextContent().trim() !== "") return false
1015
1177
 
1016
- for (const format of transformer.format) {
1017
- if (nextSelection.hasFormat(format)) {
1018
- nextSelection.toggleFormat(format);
1178
+ const children = node.getChildren?.();
1179
+ if (!children || children.length === 0) return true
1180
+
1181
+ return children.every(child => {
1182
+ if ($isLineBreakNode(child)) return true
1183
+ return $isBlankNode(child)
1184
+ })
1185
+ }
1186
+
1187
+ function $trimTrailingBlankNodes(parent) {
1188
+ for (const child of $lastToFirstIterator(parent)) {
1189
+ if ($isBlankNode(child)) {
1190
+ child.remove();
1191
+ } else {
1192
+ break
1019
1193
  }
1020
1194
  }
1021
1195
  }
1022
1196
 
1023
- var theme = {
1024
- text: {
1025
- bold: "lexxy-content__bold",
1026
- italic: "lexxy-content__italic",
1027
- strikethrough: "lexxy-content__strikethrough",
1028
- underline: "lexxy-content__underline",
1029
- highlight: "lexxy-content__highlight"
1030
- },
1031
- tableCellHeader: "lexxy-content__table-cell--header",
1032
- tableCellSelected: "lexxy-content__table-cell--selected",
1033
- tableSelection: "lexxy-content__table--selection",
1034
- tableScrollableWrapper: "lexxy-content__table-wrapper",
1035
- tableCellHighlight: "lexxy-content__table-cell--highlight",
1036
- tableCellFocus: "lexxy-content__table-cell--focus",
1037
- list: {
1038
- nested: {
1039
- listitem: "lexxy-nested-listitem",
1040
- }
1041
- },
1042
- codeHighlight: {
1043
- addition: "code-token__selector",
1044
- atrule: "code-token__attr",
1045
- attr: "code-token__attr",
1046
- "attr-name": "code-token__attr",
1047
- "attr-value": "code-token__selector",
1048
- boolean: "code-token__property",
1049
- bold: "code-token__variable",
1050
- builtin: "code-token__selector",
1051
- cdata: "code-token__comment",
1052
- char: "code-token__selector",
1053
- class: "code-token__function",
1054
- "class-name": "code-token__function",
1055
- color: "code-token__property",
1056
- comment: "code-token__comment",
1057
- constant: "code-token__property",
1058
- coord: "code-token__comment",
1059
- decorator: "code-token__function",
1060
- deleted: "code-token__operator",
1061
- deletion: "code-token__operator",
1062
- directive: "code-token__attr",
1063
- "directive-hash": "code-token__property",
1064
- doctype: "code-token__comment",
1065
- entity: "code-token__operator",
1066
- function: "code-token__function",
1067
- hexcode: "code-token__property",
1068
- important: "code-token__function",
1069
- inserted: "code-token__selector",
1070
- italic: "code-token__comment",
1071
- keyword: "code-token__attr",
1072
- line: "code-token__selector",
1073
- namespace: "code-token__variable",
1074
- number: "code-token__property",
1075
- macro: "code-token__function",
1076
- operator: "code-token__operator",
1077
- parameter: "code-token__variable",
1078
- prolog: "code-token__comment",
1079
- property: "code-token__property",
1080
- punctuation: "code-token__punctuation",
1081
- "raw-string": "code-token__operator",
1082
- regex: "code-token__variable",
1083
- script: "code-token__function",
1084
- selector: "code-token__selector",
1085
- string: "code-token__selector",
1086
- style: "code-token__function",
1087
- symbol: "code-token__property",
1088
- tag: "code-token__property",
1089
- title: "code-token__function",
1090
- "type-definition": "code-token__function",
1091
- url: "code-token__operator",
1092
- variable: "code-token__variable",
1093
- }
1094
- };
1095
-
1096
- function deepMerge(target, source) {
1097
- const result = { ...target, ...source };
1098
- for (const [ key, value ] of Object.entries(source)) {
1099
- if (arePlainHashes(target[key], value)) {
1100
- result[key] = deepMerge(target[key], value);
1197
+ // A list item is structurally empty if it contains no meaningful content.
1198
+ // Unlike getTextContent().trim() === "", this walks descendants to ensure
1199
+ // decorator nodes (mentions, attachments whose getTextContent() may return
1200
+ // invisible characters like \ufeff) are treated as non-empty content.
1201
+ function $isListItemStructurallyEmpty(listItem) {
1202
+ const children = listItem.getChildren();
1203
+ for (const child of children) {
1204
+ if ($isDecoratorNode(child)) return false
1205
+ if ($isLineBreakNode(child)) continue
1206
+ if ($isTextNode(child)) {
1207
+ if (child.getTextContent().trim() !== "") return false
1208
+ } else if ($isElementNode(child)) {
1209
+ if (child.getTextContent().trim() !== "") return false
1101
1210
  }
1102
1211
  }
1103
-
1104
- return result
1212
+ return true
1105
1213
  }
1106
1214
 
1107
- function arePlainHashes(...values) {
1108
- return values.every(value => value && value.constructor == Object)
1215
+ function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1216
+ return $isTextNode(node)
1217
+ && node.getTextContent() === " "
1218
+ && index === childCount - 1
1219
+ && previousNode instanceof CustomActionTextAttachmentNode
1109
1220
  }
1110
1221
 
1111
- class Configuration {
1112
- #tree = {}
1113
-
1114
- constructor(...configs) {
1115
- this.merge(...configs);
1116
- }
1117
-
1118
- merge(...configs) {
1119
- return this.#tree = configs.reduce(deepMerge, this.#tree)
1120
- }
1222
+ function $splitParagraphsAtLineBreakBoundaries(selection) {
1223
+ $ensureForwardRangeSelection(selection);
1121
1224
 
1122
- get(path) {
1123
- const keys = path.split(".");
1124
- return keys.reduce((node, key) => node[key], this.#tree)
1125
- }
1225
+ // Split focus first so the anchor split position stays valid.
1226
+ $splitAtNearestLineBreak(selection.focus, "next");
1227
+ $splitAtNearestLineBreak(selection.anchor, "previous");
1126
1228
  }
1127
1229
 
1128
- function range(from, to) {
1129
- return [ ...Array(1 + to - from).keys() ].map(i => i + from)
1130
- }
1230
+ function $splitAtNearestLineBreak(point, direction) {
1231
+ const paragraph = point.getNode().getTopLevelElement();
1232
+ if (!paragraph || !$isParagraphNode(paragraph)) return
1131
1233
 
1132
- const global = new Configuration({
1133
- attachmentTagName: "action-text-attachment",
1134
- attachmentContentTypeNamespace: "actiontext",
1135
- authenticatedUploads: false,
1136
- extensions: []
1137
- });
1234
+ const pointNode = point.getNode();
1235
+ const selectionChild = pointNode.getParent().is(paragraph) ? pointNode : pointNode.getParentOrThrow();
1236
+ const lineBreakCaret = $caretAtNearestNodeOfType(selectionChild, LineBreakNode, direction);
1237
+ if (!lineBreakCaret) return
1138
1238
 
1139
- const presets = new Configuration({
1140
- default: {
1141
- attachments: true,
1142
- markdown: true,
1143
- multiLine: true,
1144
- richText: true,
1145
- toolbar: {
1146
- upload: "both"
1147
- },
1148
- highlight: {
1149
- buttons: {
1150
- color: range(1, 9).map(n => `var(--highlight-${n})`),
1151
- "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
1152
- },
1153
- permit: {
1154
- color: [],
1155
- "background-color": []
1156
- }
1157
- }
1158
- }
1159
- });
1239
+ const lineBreak = lineBreakCaret.origin;
1240
+ const isEdge = lineBreakCaret.getNodeAtCaret() === null;
1160
1241
 
1161
- var Lexxy = {
1162
- global,
1163
- presets,
1164
- configure({ global: newGlobal, ...newPresets }) {
1165
- if (newGlobal) {
1166
- global.merge(newGlobal);
1167
- }
1168
- presets.merge(newPresets);
1242
+ if (!isEdge) {
1243
+ $splitNode(paragraph, lineBreak.getIndexWithinParent());
1169
1244
  }
1170
- };
1171
1245
 
1172
- function setSanitizerConfig(allowedTags) {
1173
- DOMPurify.clearConfig();
1174
- DOMPurify.setConfig(buildConfig(allowedTags));
1246
+ lineBreak.remove();
1175
1247
  }
1176
1248
 
1177
- function sanitize(html) {
1178
- return DOMPurify.sanitize(html)
1249
+ function $caretAtNearestNodeOfType(node, klass, direction) {
1250
+ for (const caret of $getSiblingCaret(node, direction)) {
1251
+ if (caret.origin instanceof klass) return caret
1252
+ }
1253
+ return null
1179
1254
  }
1180
1255
 
1181
- function bytesToHumanSize(bytes) {
1182
- if (bytes === 0) return "0 B"
1183
- const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
1184
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
1185
- const value = bytes / Math.pow(1024, i);
1186
- return `${ value.toFixed(2) } ${ sizes[i] }`
1187
- }
1256
+ // Payload: Record<nodeKey, { patch?, replace? }>
1257
+ // - patch: plain object, shallow-merged into the existing node's properties
1258
+ // - replace: a LexicalNode instance that replaces the node
1259
+ const REWRITE_HISTORY_COMMAND = createCommand("REWRITE_HISTORY_COMMAND");
1188
1260
 
1189
- function extractFileName(string) {
1190
- return string.split("/").pop()
1191
- }
1261
+ class RewritableHistoryExtension extends LexxyExtension {
1262
+ #historyState = null
1192
1263
 
1193
- // The content attribute is raw HTML (matching Trix/ActionText). Older Lexxy
1194
- // versions JSON-encoded it, so try JSON.parse first for backward compatibility.
1195
- function parseAttachmentContent(content) {
1196
- try {
1197
- return JSON.parse(content)
1198
- } catch {
1199
- return content
1264
+ get lexicalExtension() {
1265
+ return defineExtension({
1266
+ name: "lexxy/rewritable-history",
1267
+ dependencies: [ HistoryExtension ],
1268
+ register: (editor, _config, state) => {
1269
+ const historyOutput = state.getDependency(HistoryExtension).output;
1270
+ this.#historyState = historyOutput.historyState.value;
1271
+
1272
+ return editor.registerCommand(
1273
+ REWRITE_HISTORY_COMMAND,
1274
+ (rewrites) => this.#rewriteHistory(rewrites),
1275
+ COMMAND_PRIORITY_EDITOR
1276
+ )
1277
+ }
1278
+ })
1200
1279
  }
1201
- }
1202
1280
 
1203
- class CustomActionTextAttachmentNode extends DecoratorNode {
1204
- static getType() {
1205
- return "custom_action_text_attachment"
1281
+ get historyState() {
1282
+ return this.#historyState
1206
1283
  }
1207
1284
 
1208
- static clone(node) {
1209
- return new CustomActionTextAttachmentNode({ ...node }, node.__key)
1285
+ get #allHistoryEntries() {
1286
+ const entries = Array.from(this.#historyState.undoStack);
1287
+ if (this.#historyState.current) entries.push(this.#historyState.current);
1288
+ return entries.concat(this.#historyState.redoStack)
1210
1289
  }
1211
1290
 
1212
- static importJSON(serializedNode) {
1213
- return new CustomActionTextAttachmentNode({ ...serializedNode })
1291
+ #rewriteHistory(rewrites) {
1292
+ this.#applyRewritesImmediatelyToCurrentState(rewrites);
1293
+ this.#applyRewritesToHistory(rewrites);
1294
+
1295
+ return true
1214
1296
  }
1215
1297
 
1216
- static importDOM() {
1217
- return {
1218
- [this.TAG_NAME]: (element) => {
1219
- if (!element.getAttribute("content")) {
1220
- return null
1221
- }
1298
+ #applyRewritesImmediatelyToCurrentState(rewrites) {
1299
+ $getEditor().update(() => {
1300
+ for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
1301
+ const node = $getNodeByKey(nodeKey);
1302
+ if (!node) continue
1222
1303
 
1223
- return {
1224
- conversion: (attachment) => {
1225
- // Preserve initial space if present since Lexical removes it
1226
- const nodes = [];
1227
- const previousSibling = attachment.previousSibling;
1228
- if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
1229
- nodes.push($createTextNode(" "));
1230
- }
1304
+ if (patch) Object.assign(node.getWritable(), patch);
1305
+ if (replace) node.replace(replace);
1306
+ }
1307
+ }, { discrete: true, tag: this.#getBackgroundUpdateTags() });
1308
+ }
1231
1309
 
1232
- const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
1310
+ #applyRewritesToHistory(rewrites) {
1311
+ const nodeKeys = Object.keys(rewrites);
1233
1312
 
1234
- nodes.push(new CustomActionTextAttachmentNode({
1235
- sgid: attachment.getAttribute("sgid"),
1236
- innerHtml,
1237
- plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
1238
- contentType: attachment.getAttribute("content-type")
1239
- }));
1313
+ for (const entry of this.#allHistoryEntries) {
1314
+ if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue
1240
1315
 
1241
- const nextSibling = attachment.nextSibling;
1242
- if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && /^\s/.test(nextSibling.textContent)) {
1243
- nodes.push($createTextNode(" "));
1244
- }
1316
+ const editorState = entry.editorState = safeCloneEditorState(entry.editorState);
1245
1317
 
1246
- return { node: nodes }
1247
- },
1248
- priority: 2
1318
+ for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
1319
+ const node = editorState._nodeMap.get(nodeKey);
1320
+ if (!node) continue
1321
+
1322
+ if (patch) {
1323
+ this.#patchNodeInEditorState(editorState, node, patch);
1324
+ } else if (replace) {
1325
+ this.#replaceNodeInEditorState(editorState, node, replace);
1326
+ }
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ #entryHasSomeKeys(entry, nodeKeys) {
1332
+ return nodeKeys.some(key => entry.editorState._nodeMap.has(key))
1333
+ }
1334
+
1335
+ #getBackgroundUpdateTags() {
1336
+ const tags = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
1337
+ if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG); }
1338
+ return tags
1339
+ }
1340
+
1341
+ #patchNodeInEditorState(editorState, node, patch) {
1342
+ editorState._nodeMap.set(node.__key, $cloneNodeWithPatch(node, patch));
1343
+ }
1344
+
1345
+ #replaceNodeInEditorState(editorState, node, replaceWith) {
1346
+ editorState._nodeMap.set(node.__key, $cloneNodeAdoptingKeys(replaceWith, node));
1347
+ }
1348
+ }
1349
+
1350
+ function $cloneNodeWithPatch(node, patch) {
1351
+ const clone = $cloneWithProperties(node);
1352
+ Object.assign(clone, patch);
1353
+ return clone
1354
+ }
1355
+
1356
+ function $cloneNodeAdoptingKeys(node, previousNode) {
1357
+ const clone = $cloneWithProperties(node);
1358
+ clone.__key = previousNode.__key;
1359
+ clone.__parent = previousNode.__parent;
1360
+ clone.__prev = previousNode.__prev;
1361
+ clone.__next = previousNode.__next;
1362
+ return clone
1363
+ }
1364
+
1365
+ // EditorState#clone() keeps the same map reference.
1366
+ // A new Map is needed to prevent editing Lexical's internal map
1367
+ // Warning: this bypasses DEV's safety map freezing
1368
+ function safeCloneEditorState(editorState) {
1369
+ const clone = editorState.clone();
1370
+ clone._nodeMap = new Map(editorState._nodeMap);
1371
+ return clone
1372
+ }
1373
+
1374
+ class ActionTextAttachmentNode extends DecoratorNode {
1375
+ static getType() {
1376
+ return "action_text_attachment"
1377
+ }
1378
+
1379
+ static clone(node) {
1380
+ return new ActionTextAttachmentNode({ ...node }, node.__key)
1381
+ }
1382
+
1383
+ static importJSON(serializedNode) {
1384
+ return new ActionTextAttachmentNode({ ...serializedNode })
1385
+ }
1386
+
1387
+ static importDOM() {
1388
+ return {
1389
+ [this.TAG_NAME]: () => {
1390
+ return {
1391
+ conversion: (attachment) => ({
1392
+ node: new ActionTextAttachmentNode({
1393
+ sgid: attachment.getAttribute("sgid"),
1394
+ src: attachment.getAttribute("url"),
1395
+ previewable: attachment.getAttribute("previewable"),
1396
+ altText: attachment.getAttribute("alt"),
1397
+ caption: attachment.getAttribute("caption"),
1398
+ contentType: attachment.getAttribute("content-type"),
1399
+ fileName: attachment.getAttribute("filename"),
1400
+ fileSize: attachment.getAttribute("filesize"),
1401
+ width: attachment.getAttribute("width"),
1402
+ height: attachment.getAttribute("height")
1403
+ })
1404
+ }), priority: 1
1405
+ }
1406
+ },
1407
+ "img": () => {
1408
+ return {
1409
+ conversion: (img) => {
1410
+ const fileName = extractFileName(img.getAttribute("src") ?? "");
1411
+ return {
1412
+ node: new ActionTextAttachmentNode({
1413
+ src: img.getAttribute("src"),
1414
+ fileName: fileName,
1415
+ caption: img.getAttribute("alt") || "",
1416
+ contentType: "image/*",
1417
+ width: img.getAttribute("width"),
1418
+ height: img.getAttribute("height")
1419
+ })
1420
+ }
1421
+ }, priority: 1
1422
+ }
1423
+ },
1424
+ "video": () => {
1425
+ return {
1426
+ conversion: (video) => {
1427
+ const videoSource = video.getAttribute("src") || video.querySelector("source")?.src;
1428
+ const fileName = videoSource?.split("/")?.pop();
1429
+ const contentType = video.querySelector("source")?.getAttribute("content-type") || "video/*";
1430
+
1431
+ return {
1432
+ node: new ActionTextAttachmentNode({
1433
+ src: videoSource,
1434
+ fileName: fileName,
1435
+ contentType: contentType
1436
+ })
1437
+ }
1438
+ }, priority: 1
1249
1439
  }
1250
1440
  }
1251
1441
  }
@@ -1255,50 +1445,79 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1255
1445
  return Lexxy.global.get("attachmentTagName")
1256
1446
  }
1257
1447
 
1258
- constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
1448
+ constructor({ tagName, sgid, src, previewSrc, previewable, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
1259
1449
  super(key);
1260
1450
 
1261
- const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
1262
-
1263
- this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
1451
+ this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
1264
1452
  this.sgid = sgid;
1265
- this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
1266
- this.innerHtml = innerHtml;
1267
- this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
1453
+ this.src = src;
1454
+ this.previewSrc = previewSrc;
1455
+ this.previewable = parseBoolean(previewable);
1456
+ this.pendingPreview = pendingPreview;
1457
+ this.altText = altText || "";
1458
+ this.caption = caption || "";
1459
+ this.contentType = contentType || "";
1460
+ this.fileName = fileName || "";
1461
+ this.fileSize = fileSize;
1462
+ this.width = width;
1463
+ this.height = height;
1464
+ this.uploadError = uploadError;
1465
+
1466
+ this.editor = $getEditor();
1268
1467
  }
1269
1468
 
1270
1469
  createDOM() {
1271
- const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
1470
+ if (this.uploadError) return this.createDOMForError()
1471
+ if (this.pendingPreview) return this.#createDOMForPendingPreview()
1272
1472
 
1273
- figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
1473
+ const figure = this.createAttachmentFigure();
1274
1474
 
1275
- const deleteButton = createElement("lexxy-node-delete-button");
1276
- figure.appendChild(deleteButton);
1475
+ if (this.isPreviewableAttachment) {
1476
+ figure.appendChild(this.#createDOMForImage());
1477
+ figure.appendChild(this.#createEditableCaption());
1478
+ } else if (this.isVideo) {
1479
+ figure.appendChild(this.#createDOMForFile());
1480
+ figure.appendChild(this.#createEditableCaption());
1481
+ } else {
1482
+ figure.appendChild(this.#createDOMForFile());
1483
+ figure.appendChild(this.#createDOMForNotImage());
1484
+ }
1277
1485
 
1278
1486
  return figure
1279
1487
  }
1280
1488
 
1281
- updateDOM() {
1489
+ updateDOM(prevNode, dom) {
1490
+ if (this.uploadError !== prevNode.uploadError) return true
1491
+
1492
+ const caption = dom.querySelector("figcaption textarea");
1493
+ if (caption && this.caption) {
1494
+ caption.value = this.caption;
1495
+ }
1496
+
1282
1497
  return false
1283
1498
  }
1284
1499
 
1285
1500
  getTextContent() {
1286
- return "\ufeff"
1287
- }
1288
-
1289
- getReadableTextContent() {
1290
- return this.plainText || `[${this.contentType}]`
1501
+ return `[${this.caption || this.fileName}]\n\n`
1291
1502
  }
1292
1503
 
1293
1504
  isInline() {
1294
- return true
1505
+ return this.isAttached() && !this.getParent().is($getNearestRootOrShadowRoot(this))
1295
1506
  }
1296
1507
 
1297
1508
  exportDOM() {
1298
1509
  const attachment = createElement(this.tagName, {
1299
1510
  sgid: this.sgid,
1300
- content: this.innerHtml,
1301
- "content-type": this.contentType
1511
+ previewable: this.previewable || null,
1512
+ url: this.src,
1513
+ alt: this.altText,
1514
+ caption: this.caption,
1515
+ "content-type": this.contentType,
1516
+ filename: this.fileName,
1517
+ filesize: this.fileSize,
1518
+ width: this.width,
1519
+ height: this.height,
1520
+ presentation: "gallery"
1302
1521
  });
1303
1522
 
1304
1523
  return { element: attachment }
@@ -1306,1894 +1525,1699 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1306
1525
 
1307
1526
  exportJSON() {
1308
1527
  return {
1309
- type: "custom_action_text_attachment",
1528
+ type: "action_text_attachment",
1310
1529
  version: 1,
1311
1530
  tagName: this.tagName,
1312
1531
  sgid: this.sgid,
1532
+ src: this.src,
1533
+ previewable: this.previewable,
1534
+ altText: this.altText,
1535
+ caption: this.caption,
1313
1536
  contentType: this.contentType,
1314
- innerHtml: this.innerHtml,
1315
- plainText: this.plainText
1537
+ fileName: this.fileName,
1538
+ fileSize: this.fileSize,
1539
+ width: this.width,
1540
+ height: this.height
1316
1541
  }
1317
1542
  }
1318
1543
 
1319
1544
  decorate() {
1320
1545
  return null
1321
1546
  }
1322
- }
1323
-
1324
- function $createNodeSelectionWith(...nodes) {
1325
- const selection = $createNodeSelection();
1326
- nodes.forEach(node => selection.add(node.getKey()));
1327
- return selection
1328
- }
1329
-
1330
- function $isShadowRoot(node) {
1331
- return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
1332
- }
1333
1547
 
1334
- function $makeSafeForRoot(node) {
1335
- if ($isTextNode(node)) {
1336
- return $wrapNodeInElement(node, $createParagraphNode)
1337
- } else if (node.isParentRequired()) {
1338
- const parent = node.createRequiredParent();
1339
- return $wrapNodeInElement(node, parent)
1340
- } else {
1341
- return node
1548
+ createDOMForError() {
1549
+ const figure = this.createAttachmentFigure();
1550
+ figure.classList.add("attachment--error");
1551
+ figure.appendChild(createElement("div", { innerText: `Error uploading ${this.fileName || "file"}` }));
1552
+ return figure
1342
1553
  }
1343
- }
1344
1554
 
1345
- function getListType(node) {
1346
- const list = $getNearestNodeOfType(node, ListNode);
1347
- return list?.getListType() ?? null
1348
- }
1555
+ createAttachmentFigure(previewable = this.isPreviewableAttachment) {
1556
+ const figure = createAttachmentFigure(this.contentType, previewable, this.fileName);
1557
+ figure.draggable = true;
1558
+ figure.dataset.lexicalNodeKey = this.__key;
1349
1559
 
1350
- function isEditorFocused(editor) {
1351
- const rootElement = editor.getRootElement();
1352
- return rootElement !== null && rootElement.contains(document.activeElement)
1353
- }
1560
+ const deleteButton = createElement("lexxy-node-delete-button");
1561
+ figure.appendChild(deleteButton);
1354
1562
 
1355
- function $isAtNodeEdge(point, atStart = null) {
1356
- if (atStart === null) {
1357
- return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
1358
- } else {
1359
- return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
1563
+ return figure
1360
1564
  }
1361
- }
1362
1565
 
1363
- function $isAtNodeStart(point) {
1364
- return point.offset === 0
1365
- }
1366
-
1367
- function extendTextNodeConversion(conversionName, ...callbacks) {
1368
- return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
1369
- ...conversionOutput,
1370
- forChild: (lexicalNode, parentNode) => {
1371
- const originalForChild = conversionOutput?.forChild ?? (x => x);
1372
- let childNode = originalForChild(lexicalNode, parentNode);
1566
+ get isPreviewableAttachment() {
1567
+ return this.isPreviewableImage || this.previewable
1568
+ }
1373
1569
 
1570
+ get isPreviewableImage() {
1571
+ return isPreviewableImage(this.contentType)
1572
+ }
1374
1573
 
1375
- if ($isTextNode(childNode)) {
1376
- childNode = callbacks.reduce(
1377
- (childNode, callback) => callback(childNode, element) ?? childNode,
1378
- childNode
1379
- );
1380
- return childNode
1381
- }
1382
- }
1383
- }))
1384
- }
1385
-
1386
- function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
1387
- return (element) => {
1388
- const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
1389
- if (!converter) return null
1574
+ get isVideo() {
1575
+ return this.contentType.startsWith("video/")
1576
+ }
1390
1577
 
1391
- const conversionOutput = converter.conversion(element);
1392
- if (!conversionOutput) return conversionOutput
1578
+ #createDOMForPendingPreview() {
1579
+ const figure = this.createAttachmentFigure(false);
1580
+ figure.appendChild(this.#createDOMForFile());
1581
+ figure.appendChild(this.#createDOMForNotImage());
1582
+ this.#pollForPreview(figure);
1583
+ return figure
1584
+ }
1393
1585
 
1394
- return callback(conversionOutput, element) ?? conversionOutput
1586
+ patchAndRewriteHistory(patch) {
1587
+ this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
1588
+ [this.getKey()]: { patch }
1589
+ });
1395
1590
  }
1396
- }
1397
1591
 
1398
- function $isCursorOnLastLine(selection) {
1399
- const anchorNode = selection.anchor.getNode();
1400
- const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
1401
- const children = elementNode.getChildren();
1402
- if (children.length === 0) return true
1592
+ replaceAndRewriteHistory(node) {
1593
+ this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
1594
+ [this.getKey()]: { replace: node }
1595
+ });
1596
+ }
1403
1597
 
1404
- const lastChild = children[children.length - 1];
1598
+ #createDOMForImage(options = {}) {
1599
+ const initialSrc = this.previewSrc || this.src;
1600
+ const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
1405
1601
 
1406
- if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
1407
- if (anchorNode === lastChild) return true
1602
+ if (this.previewable && !this.isPreviewableImage) {
1603
+ img.onerror = () => this.#swapPreviewToFileDOM(img);
1604
+ }
1408
1605
 
1409
- const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
1410
- if (lastLineBreakIndex === -1) return true
1606
+ if (this.previewSrc) {
1607
+ this.#preloadAndSwapSrc(img);
1608
+ }
1411
1609
 
1412
- const anchorIndex = children.indexOf(anchorNode);
1413
- return anchorIndex > lastLineBreakIndex
1414
- }
1610
+ const container = createElement("div", { className: "attachment__container" });
1611
+ container.appendChild(img);
1612
+ return container
1613
+ }
1415
1614
 
1416
- function $isBlankNode(node) {
1417
- if (node.getTextContent().trim() !== "") return false
1615
+ #preloadAndSwapSrc(img) {
1616
+ const previewSrc = this.previewSrc;
1617
+ const serverImage = new Image();
1418
1618
 
1419
- const children = node.getChildren?.();
1420
- if (!children || children.length === 0) return true
1619
+ serverImage.onload = () => this.#handleImageLoaded(img, previewSrc);
1620
+ serverImage.onerror = () => this.#handleImageLoadError(previewSrc);
1621
+ serverImage.src = this.src;
1622
+ }
1421
1623
 
1422
- return children.every(child => {
1423
- if ($isLineBreakNode(child)) return true
1424
- return $isBlankNode(child)
1425
- })
1426
- }
1624
+ #handleImageLoaded(img, previewSrc) {
1625
+ img.src = this.src;
1626
+ this.patchAndRewriteHistory({ previewSrc: null });
1627
+ this.#revokePreviewSrc(previewSrc);
1628
+ }
1427
1629
 
1428
- function $trimTrailingBlankNodes(parent) {
1429
- for (const child of $lastToFirstIterator(parent)) {
1430
- if ($isBlankNode(child)) {
1431
- child.remove();
1432
- } else {
1433
- break
1434
- }
1630
+ #handleImageLoadError(previewSrc) {
1631
+ this.patchAndRewriteHistory({
1632
+ previewSrc: null,
1633
+ uploadError: true
1634
+ });
1635
+ this.#revokePreviewSrc(previewSrc);
1435
1636
  }
1436
- }
1437
1637
 
1438
- // A list item is structurally empty if it contains no meaningful content.
1439
- // Unlike getTextContent().trim() === "", this walks descendants to ensure
1440
- // decorator nodes (mentions, attachments whose getTextContent() may return
1441
- // invisible characters like \ufeff) are treated as non-empty content.
1442
- function $isListItemStructurallyEmpty(listItem) {
1443
- const children = listItem.getChildren();
1444
- for (const child of children) {
1445
- if ($isDecoratorNode(child)) return false
1446
- if ($isLineBreakNode(child)) continue
1447
- if ($isTextNode(child)) {
1448
- if (child.getTextContent().trim() !== "") return false
1449
- } else if ($isElementNode(child)) {
1450
- if (child.getTextContent().trim() !== "") return false
1451
- }
1638
+ #revokePreviewSrc(previewSrc) {
1639
+ if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc);
1452
1640
  }
1453
- return true
1454
- }
1455
1641
 
1456
- function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1457
- return $isTextNode(node)
1458
- && node.getTextContent() === " "
1459
- && index === childCount - 1
1460
- && previousNode instanceof CustomActionTextAttachmentNode
1461
- }
1642
+ #swapPreviewToFileDOM(img) {
1643
+ const figure = img.closest("figure.attachment");
1644
+ if (!figure) return
1462
1645
 
1463
- function $splitParagraphsAtLineBreakBoundaries(selection) {
1464
- $ensureForwardRangeSelection(selection);
1646
+ this.#swapFigureContent(figure, "attachment--preview", "attachment--file", () => {
1647
+ figure.appendChild(this.#createDOMForFile());
1648
+ figure.appendChild(this.#createDOMForNotImage());
1649
+ });
1650
+ }
1465
1651
 
1466
- // Split focus first so the anchor split position stays valid.
1467
- $splitAtNearestLineBreak(selection.focus, "next");
1468
- $splitAtNearestLineBreak(selection.anchor, "previous");
1469
- }
1652
+ #pollForPreview(figure) {
1653
+ let attempt = 0;
1654
+ const maxAttempts = 10;
1470
1655
 
1471
- function $splitAtNearestLineBreak(point, direction) {
1472
- const paragraph = point.getNode().getTopLevelElement();
1473
- if (!paragraph || !$isParagraphNode(paragraph)) return
1656
+ const tryLoad = () => {
1657
+ if (!this.editor.read(() => this.isAttached())) return
1474
1658
 
1475
- const pointNode = point.getNode();
1476
- const selectionChild = pointNode.getParent().is(paragraph) ? pointNode : pointNode.getParentOrThrow();
1477
- const lineBreakCaret = $caretAtNearestNodeOfType(selectionChild, LineBreakNode, direction);
1478
- if (!lineBreakCaret) return
1659
+ const img = new Image();
1660
+ const cacheBustedSrc = `${this.src}${this.src.includes("?") ? "&" : "?"}_=${Date.now()}`;
1479
1661
 
1480
- const lineBreak = lineBreakCaret.origin;
1481
- const isEdge = lineBreakCaret.getNodeAtCaret() === null;
1662
+ img.onload = () => {
1663
+ if (!this.editor.read(() => this.isAttached())) return
1482
1664
 
1483
- if (!isEdge) {
1484
- $splitNode(paragraph, lineBreak.getIndexWithinParent());
1485
- }
1665
+ // The placeholder is a file-type icon SVG (86×100). A real thumbnail
1666
+ // generated from PDF/video content is significantly larger.
1667
+ if (img.naturalWidth > 150 && img.naturalHeight > 150) {
1668
+ this.#swapToPreviewDOM(figure, cacheBustedSrc);
1669
+ } else {
1670
+ retry();
1671
+ }
1672
+ };
1673
+ img.onerror = () => retry();
1674
+ img.src = cacheBustedSrc;
1675
+ };
1486
1676
 
1487
- lineBreak.remove();
1488
- }
1677
+ const retry = () => {
1678
+ attempt++;
1679
+ if (attempt < maxAttempts && this.editor.read(() => this.isAttached())) {
1680
+ const delay = Math.min(2000 * Math.pow(1.5, attempt), 15000);
1681
+ setTimeout(tryLoad, delay);
1682
+ }
1683
+ };
1489
1684
 
1490
- function $caretAtNearestNodeOfType(node, klass, direction) {
1491
- for (const caret of $getSiblingCaret(node, direction)) {
1492
- if (caret.origin instanceof klass) return caret
1685
+ // Give the server time to start processing before the first attempt
1686
+ setTimeout(tryLoad, 3000);
1493
1687
  }
1494
- return null
1495
- }
1496
-
1497
- // Shared, strictly-contained element used to attach ephemeral nodes when we
1498
- // need to read computed styles (e.g. canonicalizing style values, resolving
1499
- // CSS custom properties). The container is created once and attached to
1500
- // `document.body` once; subsequent child mutations happen *inside* the
1501
- // contained subtree so they do not invalidate style on the rest of the page.
1502
- //
1503
- // Without this, `document.body.appendChild(...)` / `element.remove()` calls
1504
- // forced the browser to re-evaluate every ancestor-dependent selector (`:has()`,
1505
- // descendant combinators, universal sibling rules) across the document on each
1506
- // invocation — a 13,000+ element style recalc per call on a typical Basecamp
1507
- // page.
1508
1688
 
1509
- let resolverRoot = null;
1689
+ #swapToPreviewDOM(figure, previewSrc) {
1690
+ this.#swapFigureContent(figure, "attachment--file", "attachment--preview", () => {
1691
+ const img = createElement("img", { src: previewSrc, draggable: false, alt: this.altText });
1692
+ img.onerror = () => this.#swapPreviewToFileDOM(img);
1693
+ const container = createElement("div", { className: "attachment__container" });
1694
+ container.appendChild(img);
1695
+ figure.appendChild(container);
1696
+ figure.appendChild(this.#createEditableCaption());
1697
+ });
1510
1698
 
1511
- function styleResolverRoot() {
1512
- if (resolverRoot && resolverRoot.isConnected) return resolverRoot
1699
+ this.patchAndRewriteHistory({ pendingPreview: false });
1700
+ }
1513
1701
 
1514
- resolverRoot = document.createElement("div");
1515
- resolverRoot.setAttribute("aria-hidden", "true");
1516
- resolverRoot.setAttribute("data-lexxy-style-resolver", "");
1517
- // `contain: strict` (size, layout, paint, style) isolates everything.
1518
- // The root itself paints nothing (visibility hidden), has zero
1519
- // geometric impact (position fixed, intrinsic size via contain), and
1520
- // never leaks style invalidation to its ancestors.
1521
- resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;";
1522
- document.body.appendChild(resolverRoot);
1523
- return resolverRoot
1524
- }
1702
+ #swapFigureContent(figure, fromClass, toClass, renderContent) {
1703
+ figure.className = figure.className.replace(fromClass, toClass);
1525
1704
 
1526
- function isSelectionHighlighted(selection) {
1527
- if (!$isRangeSelection(selection)) return false
1705
+ for (const child of [ ...figure.querySelectorAll(".attachment__container, .attachment__icon, figcaption") ]) {
1706
+ child.remove();
1707
+ }
1528
1708
 
1529
- if (selection.isCollapsed()) {
1530
- return hasHighlightStyles(selection.style)
1531
- } else {
1532
- return selection.hasFormat("highlight")
1709
+ renderContent();
1533
1710
  }
1534
- }
1535
-
1536
- function getHighlightStyles(selection) {
1537
- if (!$isRangeSelection(selection)) return null
1538
1711
 
1539
- let styles = getStyleObjectFromCSS(selection.style);
1540
- if (!styles.color && !styles["background-color"]) {
1541
- const anchorNode = selection.anchor.getNode();
1542
- if ($isTextNode(anchorNode)) {
1543
- styles = getStyleObjectFromCSS(anchorNode.getStyle());
1712
+ get #imageDimensions() {
1713
+ if (this.width && this.height) {
1714
+ return { width: this.width, height: this.height }
1715
+ } else {
1716
+ return {}
1544
1717
  }
1545
1718
  }
1546
1719
 
1547
- const color = styles.color || null;
1548
- const backgroundColor = styles["background-color"] || null;
1549
- if (!color && !backgroundColor) return null
1720
+ #createDOMForFile() {
1721
+ const extension = this.fileName ? this.fileName.split(".").pop().toLowerCase() : "unknown";
1722
+ return createElement("span", { className: "attachment__icon", textContent: `${extension}` })
1723
+ }
1550
1724
 
1551
- return { color, backgroundColor }
1552
- }
1553
-
1554
- function hasHighlightStyles(cssOrStyles) {
1555
- const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
1556
- return !!(styles.color || styles["background-color"])
1557
- }
1558
-
1559
- function applyCanonicalizers(styles, canonicalizers = []) {
1560
- return canonicalizers.reduce((css, canonicalizer) => {
1561
- return canonicalizer.applyCanonicalization(css)
1562
- }, styles)
1563
- }
1725
+ #createDOMForNotImage() {
1726
+ const figcaption = createElement("figcaption", { className: "attachment__caption" });
1564
1727
 
1565
- class StyleCanonicalizer {
1566
- constructor(property, allowedValues= []) {
1567
- this._property = property;
1568
- this._allowedValues = allowedValues;
1569
- this._canonicalValues = this.#allowedValuesIdentityObject;
1570
- }
1728
+ const nameTag = createElement("strong", { className: "attachment__name", textContent: this.caption || this.fileName });
1571
1729
 
1572
- applyCanonicalization(css) {
1573
- const styles = { ...getStyleObjectFromCSS(css) };
1730
+ figcaption.appendChild(nameTag);
1574
1731
 
1575
- styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
1576
- if (!styles[this._property]) {
1577
- delete styles[this._property];
1732
+ if (this.fileSize) {
1733
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.fileSize) });
1734
+ figcaption.appendChild(sizeSpan);
1578
1735
  }
1579
1736
 
1580
- return getCSSFromStyleObject(styles)
1737
+ return figcaption
1581
1738
  }
1582
1739
 
1583
- getCanonicalAllowedValue(value) {
1584
- return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
1585
- }
1740
+ #createEditableCaption() {
1741
+ const caption = createElement("figcaption", { className: "attachment__caption" });
1742
+ const input = createElement("textarea", {
1743
+ value: this.caption,
1744
+ placeholder: this.fileName,
1745
+ rows: "1"
1746
+ });
1586
1747
 
1587
- // Private
1748
+ input.addEventListener("focusin", () => input.placeholder = "Add caption...");
1749
+ input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
1750
+ input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
1751
+ input.addEventListener("copy", (event) => event.stopPropagation());
1752
+ input.addEventListener("cut", (event) => event.stopPropagation());
1753
+ input.addEventListener("paste", (event) => event.stopPropagation());
1588
1754
 
1589
- get #allowedValuesIdentityObject() {
1590
- return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
1755
+ caption.appendChild(input);
1756
+
1757
+ return caption
1591
1758
  }
1592
1759
 
1593
- #resolveCannonicalValue(value) {
1594
- let index = this.#computedAllowedValues.indexOf(value);
1595
- if (index === -1) {
1596
- index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
1597
- }
1598
- return index === -1 ? null : this._allowedValues[index]
1760
+ #handleCaptionInputBlurred(event) {
1761
+ this.#updateCaptionValueFromInput(event.target);
1599
1762
  }
1600
1763
 
1601
- get #computedAllowedValues() {
1602
- return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
1764
+ #updateCaptionValueFromInput(input) {
1765
+ input.placeholder = this.fileName;
1766
+ this.editor.update(() => {
1767
+ this.getWritable().caption = input.value;
1768
+ });
1603
1769
  }
1604
- }
1605
1770
 
1606
- // Separates DOM writes from layout reads to avoid forced reflows, and attaches
1607
- // resolver elements to a strictly-contained root (outside the normal document
1608
- // flow) so neither the attach nor the detach invalidate styles on the rest of
1609
- // the page. Without containment, appending to `document.body` triggered a
1610
- // page-wide style recalc on every canonicalization pass.
1611
- function computeStyleValues(property, values) {
1612
- const fragment = document.createDocumentFragment();
1771
+ #handleCaptionInputKeydown(event) {
1772
+ if (event.key === "Enter") {
1773
+ event.preventDefault();
1774
+ event.target.blur();
1613
1775
 
1614
- const elements = values.map(value => {
1615
- const element = createElement("span", { style: `display: none; ${property}: ${value};` });
1616
- fragment.appendChild(element);
1617
- return element
1618
- });
1776
+ this.editor.update(() => {
1777
+ // Place the cursor after the current image
1778
+ this.selectNext(0, 0);
1779
+ }, {
1780
+ tag: HISTORY_MERGE_TAG
1781
+ });
1782
+ }
1619
1783
 
1620
- styleResolverRoot().appendChild(fragment);
1784
+ // Stop all keydown events from bubbling to the Lexical root element.
1785
+ // The caption textarea is outside Lexical's content model and should
1786
+ // handle its own keyboard events natively (Ctrl+A, Ctrl+C, Ctrl+X, etc.).
1787
+ event.stopPropagation();
1788
+ }
1789
+ }
1621
1790
 
1622
- const computed = elements.map(element =>
1623
- window.getComputedStyle(element).getPropertyValue(property)
1624
- );
1791
+ function $createActionTextAttachmentNode(...args) {
1792
+ return new ActionTextAttachmentNode(...args)
1793
+ }
1625
1794
 
1626
- elements.forEach(element => element.remove());
1627
- return computed
1795
+ function $isActionTextAttachmentNode(node) {
1796
+ return node instanceof ActionTextAttachmentNode
1628
1797
  }
1629
1798
 
1630
- class LexxyExtension {
1631
- #editorElement
1799
+ function $generateFilteredNodesFromDOM(editorElement, doc) {
1800
+ const nodes = $generateNodesFromDOM(editorElement.editor, doc);
1801
+ return filterDisallowedAttachmentNodes(nodes, editorElement)
1802
+ }
1632
1803
 
1633
- constructor(editorElement) {
1634
- this.#editorElement = editorElement;
1635
- }
1804
+ function filterDisallowedAttachmentNodes(nodes, editorElement) {
1805
+ return nodes
1806
+ .filter(node => !isDisallowedAttachment(node, editorElement))
1807
+ .map(node => {
1808
+ $descendantsMatching([ node ], descendant => isDisallowedAttachment(descendant, editorElement))
1809
+ .forEach(descendant => descendant.remove());
1810
+ return node
1811
+ })
1812
+ }
1636
1813
 
1637
- get editorElement() {
1638
- return this.#editorElement
1639
- }
1814
+ function isDisallowedAttachment(node, editorElement) {
1815
+ const isAttachmentNode =
1816
+ node instanceof CustomActionTextAttachmentNode ||
1817
+ node instanceof ActionTextAttachmentNode;
1818
+ return isAttachmentNode &&
1819
+ !editorElement.permitsAttachmentContentType(node.contentType)
1820
+ }
1640
1821
 
1641
- get editorConfig() {
1642
- return this.#editorElement.config
1822
+ class HorizontalDividerNode extends DecoratorNode {
1823
+ static getType() {
1824
+ return "horizontal_divider"
1643
1825
  }
1644
1826
 
1645
- // optional: defaults to true
1646
- get enabled() {
1647
- return true
1827
+ static clone(node) {
1828
+ return new HorizontalDividerNode(node.__key)
1648
1829
  }
1649
1830
 
1650
- get lexicalExtension() {
1651
- return null
1831
+ static importJSON(serializedNode) {
1832
+ return new HorizontalDividerNode()
1652
1833
  }
1653
1834
 
1654
- get allowedElements() {
1655
- return []
1835
+ static importDOM() {
1836
+ return {
1837
+ "hr": (hr) => {
1838
+ return {
1839
+ conversion: () => ({
1840
+ node: new HorizontalDividerNode()
1841
+ }),
1842
+ priority: 1
1843
+ }
1844
+ }
1845
+ }
1656
1846
  }
1657
1847
 
1658
- initializeToolbar(_lexxyToolbar) {
1659
-
1848
+ constructor(key) {
1849
+ super(key);
1660
1850
  }
1661
- }
1662
1851
 
1663
- const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
1664
- const REMOVE_HIGHLIGHT_COMMAND = createCommand();
1665
- const BLANK_STYLES = { "color": null, "background-color": null };
1852
+ createDOM() {
1853
+ const figure = createElement("figure", { className: "horizontal-divider" });
1854
+ const hr = createElement("hr");
1666
1855
 
1667
- const hasPastedStylesState = createState("hasPastedStyles", {
1668
- parse: (value) => value || false
1669
- });
1856
+ figure.appendChild(hr);
1670
1857
 
1671
- // Stores pending highlight ranges extracted during HTML import, keyed by CodeNode key.
1672
- // After the code retokenizer creates fresh CodeHighlightNodes, a mutation listener
1673
- // reads this map and re-applies the highlight styles. Scoped per editor instance
1674
- // so entries don't leak across editors or outlive a torn-down editor.
1675
- const pendingCodeHighlights = new WeakMap();
1858
+ const deleteButton = createElement("lexxy-node-delete-button");
1859
+ figure.appendChild(deleteButton);
1676
1860
 
1677
- class HighlightExtension extends LexxyExtension {
1678
- get enabled() {
1679
- return this.editorElement.supportsRichText
1861
+ return figure
1680
1862
  }
1681
1863
 
1682
- get lexicalExtension() {
1683
- const extension = defineExtension({
1684
- dependencies: [ RichTextExtension ],
1685
- name: "lexxy/highlight",
1686
- config: {
1687
- color: { buttons: [], permit: [] },
1688
- "background-color": { buttons: [], permit: [] }
1689
- },
1690
- html: {
1691
- import: {
1692
- mark: $markConversion
1693
- }
1694
- },
1695
- register(editor, config) {
1696
- // keep the ref to the canonicalizers for optimized css conversion
1697
- const canonicalizers = buildCanonicalizers(config);
1698
-
1699
- // Register the <pre> converter directly in the conversion cache so it
1700
- // coexists with other extensions' "pre" converters (the extension-level
1701
- // html.import uses Object.assign, which means only one "pre" per key).
1702
- $registerPreConversion(editor);
1703
-
1704
- return mergeRegister(
1705
- editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, (styles) => $toggleSelectionStyles(editor, styles), COMMAND_PRIORITY_NORMAL),
1706
- editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(editor, BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
1707
- editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
1708
- editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode),
1709
- editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers)),
1710
- editor.registerMutationListener(CodeNode, (mutations) => {
1711
- $applyPendingCodeHighlights(editor, mutations);
1712
- }, { skipInitialization: true })
1713
- )
1714
- }
1715
- });
1864
+ updateDOM() {
1865
+ return true
1866
+ }
1716
1867
 
1717
- return [ extension, this.editorConfig.get("highlight") ]
1868
+ getTextContent() {
1869
+ return "┄\n\n"
1718
1870
  }
1719
- }
1720
1871
 
1721
- function $applyHighlightStyle(textNode, element) {
1722
- const elementStyles = {
1723
- color: element.style?.color,
1724
- "background-color": element.style?.backgroundColor
1725
- };
1872
+ isInline() {
1873
+ return false
1874
+ }
1726
1875
 
1727
- if ($hasUpdateTag(PASTE_TAG)) { $setPastedStyles(textNode); }
1728
- const highlightStyle = getCSSFromStyleObject(elementStyles);
1876
+ exportDOM() {
1877
+ const hr = createElement("hr");
1878
+ return { element: hr }
1879
+ }
1729
1880
 
1730
- if (highlightStyle.length) {
1731
- return textNode.setStyle(textNode.getStyle() + highlightStyle)
1881
+ exportJSON() {
1882
+ return {
1883
+ type: "horizontal_divider",
1884
+ version: 1
1885
+ }
1732
1886
  }
1733
- }
1734
1887
 
1735
- function $markConversion() {
1736
- return {
1737
- conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
1738
- priority: 1
1888
+ decorate() {
1889
+ return null
1739
1890
  }
1740
1891
  }
1741
1892
 
1742
- // Register a custom <pre> converter directly in the editor's HTML conversion
1743
- // cache. We can't use the extension-level html.import because Object.assign
1744
- // merges all extensions' converters by tag, and a later extension (e.g.
1745
- // TrixContentExtension) would overwrite ours.
1746
- function $registerPreConversion(editor) {
1747
- if (!editor._htmlConversions) return
1748
-
1749
- let preEntries = editor._htmlConversions.get("pre");
1750
- if (!preEntries) {
1751
- preEntries = [];
1752
- editor._htmlConversions.set("pre", preEntries);
1753
- }
1754
- preEntries.push($preConversionWithHighlightsFactory(editor));
1755
- }
1756
-
1757
- // Returns a <pre> converter factory scoped to a specific editor instance.
1758
- // The factory extracts highlight ranges from <mark> elements before the code
1759
- // retokenizer can destroy them. The ranges are stored in pendingCodeHighlights
1760
- // and applied after retokenization via a mutation listener.
1761
- function $preConversionWithHighlightsFactory(editor) {
1762
- return function $preConversionWithHighlights(domNode) {
1763
- const highlights = extractHighlightRanges(domNode);
1764
- if (highlights.length === 0) return null
1765
-
1766
- return {
1767
- conversion: (domNode) => {
1768
- const language = domNode.getAttribute("data-language");
1769
- const codeNode = $createCodeNode(language);
1770
- $getPendingHighlights(editor).set(codeNode.getKey(), highlights);
1771
- return { node: codeNode }
1772
- },
1773
- priority: 2
1774
- }
1775
- }
1776
- }
1777
-
1778
- // Walk the DOM tree inside a <pre> element and build a list of
1779
- // { start, end, style } ranges for every <mark> element found.
1780
- function extractHighlightRanges(preElement) {
1781
- const ranges = [];
1782
- const codeElement = preElement.querySelector("code") || preElement;
1783
-
1784
- let offset = 0;
1785
-
1786
- function walk(node) {
1787
- if (node.nodeType === Node.TEXT_NODE) {
1788
- offset += node.textContent.length;
1789
- } else if (node.nodeType === Node.ELEMENT_NODE) {
1790
- // <br> maps to a LineBreakNode (1 character) in Lexical
1791
- if (node.tagName === "BR") {
1792
- offset += 1;
1793
- return
1794
- }
1795
-
1796
- const isMark = node.tagName === "MARK";
1797
- const start = offset;
1798
-
1799
- for (const child of node.childNodes) {
1800
- walk(child);
1801
- }
1802
-
1803
- if (isMark) {
1804
- const style = extractHighlightStyleFromElement(node);
1805
- if (style) {
1806
- ranges.push({ start, end: offset, style });
1807
- }
1808
- }
1809
- }
1810
- }
1811
-
1812
- for (const child of codeElement.childNodes) {
1813
- walk(child);
1814
- }
1815
-
1816
- return ranges
1817
- }
1818
-
1819
- function $getPendingHighlights(editor) {
1820
- let map = pendingCodeHighlights.get(editor);
1821
- if (!map) {
1822
- map = new Map();
1823
- pendingCodeHighlights.set(editor, map);
1824
- }
1825
- return map
1826
- }
1827
-
1828
- function extractHighlightStyleFromElement(element) {
1829
- const styles = {};
1830
- if (element.style?.color) styles.color = element.style.color;
1831
- if (element.style?.backgroundColor) styles["background-color"] = element.style.backgroundColor;
1832
- const css = getCSSFromStyleObject(styles);
1833
- return css.length > 0 ? css : null
1834
- }
1835
-
1836
- // Called from the CodeNode mutation listener after the retokenizer has
1837
- // replaced TextNodes with fresh CodeHighlightNodes.
1838
- function $applyPendingCodeHighlights(editor, mutations) {
1839
- const pending = $getPendingHighlights(editor);
1840
- const keysToProcess = [];
1893
+ const HORIZONTAL_DIVIDER = {
1894
+ dependencies: [ HorizontalDividerNode ],
1895
+ export: (node) => {
1896
+ return node instanceof HorizontalDividerNode ? "---" : null
1897
+ },
1898
+ regExpStart: /^-{3,}\s?$/,
1899
+ replace: (parentNode, children, match, endMatch, linesInBetween, isImport) => {
1900
+ const hrNode = new HorizontalDividerNode();
1901
+ parentNode.replace(hrNode);
1841
1902
 
1842
- for (const [ key, type ] of mutations) {
1843
- if (type !== "destroyed" && pending.has(key)) {
1844
- keysToProcess.push(key);
1903
+ if (!isImport) {
1904
+ const paragraph = $createParagraphNode();
1905
+ hrNode.insertAfter(paragraph);
1906
+ paragraph.select();
1845
1907
  }
1846
- }
1908
+ },
1909
+ type: "multiline-element"
1910
+ };
1847
1911
 
1848
- if (keysToProcess.length === 0) return
1912
+ const PUNCTUATION_OR_SPACE = /[^\w]/;
1849
1913
 
1850
- // Use a deferred update so the retokenizer has finished its
1851
- // skipTransforms update before we touch the nodes.
1852
- editor.update(() => {
1853
- for (const key of keysToProcess) {
1854
- const highlights = pending.get(key);
1855
- pending.delete(key);
1856
- if (!highlights) continue
1914
+ // Supplements Lexical's built-in registerMarkdownShortcuts to handle the case
1915
+ // where a user types a leading tag before text that already ends with a
1916
+ // trailing tag (e.g. typing ` before `hello`` or ** before **hello**).
1917
+ //
1918
+ // Lexical's markdown shortcut handler only triggers format transformations when
1919
+ // the closing tag is the character just typed. When the opening tag is typed
1920
+ // instead (e.g. typing ` before `hello`` to form ``hello``), the built-in
1921
+ // handler doesn't match because it looks backward from the cursor for an
1922
+ // opening tag, but the cursor is right after it.
1923
+ //
1924
+ // This listener detects that scenario for ALL text format transformers
1925
+ // (backtick, bold, italic, strikethrough, etc.) and applies the appropriate
1926
+ // format.
1927
+ function registerMarkdownLeadingTagHandler(editor, transformers) {
1928
+ const textFormatTransformers = transformers
1929
+ .filter(t => t.type === "text-format")
1930
+ .sort((a, b) => b.tag.length - a.tag.length); // Longer tags first
1857
1931
 
1858
- const codeNode = $getNodeByKey(key);
1859
- if (!codeNode || !$isCodeNode(codeNode)) continue
1932
+ return editor.registerUpdateListener(({ tags, dirtyLeaves, editorState, prevEditorState }) => {
1933
+ if (tags.has("historic") || tags.has("collaboration")) return
1934
+ if (editor.isComposing()) return
1860
1935
 
1861
- $applyHighlightRangesToCodeNode(codeNode, highlights);
1862
- }
1863
- }, { skipTransforms: true, discrete: true });
1864
- }
1936
+ const selection = editorState.read($getSelection);
1937
+ const prevSelection = prevEditorState.read($getSelection);
1865
1938
 
1866
- // Apply saved highlight ranges to the CodeHighlightNode children
1867
- // of a CodeNode, splitting nodes at range boundaries as needed.
1868
- // We can't use TextNode.splitText() because it creates TextNode
1869
- // instances (not CodeHighlightNodes) for the split parts. Instead,
1870
- // we manually create CodeHighlightNode replacements.
1871
- function $applyHighlightRangesToCodeNode(codeNode, highlights) {
1872
- if (highlights.length === 0) return
1939
+ if (!$isRangeSelection(prevSelection) || !$isRangeSelection(selection) || !selection.isCollapsed()) return
1873
1940
 
1874
- for (const { start: hlStart, end: hlEnd, style } of highlights) {
1875
- // Rebuild the child-to-offset mapping for each highlight range because
1876
- // earlier ranges may have split nodes, invalidating previous mappings.
1877
- const childRanges = $buildChildRanges(codeNode);
1941
+ const anchorKey = selection.anchor.key;
1942
+ const anchorOffset = selection.anchor.offset;
1878
1943
 
1879
- for (const { node, start: nodeStart, end: nodeEnd } of childRanges) {
1880
- // Skip plain TextNodes: only CodeHighlightNodes can be split into
1881
- // styled replacements here. The retokenizer normally converts any
1882
- // TextNode children back to CodeHighlightNodes before this runs,
1883
- // but the iteration over $buildChildRanges has to keep counting
1884
- // them so character offsets stay aligned with the saved ranges.
1885
- if (!$isCodeHighlightNode(node)) continue
1944
+ if (!dirtyLeaves.has(anchorKey)) return
1886
1945
 
1887
- // Check if this child overlaps with the highlight range
1888
- const overlapStart = Math.max(hlStart, nodeStart);
1889
- const overlapEnd = Math.min(hlEnd, nodeEnd);
1946
+ const anchorNode = editorState.read(() => $getNodeByKey(anchorKey));
1947
+ if (!$isTextNode(anchorNode)) return
1890
1948
 
1891
- if (overlapStart >= overlapEnd) continue
1949
+ // Only trigger when cursor moved forward (typing)
1950
+ const prevOffset = prevSelection.anchor.key === anchorKey ? prevSelection.anchor.offset : 0;
1951
+ if (anchorOffset <= prevOffset) return
1892
1952
 
1893
- // Calculate offsets relative to this node
1894
- const relStart = overlapStart - nodeStart;
1895
- const relEnd = overlapEnd - nodeStart;
1896
- const nodeLength = nodeEnd - nodeStart;
1953
+ const textContent = editorState.read(() => anchorNode.getTextContent());
1897
1954
 
1898
- if (relStart === 0 && relEnd === nodeLength) {
1899
- // Entire node is highlighted - apply style directly
1900
- node.setStyle(style);
1901
- $setCodeHighlightFormat(node, true);
1902
- } else {
1903
- // Need to split: replace the node with 2 or 3 CodeHighlightNodes
1904
- const text = node.getTextContent();
1905
- const highlightType = node.getHighlightType();
1906
- const replacements = [];
1955
+ // Try each transformer, longest tags first
1956
+ for (const transformer of textFormatTransformers) {
1957
+ const tag = transformer.tag;
1958
+ const tagLen = tag.length;
1907
1959
 
1908
- if (relStart > 0) {
1909
- replacements.push($createCodeHighlightNode(text.slice(0, relStart), highlightType));
1910
- }
1960
+ // The typed characters must end at the cursor position and form the opening tag
1961
+ const openTagStart = anchorOffset - tagLen;
1962
+ if (openTagStart < 0) continue
1911
1963
 
1912
- const styledNode = $createCodeHighlightNode(text.slice(relStart, relEnd), highlightType);
1913
- styledNode.setStyle(style);
1914
- $setCodeHighlightFormat(styledNode, true);
1915
- replacements.push(styledNode);
1964
+ const candidateOpenTag = textContent.slice(openTagStart, anchorOffset);
1965
+ if (candidateOpenTag !== tag) continue
1916
1966
 
1917
- if (relEnd < nodeLength) {
1918
- replacements.push($createCodeHighlightNode(text.slice(relEnd), highlightType));
1919
- }
1967
+ // Disambiguate from longer tags: if the character before the opening tag
1968
+ // is the same as the tag character, this might be part of a longer tag
1969
+ // (e.g. seeing `*` when the user is actually typing `**`)
1970
+ const tagChar = tag[0];
1971
+ if (openTagStart > 0 && textContent[openTagStart - 1] === tagChar) continue
1920
1972
 
1921
- for (const replacement of replacements) {
1922
- node.insertBefore(replacement);
1923
- }
1924
- node.remove();
1973
+ // Check intraword constraint: if intraword is false, the character before
1974
+ // the opening tag must be a space, punctuation, or the start of the text
1975
+ if (transformer.intraword === false && openTagStart > 0) {
1976
+ const beforeChar = textContent[openTagStart - 1];
1977
+ if (beforeChar && !PUNCTUATION_OR_SPACE.test(beforeChar)) continue
1925
1978
  }
1926
- }
1927
- }
1928
- }
1929
1979
 
1930
- function $buildChildRanges(codeNode) {
1931
- const childRanges = [];
1932
- let charOffset = 0;
1933
-
1934
- for (const child of codeNode.getChildren()) {
1935
- if ($isCodeHighlightNode(child) || $isTextNode(child)) {
1936
- const text = child.getTextContent();
1937
- childRanges.push({ node: child, start: charOffset, end: charOffset + text.length });
1938
- charOffset += text.length;
1939
- } else {
1940
- // LineBreakNode, TabNode - count as 1 character each (\n, \t)
1941
- charOffset += 1;
1942
- }
1943
- }
1944
-
1945
- return childRanges
1946
- }
1947
-
1948
- // Extract highlight ranges from the Lexical node tree of a CodeNode.
1949
- // This mirrors extractHighlightRanges (which works on DOM elements during
1950
- // HTML import) but reads from live CodeHighlightNode children instead.
1951
- function $extractHighlightRangesFromCodeNode(codeNode) {
1952
- const ranges = [];
1953
- const childRanges = $buildChildRanges(codeNode);
1954
-
1955
- for (const { node, start, end } of childRanges) {
1956
- const style = node.getStyle();
1957
- if (style && hasHighlightStyles(style)) {
1958
- ranges.push({ start, end, style });
1959
- }
1960
- }
1961
-
1962
- return ranges
1963
- }
1964
-
1965
- function buildCanonicalizers(config) {
1966
- return [
1967
- new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
1968
- new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
1969
- ]
1970
- }
1971
-
1972
- function $toggleSelectionStyles(editor, styles) {
1973
- const selection = $getSelection();
1974
- if (!$isRangeSelection(selection)) return
1980
+ // Search forward for a closing tag in the same text node
1981
+ const searchStart = anchorOffset;
1982
+ const closeTagIndex = textContent.indexOf(tag, searchStart);
1983
+ if (closeTagIndex < 0) continue
1975
1984
 
1976
- const patch = {};
1977
- for (const property in styles) {
1978
- const oldValue = $getSelectionStyleValueForProperty(selection, property);
1979
- patch[property] = toggleOrReplace(oldValue, styles[property]);
1980
- }
1985
+ // Disambiguate closing tag from longer tags: if the character right after
1986
+ // the closing tag is the same as the tag character, skip
1987
+ // (e.g. `*hello**` the first `*` at index 6 is part of `**`)
1988
+ if (textContent[closeTagIndex + tagLen] === tagChar) continue
1981
1989
 
1982
- if ($selectionIsInCodeBlock(selection)) {
1983
- $patchCodeHighlightStyles(editor, selection, patch);
1984
- } else {
1985
- $patchStyleText(selection, patch);
1986
- }
1987
- }
1990
+ // Also check if the character before the closing tag start is the same
1991
+ // tag character (e.g. the closing tag might be a suffix of a longer sequence)
1992
+ if (closeTagIndex > 0 && textContent[closeTagIndex - 1] === tagChar) continue
1988
1993
 
1989
- function $selectionIsInCodeBlock(selection) {
1990
- const nodes = selection.getNodes();
1991
- return nodes.some((node) => {
1992
- // A text node inside a code block may be either a CodeHighlightNode
1993
- // (after retokenization) or a plain TextNode (after splitText or before
1994
- // the retokenizer has run). Check the parent in both cases.
1995
- if ($isCodeHighlightNode(node) || $isTextNode(node)) {
1996
- return $isCodeNode(node.getParent())
1997
- }
1998
- return $isCodeNode(node)
1999
- })
2000
- }
1994
+ // There must be content between the tags (not just empty or whitespace-adjacent)
1995
+ const innerStart = anchorOffset;
1996
+ const innerEnd = closeTagIndex;
1997
+ if (innerEnd <= innerStart) continue
2001
1998
 
2002
- function $patchCodeHighlightStyles(editor, selection, patch) {
2003
- // Capture selection state and node keys before the nested update.
2004
- // Accept both CodeHighlightNode and TextNode children of a CodeNode
2005
- // because splitText creates TextNode instances and the retokenizer
2006
- // may not have converted them back to CodeHighlightNodes yet.
2007
- const nodeKeys = selection.getNodes()
2008
- .filter((node) => ($isCodeHighlightNode(node) || $isTextNode(node)) && $isCodeNode(node.getParent()))
2009
- .map((node) => ({
2010
- key: node.getKey(),
2011
- startOffset: $getNodeSelectionOffsets(node, selection)[0],
2012
- endOffset: $getNodeSelectionOffsets(node, selection)[1],
2013
- textSize: node.getTextContentSize()
2014
- }));
1999
+ // No space immediately after opening tag
2000
+ if (textContent[innerStart] === " ") continue
2015
2001
 
2016
- // Use skipTransforms to prevent the code highlighting system from
2017
- // re-tokenizing and wiping out the style changes we apply.
2018
- // Use discrete to force a synchronous commit, ensuring the changes
2019
- // are committed before editor.focus() triggers a second update cycle
2020
- // that would re-run transforms and wipe out the styles.
2021
- editor.update(() => {
2022
- const affectedCodeNodes = new Set();
2002
+ // No space immediately before closing tag
2003
+ if (textContent[innerEnd - 1] === " ") continue
2023
2004
 
2024
- for (const { key, startOffset, endOffset, textSize } of nodeKeys) {
2025
- const node = $getNodeByKey(key);
2026
- if (!node) continue
2005
+ // Check intraword constraint for closing tag
2006
+ if (transformer.intraword === false) {
2007
+ const afterCloseChar = textContent[closeTagIndex + tagLen];
2008
+ if (afterCloseChar && !PUNCTUATION_OR_SPACE.test(afterCloseChar)) continue
2009
+ }
2027
2010
 
2028
- const parent = node.getParent();
2029
- if (!$isCodeNode(parent)) continue
2030
- if (startOffset === endOffset) continue
2011
+ editor.update(() => {
2012
+ const node = $getNodeByKey(anchorKey);
2013
+ if (!node || !$isTextNode(node)) return
2031
2014
 
2032
- affectedCodeNodes.add(parent);
2015
+ const parent = node.getParent();
2016
+ if (parent === null || $isCodeNode(parent)) return
2033
2017
 
2034
- if (startOffset === 0 && endOffset === textSize) {
2035
- $applyStylePatchToNode(node, patch);
2036
- } else {
2037
- const splitNodes = node.splitText(startOffset, endOffset);
2038
- const targetNode = splitNodes[startOffset === 0 ? 0 : 1];
2039
- $applyStylePatchToNode(targetNode, patch);
2040
- }
2041
- }
2018
+ $applyFormatFromLeadingTag(node, openTagStart, transformer);
2019
+ });
2042
2020
 
2043
- // After applying styles, save highlight ranges for each affected CodeNode.
2044
- // The code retokenizer will replace the styled nodes with fresh unstyled
2045
- // tokens when transforms run. The pending highlights are picked up by the
2046
- // CodeNode mutation listener and reapplied after retokenization.
2047
- for (const codeNode of affectedCodeNodes) {
2048
- const ranges = $extractHighlightRangesFromCodeNode(codeNode);
2049
- if (ranges.length > 0) {
2050
- $getPendingHighlights(editor).set(codeNode.getKey(), ranges);
2051
- }
2021
+ break // Only apply the first (longest) matching transformer
2052
2022
  }
2053
- }, { skipTransforms: true, discrete: true });
2023
+ })
2054
2024
  }
2055
2025
 
2056
- function $getNodeSelectionOffsets(node, selection) {
2057
- const nodeKey = node.getKey();
2058
- const anchorKey = selection.anchor.key;
2059
- const focusKey = selection.focus.key;
2060
- const textSize = node.getTextContentSize();
2026
+ function $applyFormatFromLeadingTag(anchorNode, openTagStart, transformer) {
2027
+ const tag = transformer.tag;
2028
+ const tagLen = tag.length;
2029
+ const textContent = anchorNode.getTextContent();
2061
2030
 
2062
- const isAnchor = nodeKey === anchorKey;
2063
- const isFocus = nodeKey === focusKey;
2031
+ const innerStart = openTagStart + tagLen;
2032
+ const closeTagIndex = textContent.indexOf(tag, innerStart);
2033
+ if (closeTagIndex < 0) return
2064
2034
 
2065
- // Determine if selection is forward or backward
2066
- const isForward = selection.isBackward() === false;
2035
+ const inner = textContent.slice(innerStart, closeTagIndex);
2036
+ if (inner.length === 0) return
2067
2037
 
2068
- let start = 0;
2069
- let end = textSize;
2038
+ // Remove both tags and apply format
2039
+ const before = textContent.slice(0, openTagStart);
2040
+ const after = textContent.slice(closeTagIndex + tagLen);
2070
2041
 
2071
- if (isForward) {
2072
- if (isAnchor) start = selection.anchor.offset;
2073
- if (isFocus) end = selection.focus.offset;
2074
- } else {
2075
- if (isFocus) start = selection.focus.offset;
2076
- if (isAnchor) end = selection.anchor.offset;
2077
- }
2042
+ anchorNode.setTextContent(before + inner + after);
2078
2043
 
2079
- return [ start, end ]
2080
- }
2044
+ const nextSelection = $createRangeSelection();
2045
+ $setSelection(nextSelection);
2081
2046
 
2082
- function $applyStylePatchToNode(node, patch) {
2083
- const prevStyles = getStyleObjectFromCSS(node.getStyle());
2084
- const newStyles = { ...prevStyles };
2047
+ // Select the inner text to apply formatting
2048
+ nextSelection.anchor.set(anchorNode.getKey(), openTagStart, "text");
2049
+ nextSelection.focus.set(anchorNode.getKey(), openTagStart + inner.length, "text");
2085
2050
 
2086
- for (const [ key, value ] of Object.entries(patch)) {
2087
- if (value === null) {
2088
- delete newStyles[key];
2089
- } else {
2090
- newStyles[key] = value;
2051
+ for (const format of transformer.format) {
2052
+ if (!nextSelection.hasFormat(format)) {
2053
+ nextSelection.formatText(format);
2091
2054
  }
2092
2055
  }
2093
2056
 
2094
- const newCSSText = getCSSFromStyleObject(newStyles);
2095
- node.setStyle(newCSSText);
2096
-
2097
- // Sync the highlight format using TextNode's setFormat to bypass
2098
- // CodeHighlightNode's no-op override
2099
- const shouldHaveHighlight = hasHighlightStyles(newCSSText);
2100
- const hasHighlight = node.hasFormat("highlight");
2057
+ // Collapse selection to end of formatted text and clear the format
2058
+ // so subsequent typing is plain text
2059
+ nextSelection.anchor.set(nextSelection.focus.key, nextSelection.focus.offset, nextSelection.focus.type);
2101
2060
 
2102
- if (shouldHaveHighlight !== hasHighlight) {
2103
- $setCodeHighlightFormat(node, shouldHaveHighlight);
2061
+ for (const format of transformer.format) {
2062
+ if (nextSelection.hasFormat(format)) {
2063
+ nextSelection.toggleFormat(format);
2064
+ }
2104
2065
  }
2105
2066
  }
2106
2067
 
2107
- function $setCodeHighlightFormat(node, shouldHaveHighlight) {
2108
- const writable = node.getWritable();
2109
- const IS_HIGHLIGHT = 1 << 7;
2110
-
2111
- if (shouldHaveHighlight) {
2112
- writable.__format |= IS_HIGHLIGHT;
2113
- } else {
2114
- writable.__format &= ~IS_HIGHLIGHT;
2068
+ var theme = {
2069
+ text: {
2070
+ bold: "lexxy-content__bold",
2071
+ italic: "lexxy-content__italic",
2072
+ strikethrough: "lexxy-content__strikethrough",
2073
+ underline: "lexxy-content__underline",
2074
+ highlight: "lexxy-content__highlight"
2075
+ },
2076
+ tableCellHeader: "lexxy-content__table-cell--header",
2077
+ tableCellSelected: "lexxy-content__table-cell--selected",
2078
+ tableSelection: "lexxy-content__table--selection",
2079
+ tableScrollableWrapper: "lexxy-content__table-wrapper",
2080
+ tableCellHighlight: "lexxy-content__table-cell--highlight",
2081
+ tableCellFocus: "lexxy-content__table-cell--focus",
2082
+ list: {
2083
+ nested: {
2084
+ listitem: "lexxy-nested-listitem",
2085
+ }
2086
+ },
2087
+ codeHighlight: {
2088
+ addition: "code-token__selector",
2089
+ atrule: "code-token__attr",
2090
+ attr: "code-token__attr",
2091
+ "attr-name": "code-token__attr",
2092
+ "attr-value": "code-token__selector",
2093
+ boolean: "code-token__property",
2094
+ bold: "code-token__variable",
2095
+ builtin: "code-token__selector",
2096
+ cdata: "code-token__comment",
2097
+ char: "code-token__selector",
2098
+ class: "code-token__function",
2099
+ "class-name": "code-token__function",
2100
+ color: "code-token__property",
2101
+ comment: "code-token__comment",
2102
+ constant: "code-token__property",
2103
+ coord: "code-token__comment",
2104
+ decorator: "code-token__function",
2105
+ deleted: "code-token__operator",
2106
+ deletion: "code-token__operator",
2107
+ directive: "code-token__attr",
2108
+ "directive-hash": "code-token__property",
2109
+ doctype: "code-token__comment",
2110
+ entity: "code-token__operator",
2111
+ function: "code-token__function",
2112
+ hexcode: "code-token__property",
2113
+ important: "code-token__function",
2114
+ inserted: "code-token__selector",
2115
+ italic: "code-token__comment",
2116
+ keyword: "code-token__attr",
2117
+ line: "code-token__selector",
2118
+ namespace: "code-token__variable",
2119
+ number: "code-token__property",
2120
+ macro: "code-token__function",
2121
+ operator: "code-token__operator",
2122
+ parameter: "code-token__variable",
2123
+ prolog: "code-token__comment",
2124
+ property: "code-token__property",
2125
+ punctuation: "code-token__punctuation",
2126
+ "raw-string": "code-token__operator",
2127
+ regex: "code-token__variable",
2128
+ script: "code-token__function",
2129
+ selector: "code-token__selector",
2130
+ string: "code-token__selector",
2131
+ style: "code-token__function",
2132
+ symbol: "code-token__property",
2133
+ tag: "code-token__property",
2134
+ title: "code-token__function",
2135
+ "type-definition": "code-token__function",
2136
+ url: "code-token__operator",
2137
+ variable: "code-token__variable",
2115
2138
  }
2116
- }
2139
+ };
2117
2140
 
2118
- function toggleOrReplace(oldValue, newValue) {
2119
- return oldValue === newValue ? null : newValue
2120
- }
2141
+ // Shared, strictly-contained element used to attach ephemeral nodes when we
2142
+ // need to read computed styles (e.g. canonicalizing style values, resolving
2143
+ // CSS custom properties). The container is created once and attached to
2144
+ // `document.body` once; subsequent child mutations happen *inside* the
2145
+ // contained subtree so they do not invalidate style on the rest of the page.
2146
+ //
2147
+ // Without this, `document.body.appendChild(...)` / `element.remove()` calls
2148
+ // forced the browser to re-evaluate every ancestor-dependent selector (`:has()`,
2149
+ // descendant combinators, universal sibling rules) across the document on each
2150
+ // invocation — a 13,000+ element style recalc per call on a typical Basecamp
2151
+ // page.
2121
2152
 
2122
- function $syncHighlightWithStyle(textNode) {
2123
- if (hasHighlightStyles(textNode.getStyle()) !== textNode.hasFormat("highlight")) {
2124
- textNode.toggleFormat("highlight");
2125
- }
2126
- }
2153
+ let resolverRoot = null;
2127
2154
 
2128
- function $syncHighlightWithCodeHighlightNode(node) {
2129
- const parent = node.getParent();
2130
- if (!$isCodeNode(parent)) return
2155
+ function styleResolverRoot() {
2156
+ if (resolverRoot && resolverRoot.isConnected) return resolverRoot
2131
2157
 
2132
- const shouldHaveHighlight = hasHighlightStyles(node.getStyle());
2133
- const hasHighlight = node.hasFormat("highlight");
2158
+ resolverRoot = document.createElement("div");
2159
+ resolverRoot.setAttribute("aria-hidden", "true");
2160
+ resolverRoot.setAttribute("data-lexxy-style-resolver", "");
2161
+ // `contain: strict` (size, layout, paint, style) isolates everything.
2162
+ // The root itself paints nothing (visibility hidden), has zero
2163
+ // geometric impact (position fixed, intrinsic size via contain), and
2164
+ // never leaks style invalidation to its ancestors.
2165
+ resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;";
2166
+ document.body.appendChild(resolverRoot);
2167
+ return resolverRoot
2168
+ }
2134
2169
 
2135
- if (shouldHaveHighlight !== hasHighlight) {
2136
- $setCodeHighlightFormat(node, shouldHaveHighlight);
2170
+ function isSelectionHighlighted(selection) {
2171
+ if (!$isRangeSelection(selection)) return false
2172
+
2173
+ if (selection.isCollapsed()) {
2174
+ return hasHighlightStyles(selection.style)
2175
+ } else {
2176
+ return selection.hasFormat("highlight")
2137
2177
  }
2138
2178
  }
2139
2179
 
2140
- function $canonicalizePastedStyles(textNode, canonicalizers = []) {
2141
- if ($hasPastedStyles(textNode)) {
2142
- $setPastedStyles(textNode, false);
2143
-
2144
- const canonicalizedCSS = applyCanonicalizers(textNode.getStyle(), canonicalizers);
2145
- textNode.setStyle(canonicalizedCSS);
2180
+ function getHighlightStyles(selection) {
2181
+ if (!$isRangeSelection(selection)) return null
2146
2182
 
2147
- const selection = $getSelection();
2148
- if (textNode.isSelected(selection)) {
2149
- selection.setStyle(textNode.getStyle());
2150
- selection.setFormat(textNode.getFormat());
2183
+ let styles = getStyleObjectFromCSS(selection.style);
2184
+ if (!styles.color && !styles["background-color"]) {
2185
+ const anchorNode = selection.anchor.getNode();
2186
+ if ($isTextNode(anchorNode)) {
2187
+ styles = getStyleObjectFromCSS(anchorNode.getStyle());
2151
2188
  }
2152
2189
  }
2153
- }
2154
2190
 
2155
- function $setPastedStyles(textNode, value = true) {
2156
- $setState(textNode, hasPastedStylesState, value);
2157
- }
2191
+ const color = styles.color || null;
2192
+ const backgroundColor = styles["background-color"] || null;
2193
+ if (!color && !backgroundColor) return null
2158
2194
 
2159
- function $hasPastedStyles(textNode) {
2160
- return $getState(textNode, hasPastedStylesState)
2195
+ return { color, backgroundColor }
2161
2196
  }
2162
2197
 
2163
- const COMMANDS = [
2164
- "bold",
2165
- "italic",
2166
- "strikethrough",
2167
- "underline",
2168
- "link",
2169
- "unlink",
2170
- "toggleHighlight",
2171
- "removeHighlight",
2172
- "setFormatHeadingLarge",
2173
- "setFormatHeadingMedium",
2174
- "setFormatHeadingSmall",
2175
- "setFormatParagraph",
2176
- "clearFormatting",
2177
- "insertUnorderedList",
2178
- "insertOrderedList",
2179
- "insertQuoteBlock",
2180
- "insertCodeBlock",
2181
- "setCodeLanguage",
2182
- "insertHorizontalDivider",
2183
- "uploadImage",
2184
- "uploadFile",
2185
-
2186
- "insertTable",
2187
-
2188
- "undo",
2189
- "redo"
2190
- ];
2191
-
2192
- class CommandDispatcher {
2193
- #selectionBeforeDrag = null
2194
- #listeners = new ListenerBin()
2198
+ function hasHighlightStyles(cssOrStyles) {
2199
+ const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
2200
+ return !!(styles.color || styles["background-color"])
2201
+ }
2195
2202
 
2196
- static configureFor(editorElement) {
2197
- return new CommandDispatcher(editorElement)
2203
+ function applyCanonicalizers(styles, canonicalizers = []) {
2204
+ return canonicalizers.reduce((css, canonicalizer) => {
2205
+ return canonicalizer.applyCanonicalization(css)
2206
+ }, styles)
2207
+ }
2208
+
2209
+ class StyleCanonicalizer {
2210
+ constructor(property, allowedValues= []) {
2211
+ this._property = property;
2212
+ this._allowedValues = allowedValues;
2213
+ this._canonicalValues = this.#allowedValuesIdentityObject;
2198
2214
  }
2199
2215
 
2200
- constructor(editorElement) {
2201
- this.editorElement = editorElement;
2202
- this.editor = editorElement.editor;
2203
- this.selection = editorElement.selection;
2204
- this.contents = editorElement.contents;
2205
- this.clipboard = editorElement.clipboard;
2216
+ applyCanonicalization(css) {
2217
+ const styles = { ...getStyleObjectFromCSS(css) };
2206
2218
 
2207
- this.#registerCommands();
2208
- this.#registerKeyboardCommands();
2209
- this.#registerDragAndDropHandlers();
2210
- }
2219
+ styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
2220
+ if (!styles[this._property]) {
2221
+ delete styles[this._property];
2222
+ }
2211
2223
 
2212
- dispatchPaste(event) {
2213
- return this.clipboard.paste(event)
2224
+ return getCSSFromStyleObject(styles)
2214
2225
  }
2215
2226
 
2216
- dispatchBold() {
2217
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
2227
+ getCanonicalAllowedValue(value) {
2228
+ return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
2218
2229
  }
2219
2230
 
2220
- dispatchItalic() {
2221
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
2222
- }
2231
+ // Private
2223
2232
 
2224
- dispatchStrikethrough() {
2225
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
2233
+ get #allowedValuesIdentityObject() {
2234
+ return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
2226
2235
  }
2227
2236
 
2228
- dispatchUnderline() {
2229
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
2237
+ #resolveCannonicalValue(value) {
2238
+ let index = this.#computedAllowedValues.indexOf(value);
2239
+ if (index === -1) {
2240
+ index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
2241
+ }
2242
+ return index === -1 ? null : this._allowedValues[index]
2230
2243
  }
2231
2244
 
2232
- dispatchToggleHighlight(styles) {
2233
- this.editor.dispatchCommand(TOGGLE_HIGHLIGHT_COMMAND, styles);
2245
+ get #computedAllowedValues() {
2246
+ return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
2234
2247
  }
2248
+ }
2235
2249
 
2236
- dispatchRemoveHighlight() {
2237
- this.editor.dispatchCommand(REMOVE_HIGHLIGHT_COMMAND);
2238
- }
2250
+ // Separates DOM writes from layout reads to avoid forced reflows, and attaches
2251
+ // resolver elements to a strictly-contained root (outside the normal document
2252
+ // flow) so neither the attach nor the detach invalidate styles on the rest of
2253
+ // the page. Without containment, appending to `document.body` triggered a
2254
+ // page-wide style recalc on every canonicalization pass.
2255
+ function computeStyleValues(property, values) {
2256
+ const fragment = document.createDocumentFragment();
2239
2257
 
2240
- dispatchLink(url) {
2241
- this.editor.update(() => {
2242
- const selection = $getSelection();
2243
- if (!$isRangeSelection(selection)) return
2258
+ const elements = values.map(value => {
2259
+ const element = createElement("span", { style: `display: none; ${property}: ${value};` });
2260
+ fragment.appendChild(element);
2261
+ return element
2262
+ });
2244
2263
 
2245
- const anchorNode = selection.anchor.getNode();
2264
+ styleResolverRoot().appendChild(fragment);
2246
2265
 
2247
- if (selection.isCollapsed() && !$getNearestNodeOfType(anchorNode, LinkNode)) {
2248
- const autoLinkNode = $createAutoLinkNode(url);
2249
- const textNode = $createTextNode(url);
2250
- autoLinkNode.append(textNode);
2251
- selection.insertNodes([ autoLinkNode ]);
2252
- } else {
2253
- $toggleLink(url);
2254
- }
2255
- });
2256
- }
2266
+ const computed = elements.map(element =>
2267
+ window.getComputedStyle(element).getPropertyValue(property)
2268
+ );
2257
2269
 
2258
- dispatchUnlink() {
2259
- this.editor.update(() => {
2260
- // Let adapters signal whether unlink should target a frozen link key.
2261
- if (this.editorElement.adapter.unlinkFrozenNode?.()) {
2262
- return
2263
- }
2270
+ elements.forEach(element => element.remove());
2271
+ return computed
2272
+ }
2264
2273
 
2265
- $toggleLink(null);
2266
- });
2267
- }
2274
+ const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
2275
+ const REMOVE_HIGHLIGHT_COMMAND = createCommand();
2276
+ const BLANK_STYLES = { "color": null, "background-color": null };
2268
2277
 
2269
- dispatchInsertUnorderedList() {
2270
- const selection = $getSelection();
2271
- if (!$isRangeSelection(selection)) return
2278
+ const hasPastedStylesState = createState("hasPastedStyles", {
2279
+ parse: (value) => value || false
2280
+ });
2272
2281
 
2273
- const anchorNode = selection.anchor.getNode();
2282
+ // Stores pending highlight ranges extracted during HTML import, keyed by CodeNode key.
2283
+ // After the code retokenizer creates fresh CodeHighlightNodes, a mutation listener
2284
+ // reads this map and re-applies the highlight styles. Scoped per editor instance
2285
+ // so entries don't leak across editors or outlive a torn-down editor.
2286
+ const pendingCodeHighlights = new WeakMap();
2274
2287
 
2275
- if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
2276
- this.contents.applyParagraphFormat();
2277
- } else {
2278
- this.contents.applyUnorderedListFormat();
2279
- }
2288
+ class HighlightExtension extends LexxyExtension {
2289
+ get enabled() {
2290
+ return this.editorElement.supportsRichText
2280
2291
  }
2281
2292
 
2282
- dispatchInsertOrderedList() {
2283
- const selection = $getSelection();
2284
- if (!$isRangeSelection(selection)) return
2285
-
2286
- const anchorNode = selection.anchor.getNode();
2293
+ get lexicalExtension() {
2294
+ const extension = defineExtension({
2295
+ dependencies: [ RichTextExtension ],
2296
+ name: "lexxy/highlight",
2297
+ config: {
2298
+ color: { buttons: [], permit: [] },
2299
+ "background-color": { buttons: [], permit: [] }
2300
+ },
2301
+ html: {
2302
+ import: {
2303
+ mark: $markConversion
2304
+ }
2305
+ },
2306
+ register(editor, config) {
2307
+ // keep the ref to the canonicalizers for optimized css conversion
2308
+ const canonicalizers = buildCanonicalizers(config);
2287
2309
 
2288
- if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
2289
- this.contents.applyParagraphFormat();
2290
- } else {
2291
- this.contents.applyOrderedListFormat();
2292
- }
2293
- }
2310
+ // Register the <pre> converter directly in the conversion cache so it
2311
+ // coexists with other extensions' "pre" converters (the extension-level
2312
+ // html.import uses Object.assign, which means only one "pre" per key).
2313
+ $registerPreConversion(editor);
2294
2314
 
2295
- dispatchInsertQuoteBlock() {
2296
- this.contents.toggleBlockquote();
2297
- }
2315
+ return mergeRegister(
2316
+ editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, (styles) => $toggleSelectionStyles(editor, styles), COMMAND_PRIORITY_NORMAL),
2317
+ editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(editor, BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
2318
+ editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
2319
+ editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode),
2320
+ editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers)),
2321
+ editor.registerMutationListener(CodeNode, (mutations) => {
2322
+ $applyPendingCodeHighlights(editor, mutations);
2323
+ }, { skipInitialization: true })
2324
+ )
2325
+ }
2326
+ });
2298
2327
 
2299
- dispatchInsertCodeBlock() {
2300
- if (this.selection.hasSelectedWordsInSingleLine) {
2301
- this.#toggleInlineCode();
2302
- } else {
2303
- this.contents.toggleCodeBlock();
2304
- }
2328
+ return [ extension, this.editorConfig.get("highlight") ]
2305
2329
  }
2330
+ }
2306
2331
 
2307
- #toggleInlineCode() {
2308
- const selection = $getSelection();
2309
- if (!$isRangeSelection(selection)) return
2310
-
2311
- if (!selection.isCollapsed()) {
2312
- const textNodes = selection.getNodes().filter($isTextNode);
2313
- const applyingCode = !textNodes.every((node) => node.hasFormat("code"));
2332
+ function $applyHighlightStyle(textNode, element) {
2333
+ const elementStyles = {
2334
+ color: element.style?.color,
2335
+ "background-color": element.style?.backgroundColor
2336
+ };
2314
2337
 
2315
- if (applyingCode) {
2316
- this.#stripInlineFormattingFromSelection(selection, textNodes);
2317
- }
2318
- }
2338
+ if ($hasUpdateTag(PASTE_TAG)) { $setPastedStyles(textNode); }
2339
+ const highlightStyle = getCSSFromStyleObject(elementStyles);
2319
2340
 
2320
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
2341
+ if (highlightStyle.length) {
2342
+ return textNode.setStyle(textNode.getStyle() + highlightStyle)
2321
2343
  }
2344
+ }
2322
2345
 
2323
- // Strip all inline formatting (bold, italic, etc.) from the selected text
2324
- // nodes so that applying code produces a single merged <code> element instead
2325
- // of one per differently-formatted span.
2326
- #stripInlineFormattingFromSelection(selection, textNodes) {
2327
- const isBackward = selection.isBackward();
2328
- const startPoint = isBackward ? selection.focus : selection.anchor;
2329
- const endPoint = isBackward ? selection.anchor : selection.focus;
2330
-
2331
- for (let i = 0; i < textNodes.length; i++) {
2332
- const node = textNodes[i];
2333
- if (node.getFormat() === 0) continue
2346
+ function $markConversion() {
2347
+ return {
2348
+ conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
2349
+ priority: 1
2350
+ }
2351
+ }
2334
2352
 
2335
- const isFirst = i === 0;
2336
- const isLast = i === textNodes.length - 1;
2337
- const startOffset = isFirst && startPoint.type === "text" ? startPoint.offset : 0;
2338
- const endOffset = isLast && endPoint.type === "text" ? endPoint.offset : node.getTextContentSize();
2353
+ // Register a custom <pre> converter directly in the editor's HTML conversion
2354
+ // cache. We can't use the extension-level html.import because Object.assign
2355
+ // merges all extensions' converters by tag, and a later extension (e.g.
2356
+ // TrixContentExtension) would overwrite ours.
2357
+ function $registerPreConversion(editor) {
2358
+ if (!editor._htmlConversions) return
2339
2359
 
2340
- if (startOffset === 0 && endOffset === node.getTextContentSize()) {
2341
- node.setFormat(0);
2342
- } else {
2343
- const splits = node.splitText(startOffset, endOffset);
2344
- const target = startOffset === 0 ? splits[0] : splits[1];
2345
- target.setFormat(0);
2360
+ let preEntries = editor._htmlConversions.get("pre");
2361
+ if (!preEntries) {
2362
+ preEntries = [];
2363
+ editor._htmlConversions.set("pre", preEntries);
2364
+ }
2365
+ preEntries.push($preConversionWithHighlightsFactory(editor));
2366
+ }
2346
2367
 
2347
- if (isFirst && startPoint.type === "text") {
2348
- startPoint.set(target.getKey(), 0, "text");
2349
- }
2350
- if (isLast && endPoint.type === "text") {
2351
- endPoint.set(target.getKey(), endOffset - startOffset, "text");
2352
- }
2353
- }
2368
+ // Returns a <pre> converter factory scoped to a specific editor instance.
2369
+ // The factory extracts highlight ranges from <mark> elements before the code
2370
+ // retokenizer can destroy them. The ranges are stored in pendingCodeHighlights
2371
+ // and applied after retokenization via a mutation listener.
2372
+ function $preConversionWithHighlightsFactory(editor) {
2373
+ return function $preConversionWithHighlights(domNode) {
2374
+ const highlights = extractHighlightRanges(domNode);
2375
+ if (highlights.length === 0) return null
2376
+
2377
+ return {
2378
+ conversion: (domNode) => {
2379
+ const language = domNode.getAttribute("data-language");
2380
+ const codeNode = $createCodeNode(language);
2381
+ $getPendingHighlights(editor).set(codeNode.getKey(), highlights);
2382
+ return { node: codeNode }
2383
+ },
2384
+ priority: 2
2354
2385
  }
2355
2386
  }
2387
+ }
2356
2388
 
2357
- dispatchSetCodeLanguage(language) {
2358
- this.editor.update(() => {
2359
- if (!this.selection.isInsideCodeBlock) return
2389
+ // Walk the DOM tree inside a <pre> element and build a list of
2390
+ // { start, end, style } ranges for every <mark> element found.
2391
+ function extractHighlightRanges(preElement) {
2392
+ const ranges = [];
2393
+ const codeElement = preElement.querySelector("code") || preElement;
2360
2394
 
2361
- const codeNode = this.selection.nearestNodeOfType(CodeNode);
2362
- if (!codeNode) return
2395
+ let offset = 0;
2363
2396
 
2364
- codeNode.setLanguage(language);
2365
- });
2366
- }
2397
+ function walk(node) {
2398
+ if (node.nodeType === Node.TEXT_NODE) {
2399
+ offset += node.textContent.length;
2400
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
2401
+ // <br> maps to a LineBreakNode (1 character) in Lexical
2402
+ if (node.tagName === "BR") {
2403
+ offset += 1;
2404
+ return
2405
+ }
2367
2406
 
2368
- dispatchInsertHorizontalDivider() {
2369
- this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
2370
- this.editor.focus();
2371
- }
2407
+ const isMark = node.tagName === "MARK";
2408
+ const start = offset;
2372
2409
 
2373
- dispatchSetFormatHeadingLarge() {
2374
- this.contents.applyHeadingFormat("h2");
2375
- }
2410
+ for (const child of node.childNodes) {
2411
+ walk(child);
2412
+ }
2376
2413
 
2377
- dispatchSetFormatHeadingMedium() {
2378
- this.contents.applyHeadingFormat("h3");
2414
+ if (isMark) {
2415
+ const style = extractHighlightStyleFromElement(node);
2416
+ if (style) {
2417
+ ranges.push({ start, end: offset, style });
2418
+ }
2419
+ }
2420
+ }
2379
2421
  }
2380
2422
 
2381
- dispatchSetFormatHeadingSmall() {
2382
- this.contents.applyHeadingFormat("h4");
2423
+ for (const child of codeElement.childNodes) {
2424
+ walk(child);
2383
2425
  }
2384
2426
 
2385
- dispatchSetFormatParagraph() {
2386
- this.contents.applyParagraphFormat();
2387
- }
2427
+ return ranges
2428
+ }
2388
2429
 
2389
- dispatchClearFormatting() {
2390
- this.contents.clearFormatting();
2430
+ function $getPendingHighlights(editor) {
2431
+ let map = pendingCodeHighlights.get(editor);
2432
+ if (!map) {
2433
+ map = new Map();
2434
+ pendingCodeHighlights.set(editor, map);
2391
2435
  }
2436
+ return map
2437
+ }
2392
2438
 
2393
- dispatchUploadImage() {
2394
- this.#dispatchUploadAttachment("image/*,video/*");
2395
- }
2439
+ function extractHighlightStyleFromElement(element) {
2440
+ const styles = {};
2441
+ if (element.style?.color) styles.color = element.style.color;
2442
+ if (element.style?.backgroundColor) styles["background-color"] = element.style.backgroundColor;
2443
+ const css = getCSSFromStyleObject(styles);
2444
+ return css.length > 0 ? css : null
2445
+ }
2396
2446
 
2397
- dispatchUploadFile() {
2398
- this.#dispatchUploadAttachment();
2399
- }
2447
+ // Called from the CodeNode mutation listener after the retokenizer has
2448
+ // replaced TextNodes with fresh CodeHighlightNodes.
2449
+ function $applyPendingCodeHighlights(editor, mutations) {
2450
+ const pending = $getPendingHighlights(editor);
2451
+ const keysToProcess = [];
2400
2452
 
2401
- #dispatchUploadAttachment(accept = null) {
2402
- const attributes = {
2403
- type: "file",
2404
- multiple: true,
2405
- style: "display: none;",
2406
- onchange: ({ target: { files } }) => {
2407
- this.contents.uploadFiles(files, { selectLast: true });
2408
- }
2409
- };
2453
+ for (const [ key, type ] of mutations) {
2454
+ if (type !== "destroyed" && pending.has(key)) {
2455
+ keysToProcess.push(key);
2456
+ }
2457
+ }
2410
2458
 
2411
- if (accept) attributes.accept = accept;
2459
+ if (keysToProcess.length === 0) return
2412
2460
 
2413
- const input = createElement("input", attributes);
2461
+ // Use a deferred update so the retokenizer has finished its
2462
+ // skipTransforms update before we touch the nodes.
2463
+ editor.update(() => {
2464
+ for (const key of keysToProcess) {
2465
+ const highlights = pending.get(key);
2466
+ pending.delete(key);
2467
+ if (!highlights) continue
2414
2468
 
2415
- // Append and remove to make testable
2416
- this.editorElement.appendChild(input);
2417
- input.click();
2418
- setTimeout(() => input.remove(), 1000);
2419
- }
2469
+ const codeNode = $getNodeByKey(key);
2470
+ if (!codeNode || !$isCodeNode(codeNode)) continue
2420
2471
 
2421
- dispatchInsertTable() {
2422
- this.editor.dispatchCommand(INSERT_TABLE_COMMAND, { "rows": 3, "columns": 3, "includeHeaders": true });
2423
- }
2472
+ $applyHighlightRangesToCodeNode(codeNode, highlights);
2473
+ }
2474
+ }, { skipTransforms: true, discrete: true });
2475
+ }
2424
2476
 
2425
- dispatchUndo() {
2426
- this.editor.dispatchCommand(UNDO_COMMAND, undefined);
2427
- }
2477
+ // Apply saved highlight ranges to the CodeHighlightNode children
2478
+ // of a CodeNode, splitting nodes at range boundaries as needed.
2479
+ // We can't use TextNode.splitText() because it creates TextNode
2480
+ // instances (not CodeHighlightNodes) for the split parts. Instead,
2481
+ // we manually create CodeHighlightNode replacements.
2482
+ function $applyHighlightRangesToCodeNode(codeNode, highlights) {
2483
+ if (highlights.length === 0) return
2428
2484
 
2429
- dispatchRedo() {
2430
- this.editor.dispatchCommand(REDO_COMMAND, undefined);
2431
- }
2485
+ for (const { start: hlStart, end: hlEnd, style } of highlights) {
2486
+ // Rebuild the child-to-offset mapping for each highlight range because
2487
+ // earlier ranges may have split nodes, invalidating previous mappings.
2488
+ const childRanges = $buildChildRanges(codeNode);
2432
2489
 
2433
- dispose() {
2434
- this.#listeners.dispose();
2435
- }
2490
+ for (const { node, start: nodeStart, end: nodeEnd } of childRanges) {
2491
+ // Skip plain TextNodes: only CodeHighlightNodes can be split into
2492
+ // styled replacements here. The retokenizer normally converts any
2493
+ // TextNode children back to CodeHighlightNodes before this runs,
2494
+ // but the iteration over $buildChildRanges has to keep counting
2495
+ // them so character offsets stay aligned with the saved ranges.
2496
+ if (!$isCodeHighlightNode(node)) continue
2436
2497
 
2437
- #registerCommands() {
2438
- for (const command of COMMANDS) {
2439
- const methodName = `dispatch${capitalize(command)}`;
2440
- this.#registerCommandHandler(command, 0, this[methodName].bind(this));
2441
- }
2498
+ // Check if this child overlaps with the highlight range
2499
+ const overlapStart = Math.max(hlStart, nodeStart);
2500
+ const overlapEnd = Math.min(hlEnd, nodeEnd);
2442
2501
 
2443
- this.#registerCommandHandler(PASTE_COMMAND, COMMAND_PRIORITY_LOW, this.dispatchPaste.bind(this));
2444
- }
2502
+ if (overlapStart >= overlapEnd) continue
2445
2503
 
2446
- #registerCommandHandler(command, priority, handler) {
2447
- this.#listeners.track(this.editor.registerCommand(command, handler, priority));
2448
- }
2504
+ // Calculate offsets relative to this node
2505
+ const relStart = overlapStart - nodeStart;
2506
+ const relEnd = overlapEnd - nodeStart;
2507
+ const nodeLength = nodeEnd - nodeStart;
2449
2508
 
2450
- #registerKeyboardCommands() {
2451
- this.#registerCommandHandler(KEY_ARROW_RIGHT_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleArrowRightKey.bind(this));
2452
- this.#registerCommandHandler(KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleTabKey.bind(this));
2453
- }
2509
+ if (relStart === 0 && relEnd === nodeLength) {
2510
+ // Entire node is highlighted - apply style directly
2511
+ node.setStyle(style);
2512
+ $setCodeHighlightFormat(node, true);
2513
+ } else {
2514
+ // Need to split: replace the node with 2 or 3 CodeHighlightNodes
2515
+ const text = node.getTextContent();
2516
+ const highlightType = node.getHighlightType();
2517
+ const replacements = [];
2454
2518
 
2455
- #handleArrowRightKey(event) {
2456
- const selection = $getSelection();
2457
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
2458
- if (this.selection.isInsideCodeBlock || !selection.hasFormat("code")) return false
2519
+ if (relStart > 0) {
2520
+ replacements.push($createCodeHighlightNode(text.slice(0, relStart), highlightType));
2521
+ }
2459
2522
 
2460
- const anchorNode = selection.anchor.getNode();
2461
- if (!$isTextNode(anchorNode) || selection.anchor.offset !== anchorNode.getTextContentSize()) return false
2462
- if (anchorNode.getNextSibling() !== null) return false
2523
+ const styledNode = $createCodeHighlightNode(text.slice(relStart, relEnd), highlightType);
2524
+ styledNode.setStyle(style);
2525
+ $setCodeHighlightFormat(styledNode, true);
2526
+ replacements.push(styledNode);
2463
2527
 
2464
- event.preventDefault();
2465
- selection.toggleFormat("code");
2466
- return true
2467
- }
2528
+ if (relEnd < nodeLength) {
2529
+ replacements.push($createCodeHighlightNode(text.slice(relEnd), highlightType));
2530
+ }
2468
2531
 
2469
- #registerDragAndDropHandlers() {
2470
- if (this.editorElement.supportsAttachments) {
2471
- this.dragCounter = 0;
2472
- const root = this.editor.getRootElement();
2473
- this.#listeners.track(
2474
- registerEventListener(root, "dragover", this.#handleDragOver.bind(this)),
2475
- registerEventListener(root, "drop", this.#handleDrop.bind(this)),
2476
- registerEventListener(root, "dragenter", this.#handleDragEnter.bind(this)),
2477
- registerEventListener(root, "dragleave", this.#handleDragLeave.bind(this))
2478
- );
2532
+ for (const replacement of replacements) {
2533
+ node.insertBefore(replacement);
2534
+ }
2535
+ node.remove();
2536
+ }
2479
2537
  }
2480
2538
  }
2539
+ }
2481
2540
 
2482
- #handleDragEnter(event) {
2483
- if (this.#isInternalDrag(event)) return
2541
+ function $buildChildRanges(codeNode) {
2542
+ const childRanges = [];
2543
+ let charOffset = 0;
2484
2544
 
2485
- this.dragCounter++;
2486
- if (this.dragCounter === 1) {
2487
- this.#saveSelectionBeforeDrag();
2488
- this.editor.getRootElement().classList.add("lexxy-editor--drag-over");
2545
+ for (const child of codeNode.getChildren()) {
2546
+ if ($isCodeHighlightNode(child) || $isTextNode(child)) {
2547
+ const text = child.getTextContent();
2548
+ childRanges.push({ node: child, start: charOffset, end: charOffset + text.length });
2549
+ charOffset += text.length;
2550
+ } else {
2551
+ // LineBreakNode, TabNode - count as 1 character each (\n, \t)
2552
+ charOffset += 1;
2489
2553
  }
2490
2554
  }
2491
2555
 
2492
- #handleDragLeave(event) {
2493
- if (this.#isInternalDrag(event)) return
2556
+ return childRanges
2557
+ }
2494
2558
 
2495
- this.dragCounter--;
2496
- if (this.dragCounter === 0) {
2497
- this.#selectionBeforeDrag = null;
2498
- this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
2559
+ // Extract highlight ranges from the Lexical node tree of a CodeNode.
2560
+ // This mirrors extractHighlightRanges (which works on DOM elements during
2561
+ // HTML import) but reads from live CodeHighlightNode children instead.
2562
+ function $extractHighlightRangesFromCodeNode(codeNode) {
2563
+ const ranges = [];
2564
+ const childRanges = $buildChildRanges(codeNode);
2565
+
2566
+ for (const { node, start, end } of childRanges) {
2567
+ const style = node.getStyle();
2568
+ if (style && hasHighlightStyles(style)) {
2569
+ ranges.push({ start, end, style });
2499
2570
  }
2500
2571
  }
2501
2572
 
2502
- #handleDragOver(event) {
2503
- if (this.#isInternalDrag(event)) return
2573
+ return ranges
2574
+ }
2504
2575
 
2505
- event.preventDefault();
2506
- }
2576
+ function buildCanonicalizers(config) {
2577
+ return [
2578
+ new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
2579
+ new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
2580
+ ]
2581
+ }
2507
2582
 
2508
- #handleDrop(event) {
2509
- if (this.#isInternalDrag(event)) return
2583
+ function $toggleSelectionStyles(editor, styles) {
2584
+ const selection = $getSelection();
2585
+ if (!$isRangeSelection(selection)) return
2510
2586
 
2511
- event.preventDefault();
2587
+ const patch = {};
2588
+ for (const property in styles) {
2589
+ const oldValue = $getSelectionStyleValueForProperty(selection, property);
2590
+ patch[property] = toggleOrReplace(oldValue, styles[property]);
2591
+ }
2512
2592
 
2513
- this.dragCounter = 0;
2514
- this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
2593
+ if ($selectionIsInCodeBlock(selection)) {
2594
+ $patchCodeHighlightStyles(editor, selection, patch);
2595
+ } else {
2596
+ $patchStyleText(selection, patch);
2597
+ }
2598
+ }
2515
2599
 
2516
- const dataTransfer = event.dataTransfer;
2517
- if (!dataTransfer) return
2600
+ function $selectionIsInCodeBlock(selection) {
2601
+ const nodes = selection.getNodes();
2602
+ return nodes.some((node) => {
2603
+ // A text node inside a code block may be either a CodeHighlightNode
2604
+ // (after retokenization) or a plain TextNode (after splitText or before
2605
+ // the retokenizer has run). Check the parent in both cases.
2606
+ if ($isCodeHighlightNode(node) || $isTextNode(node)) {
2607
+ return $isCodeNode(node.getParent())
2608
+ }
2609
+ return $isCodeNode(node)
2610
+ })
2611
+ }
2518
2612
 
2519
- const files = Array.from(dataTransfer.files);
2520
- if (!files.length) return
2613
+ function $patchCodeHighlightStyles(editor, selection, patch) {
2614
+ // Capture selection state and node keys before the nested update.
2615
+ // Accept both CodeHighlightNode and TextNode children of a CodeNode
2616
+ // because splitText creates TextNode instances and the retokenizer
2617
+ // may not have converted them back to CodeHighlightNodes yet.
2618
+ const nodeKeys = selection.getNodes()
2619
+ .filter((node) => ($isCodeHighlightNode(node) || $isTextNode(node)) && $isCodeNode(node.getParent()))
2620
+ .map((node) => ({
2621
+ key: node.getKey(),
2622
+ startOffset: $getNodeSelectionOffsets(node, selection)[0],
2623
+ endOffset: $getNodeSelectionOffsets(node, selection)[1],
2624
+ textSize: node.getTextContentSize()
2625
+ }));
2521
2626
 
2522
- this.#restoreSelectionBeforeDrag();
2523
- this.contents.uploadFiles(files, { selectLast: true });
2627
+ // Use skipTransforms to prevent the code highlighting system from
2628
+ // re-tokenizing and wiping out the style changes we apply.
2629
+ // Use discrete to force a synchronous commit, ensuring the changes
2630
+ // are committed before editor.focus() triggers a second update cycle
2631
+ // that would re-run transforms and wipe out the styles.
2632
+ editor.update(() => {
2633
+ const affectedCodeNodes = new Set();
2524
2634
 
2525
- this.editor.focus();
2526
- }
2635
+ for (const { key, startOffset, endOffset, textSize } of nodeKeys) {
2636
+ const node = $getNodeByKey(key);
2637
+ if (!node) continue
2527
2638
 
2528
- #saveSelectionBeforeDrag() {
2529
- this.editor.getEditorState().read(() => {
2530
- this.#selectionBeforeDrag = $getSelection()?.clone();
2531
- });
2532
- }
2639
+ const parent = node.getParent();
2640
+ if (!$isCodeNode(parent)) continue
2641
+ if (startOffset === endOffset) continue
2533
2642
 
2534
- #restoreSelectionBeforeDrag() {
2535
- if (!this.#selectionBeforeDrag) return
2643
+ affectedCodeNodes.add(parent);
2536
2644
 
2537
- this.editor.update(() => {
2538
- $setSelection(this.#selectionBeforeDrag);
2539
- });
2645
+ if (startOffset === 0 && endOffset === textSize) {
2646
+ $applyStylePatchToNode(node, patch);
2647
+ } else {
2648
+ const splitNodes = node.splitText(startOffset, endOffset);
2649
+ const targetNode = splitNodes[startOffset === 0 ? 0 : 1];
2650
+ $applyStylePatchToNode(targetNode, patch);
2651
+ }
2652
+ }
2540
2653
 
2541
- this.#selectionBeforeDrag = null;
2542
- }
2654
+ // After applying styles, save highlight ranges for each affected CodeNode.
2655
+ // The code retokenizer will replace the styled nodes with fresh unstyled
2656
+ // tokens when transforms run. The pending highlights are picked up by the
2657
+ // CodeNode mutation listener and reapplied after retokenization.
2658
+ for (const codeNode of affectedCodeNodes) {
2659
+ const ranges = $extractHighlightRangesFromCodeNode(codeNode);
2660
+ if (ranges.length > 0) {
2661
+ $getPendingHighlights(editor).set(codeNode.getKey(), ranges);
2662
+ }
2663
+ }
2664
+ }, { skipTransforms: true, discrete: true });
2665
+ }
2543
2666
 
2544
- #isInternalDrag(event) {
2545
- return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
2546
- }
2667
+ function $getNodeSelectionOffsets(node, selection) {
2668
+ const nodeKey = node.getKey();
2669
+ const anchorKey = selection.anchor.key;
2670
+ const focusKey = selection.focus.key;
2671
+ const textSize = node.getTextContentSize();
2547
2672
 
2548
- #handleTabKey(event) {
2549
- if (this.selection.isInsideList) {
2550
- return this.#handleTabForList(event)
2551
- } else if (this.selection.isInsideCodeBlock) {
2552
- return this.#handleTabForCode()
2553
- }
2554
- return false
2555
- }
2673
+ const isAnchor = nodeKey === anchorKey;
2674
+ const isFocus = nodeKey === focusKey;
2556
2675
 
2557
- #handleTabForList(event) {
2558
- if (event.shiftKey && !this.selection.isIndentedList) return false
2676
+ // Determine if selection is forward or backward
2677
+ const isForward = selection.isBackward() === false;
2559
2678
 
2560
- event.preventDefault();
2561
- const command = event.shiftKey? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND;
2562
- return this.editor.dispatchCommand(command)
2563
- }
2679
+ let start = 0;
2680
+ let end = textSize;
2564
2681
 
2565
- #handleTabForCode() {
2566
- const selection = $getSelection();
2567
- return $isRangeSelection(selection) && selection.isCollapsed()
2682
+ if (isForward) {
2683
+ if (isAnchor) start = selection.anchor.offset;
2684
+ if (isFocus) end = selection.focus.offset;
2685
+ } else {
2686
+ if (isFocus) start = selection.focus.offset;
2687
+ if (isAnchor) end = selection.anchor.offset;
2568
2688
  }
2569
2689
 
2690
+ return [ start, end ]
2570
2691
  }
2571
2692
 
2572
- function capitalize(str) {
2573
- return str.charAt(0).toUpperCase() + str.slice(1)
2574
- }
2575
-
2576
- function debounce(fn, wait) {
2577
- let timeout;
2693
+ function $applyStylePatchToNode(node, patch) {
2694
+ const prevStyles = getStyleObjectFromCSS(node.getStyle());
2695
+ const newStyles = { ...prevStyles };
2578
2696
 
2579
- return (...args) => {
2580
- clearTimeout(timeout);
2581
- timeout = setTimeout(() => fn(...args), wait);
2697
+ for (const [ key, value ] of Object.entries(patch)) {
2698
+ if (value === null) {
2699
+ delete newStyles[key];
2700
+ } else {
2701
+ newStyles[key] = value;
2702
+ }
2582
2703
  }
2583
- }
2584
2704
 
2585
- function debounceAsync(fn, wait) {
2586
- let timeout;
2705
+ const newCSSText = getCSSFromStyleObject(newStyles);
2706
+ node.setStyle(newCSSText);
2587
2707
 
2588
- return (...args) => {
2589
- clearTimeout(timeout);
2708
+ // Sync the highlight format using TextNode's setFormat to bypass
2709
+ // CodeHighlightNode's no-op override
2710
+ const shouldHaveHighlight = hasHighlightStyles(newCSSText);
2711
+ const hasHighlight = node.hasFormat("highlight");
2590
2712
 
2591
- return new Promise((resolve, reject) => {
2592
- timeout = setTimeout(async () => {
2593
- try {
2594
- const result = await fn(...args);
2595
- resolve(result);
2596
- } catch (err) {
2597
- reject(err);
2598
- }
2599
- }, wait);
2600
- })
2713
+ if (shouldHaveHighlight !== hasHighlight) {
2714
+ $setCodeHighlightFormat(node, shouldHaveHighlight);
2601
2715
  }
2602
2716
  }
2603
2717
 
2604
- function delay(ms) {
2605
- return new Promise((resolve) => setTimeout(resolve, ms))
2606
- }
2718
+ function $setCodeHighlightFormat(node, shouldHaveHighlight) {
2719
+ const writable = node.getWritable();
2720
+ const IS_HIGHLIGHT = 1 << 7;
2607
2721
 
2608
- function nextFrame() {
2609
- return new Promise(requestAnimationFrame)
2722
+ if (shouldHaveHighlight) {
2723
+ writable.__format |= IS_HIGHLIGHT;
2724
+ } else {
2725
+ writable.__format &= ~IS_HIGHLIGHT;
2726
+ }
2610
2727
  }
2611
2728
 
2612
- function dasherize(value) {
2613
- return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
2729
+ function toggleOrReplace(oldValue, newValue) {
2730
+ return oldValue === newValue ? null : newValue
2614
2731
  }
2615
2732
 
2616
- function isUrl(string) {
2617
- try {
2618
- new URL(string);
2619
- return true
2620
- } catch {
2621
- return false
2733
+ function $syncHighlightWithStyle(textNode) {
2734
+ if (hasHighlightStyles(textNode.getStyle()) !== textNode.hasFormat("highlight")) {
2735
+ textNode.toggleFormat("highlight");
2622
2736
  }
2623
2737
  }
2624
2738
 
2625
- function normalizeFilteredText(string) {
2626
- return string
2627
- .toLowerCase()
2628
- .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
2629
- }
2630
-
2631
- function filterMatchPosition(text, potentialMatch) {
2632
- const normalizedText = normalizeFilteredText(text);
2633
- const normalizedMatch = normalizeFilteredText(potentialMatch);
2739
+ function $syncHighlightWithCodeHighlightNode(node) {
2740
+ const parent = node.getParent();
2741
+ if (!$isCodeNode(parent)) return
2634
2742
 
2635
- if (!normalizedMatch) return 0
2743
+ const shouldHaveHighlight = hasHighlightStyles(node.getStyle());
2744
+ const hasHighlight = node.hasFormat("highlight");
2636
2745
 
2637
- const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
2638
- return match ? match.index : -1
2746
+ if (shouldHaveHighlight !== hasHighlight) {
2747
+ $setCodeHighlightFormat(node, shouldHaveHighlight);
2748
+ }
2639
2749
  }
2640
2750
 
2641
- function upcaseFirst(string) {
2642
- return string.charAt(0).toUpperCase() + string.slice(1)
2643
- }
2751
+ function $canonicalizePastedStyles(textNode, canonicalizers = []) {
2752
+ if ($hasPastedStyles(textNode)) {
2753
+ $setPastedStyles(textNode, false);
2644
2754
 
2645
- function escapeForRegExp(string) {
2646
- return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2755
+ const canonicalizedCSS = applyCanonicalizers(textNode.getStyle(), canonicalizers);
2756
+ textNode.setStyle(canonicalizedCSS);
2757
+
2758
+ const selection = $getSelection();
2759
+ if (textNode.isSelected(selection)) {
2760
+ selection.setStyle(textNode.getStyle());
2761
+ selection.setFormat(textNode.getFormat());
2762
+ }
2763
+ }
2647
2764
  }
2648
2765
 
2649
- // Parses a value that may arrive as a boolean or as a string (e.g. from DOM
2650
- // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
2651
- function parseBoolean(value) {
2652
- if (typeof value === "string") return value === "true"
2653
- return Boolean(value)
2766
+ function $setPastedStyles(textNode, value = true) {
2767
+ $setState(textNode, hasPastedStylesState, value);
2654
2768
  }
2655
2769
 
2656
- // Payload: Record<nodeKey, { patch?, replace? }>
2657
- // - patch: plain object, shallow-merged into the existing node's properties
2658
- // - replace: a LexicalNode instance that replaces the node
2659
- const REWRITE_HISTORY_COMMAND = createCommand("REWRITE_HISTORY_COMMAND");
2770
+ function $hasPastedStyles(textNode) {
2771
+ return $getState(textNode, hasPastedStylesState)
2772
+ }
2660
2773
 
2661
- class RewritableHistoryExtension extends LexxyExtension {
2662
- #historyState = null
2774
+ const COMMANDS = [
2775
+ "bold",
2776
+ "italic",
2777
+ "strikethrough",
2778
+ "underline",
2779
+ "link",
2780
+ "unlink",
2781
+ "toggleHighlight",
2782
+ "removeHighlight",
2783
+ "setFormatHeadingLarge",
2784
+ "setFormatHeadingMedium",
2785
+ "setFormatHeadingSmall",
2786
+ "setFormatParagraph",
2787
+ "clearFormatting",
2788
+ "insertUnorderedList",
2789
+ "insertOrderedList",
2790
+ "insertQuoteBlock",
2791
+ "insertCodeBlock",
2792
+ "setCodeLanguage",
2793
+ "insertHorizontalDivider",
2794
+ "uploadImage",
2795
+ "uploadFile",
2663
2796
 
2664
- get lexicalExtension() {
2665
- return defineExtension({
2666
- name: "lexxy/rewritable-history",
2667
- dependencies: [ HistoryExtension ],
2668
- register: (editor, _config, state) => {
2669
- const historyOutput = state.getDependency(HistoryExtension).output;
2670
- this.#historyState = historyOutput.historyState.value;
2797
+ "insertTable",
2671
2798
 
2672
- return editor.registerCommand(
2673
- REWRITE_HISTORY_COMMAND,
2674
- (rewrites) => this.#rewriteHistory(rewrites),
2675
- COMMAND_PRIORITY_EDITOR
2676
- )
2677
- }
2678
- })
2679
- }
2799
+ "undo",
2800
+ "redo"
2801
+ ];
2680
2802
 
2681
- get historyState() {
2682
- return this.#historyState
2683
- }
2803
+ class CommandDispatcher {
2804
+ #selectionBeforeDrag = null
2805
+ #listeners = new ListenerBin()
2684
2806
 
2685
- get #allHistoryEntries() {
2686
- const entries = Array.from(this.#historyState.undoStack);
2687
- if (this.#historyState.current) entries.push(this.#historyState.current);
2688
- return entries.concat(this.#historyState.redoStack)
2807
+ static configureFor(editorElement) {
2808
+ return new CommandDispatcher(editorElement)
2689
2809
  }
2690
2810
 
2691
- #rewriteHistory(rewrites) {
2692
- this.#applyRewritesImmediatelyToCurrentState(rewrites);
2693
- this.#applyRewritesToHistory(rewrites);
2811
+ constructor(editorElement) {
2812
+ this.editorElement = editorElement;
2813
+ this.editor = editorElement.editor;
2814
+ this.selection = editorElement.selection;
2815
+ this.contents = editorElement.contents;
2816
+ this.clipboard = editorElement.clipboard;
2694
2817
 
2695
- return true
2818
+ this.#registerCommands();
2819
+ this.#registerKeyboardCommands();
2820
+ this.#registerDragAndDropHandlers();
2696
2821
  }
2697
2822
 
2698
- #applyRewritesImmediatelyToCurrentState(rewrites) {
2699
- $getEditor().update(() => {
2700
- for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
2701
- const node = $getNodeByKey(nodeKey);
2702
- if (!node) continue
2703
-
2704
- if (patch) Object.assign(node.getWritable(), patch);
2705
- if (replace) node.replace(replace);
2706
- }
2707
- }, { discrete: true, tag: this.#getBackgroundUpdateTags() });
2823
+ dispatchPaste(event) {
2824
+ return this.clipboard.paste(event)
2708
2825
  }
2709
2826
 
2710
- #applyRewritesToHistory(rewrites) {
2711
- const nodeKeys = Object.keys(rewrites);
2712
-
2713
- for (const entry of this.#allHistoryEntries) {
2714
- if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue
2715
-
2716
- const editorState = entry.editorState = safeCloneEditorState(entry.editorState);
2717
-
2718
- for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
2719
- const node = editorState._nodeMap.get(nodeKey);
2720
- if (!node) continue
2721
-
2722
- if (patch) {
2723
- this.#patchNodeInEditorState(editorState, node, patch);
2724
- } else if (replace) {
2725
- this.#replaceNodeInEditorState(editorState, node, replace);
2726
- }
2727
- }
2728
- }
2827
+ dispatchBold() {
2828
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
2729
2829
  }
2730
2830
 
2731
- #entryHasSomeKeys(entry, nodeKeys) {
2732
- return nodeKeys.some(key => entry.editorState._nodeMap.has(key))
2831
+ dispatchItalic() {
2832
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
2733
2833
  }
2734
2834
 
2735
- #getBackgroundUpdateTags() {
2736
- const tags = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
2737
- if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG); }
2738
- return tags
2835
+ dispatchStrikethrough() {
2836
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
2739
2837
  }
2740
2838
 
2741
- #patchNodeInEditorState(editorState, node, patch) {
2742
- editorState._nodeMap.set(node.__key, $cloneNodeWithPatch(node, patch));
2839
+ dispatchUnderline() {
2840
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
2743
2841
  }
2744
2842
 
2745
- #replaceNodeInEditorState(editorState, node, replaceWith) {
2746
- editorState._nodeMap.set(node.__key, $cloneNodeAdoptingKeys(replaceWith, node));
2843
+ dispatchToggleHighlight(styles) {
2844
+ this.editor.dispatchCommand(TOGGLE_HIGHLIGHT_COMMAND, styles);
2747
2845
  }
2748
- }
2749
2846
 
2750
- function $cloneNodeWithPatch(node, patch) {
2751
- const clone = $cloneWithProperties(node);
2752
- Object.assign(clone, patch);
2753
- return clone
2754
- }
2847
+ dispatchRemoveHighlight() {
2848
+ this.editor.dispatchCommand(REMOVE_HIGHLIGHT_COMMAND);
2849
+ }
2755
2850
 
2756
- function $cloneNodeAdoptingKeys(node, previousNode) {
2757
- const clone = $cloneWithProperties(node);
2758
- clone.__key = previousNode.__key;
2759
- clone.__parent = previousNode.__parent;
2760
- clone.__prev = previousNode.__prev;
2761
- clone.__next = previousNode.__next;
2762
- return clone
2763
- }
2851
+ dispatchLink(url) {
2852
+ this.editor.update(() => {
2853
+ const selection = $getSelection();
2854
+ if (!$isRangeSelection(selection)) return
2764
2855
 
2765
- // EditorState#clone() keeps the same map reference.
2766
- // A new Map is needed to prevent editing Lexical's internal map
2767
- // Warning: this bypasses DEV's safety map freezing
2768
- function safeCloneEditorState(editorState) {
2769
- const clone = editorState.clone();
2770
- clone._nodeMap = new Map(editorState._nodeMap);
2771
- return clone
2772
- }
2856
+ const anchorNode = selection.anchor.getNode();
2773
2857
 
2774
- class ActionTextAttachmentNode extends DecoratorNode {
2775
- static getType() {
2776
- return "action_text_attachment"
2858
+ if (selection.isCollapsed() && !$getNearestNodeOfType(anchorNode, LinkNode)) {
2859
+ const autoLinkNode = $createAutoLinkNode(url);
2860
+ const textNode = $createTextNode(url);
2861
+ autoLinkNode.append(textNode);
2862
+ selection.insertNodes([ autoLinkNode ]);
2863
+ } else {
2864
+ $toggleLink(url);
2865
+ }
2866
+ });
2777
2867
  }
2778
2868
 
2779
- static clone(node) {
2780
- return new ActionTextAttachmentNode({ ...node }, node.__key)
2869
+ dispatchUnlink() {
2870
+ this.editor.update(() => {
2871
+ // Let adapters signal whether unlink should target a frozen link key.
2872
+ if (this.editorElement.adapter.unlinkFrozenNode?.()) {
2873
+ return
2874
+ }
2875
+
2876
+ $toggleLink(null);
2877
+ });
2781
2878
  }
2782
2879
 
2783
- static importJSON(serializedNode) {
2784
- return new ActionTextAttachmentNode({ ...serializedNode })
2880
+ dispatchInsertUnorderedList() {
2881
+ const selection = $getSelection();
2882
+ if (!$isRangeSelection(selection)) return
2883
+
2884
+ const anchorNode = selection.anchor.getNode();
2885
+
2886
+ if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
2887
+ this.contents.applyParagraphFormat();
2888
+ } else {
2889
+ this.contents.applyUnorderedListFormat();
2890
+ }
2785
2891
  }
2786
2892
 
2787
- static importDOM() {
2788
- return {
2789
- [this.TAG_NAME]: () => {
2790
- return {
2791
- conversion: (attachment) => ({
2792
- node: new ActionTextAttachmentNode({
2793
- sgid: attachment.getAttribute("sgid"),
2794
- src: attachment.getAttribute("url"),
2795
- previewable: attachment.getAttribute("previewable"),
2796
- altText: attachment.getAttribute("alt"),
2797
- caption: attachment.getAttribute("caption"),
2798
- contentType: attachment.getAttribute("content-type"),
2799
- fileName: attachment.getAttribute("filename"),
2800
- fileSize: attachment.getAttribute("filesize"),
2801
- width: attachment.getAttribute("width"),
2802
- height: attachment.getAttribute("height")
2803
- })
2804
- }), priority: 1
2805
- }
2806
- },
2807
- "img": () => {
2808
- return {
2809
- conversion: (img) => {
2810
- const fileName = extractFileName(img.getAttribute("src") ?? "");
2811
- return {
2812
- node: new ActionTextAttachmentNode({
2813
- src: img.getAttribute("src"),
2814
- fileName: fileName,
2815
- caption: img.getAttribute("alt") || "",
2816
- contentType: "image/*",
2817
- width: img.getAttribute("width"),
2818
- height: img.getAttribute("height")
2819
- })
2820
- }
2821
- }, priority: 1
2822
- }
2823
- },
2824
- "video": () => {
2825
- return {
2826
- conversion: (video) => {
2827
- const videoSource = video.getAttribute("src") || video.querySelector("source")?.src;
2828
- const fileName = videoSource?.split("/")?.pop();
2829
- const contentType = video.querySelector("source")?.getAttribute("content-type") || "video/*";
2893
+ dispatchInsertOrderedList() {
2894
+ const selection = $getSelection();
2895
+ if (!$isRangeSelection(selection)) return
2830
2896
 
2831
- return {
2832
- node: new ActionTextAttachmentNode({
2833
- src: videoSource,
2834
- fileName: fileName,
2835
- contentType: contentType
2836
- })
2837
- }
2838
- }, priority: 1
2839
- }
2840
- }
2897
+ const anchorNode = selection.anchor.getNode();
2898
+
2899
+ if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
2900
+ this.contents.applyParagraphFormat();
2901
+ } else {
2902
+ this.contents.applyOrderedListFormat();
2841
2903
  }
2842
2904
  }
2843
2905
 
2844
- static get TAG_NAME() {
2845
- return Lexxy.global.get("attachmentTagName")
2906
+ dispatchInsertQuoteBlock() {
2907
+ this.contents.toggleBlockquote();
2846
2908
  }
2847
2909
 
2848
- constructor({ tagName, sgid, src, previewSrc, previewable, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
2849
- super(key);
2850
-
2851
- this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
2852
- this.sgid = sgid;
2853
- this.src = src;
2854
- this.previewSrc = previewSrc;
2855
- this.previewable = parseBoolean(previewable);
2856
- this.pendingPreview = pendingPreview;
2857
- this.altText = altText || "";
2858
- this.caption = caption || "";
2859
- this.contentType = contentType || "";
2860
- this.fileName = fileName || "";
2861
- this.fileSize = fileSize;
2862
- this.width = width;
2863
- this.height = height;
2864
- this.uploadError = uploadError;
2865
-
2866
- this.editor = $getEditor();
2910
+ dispatchInsertCodeBlock() {
2911
+ if (this.selection.hasSelectedWordsInSingleLine) {
2912
+ this.#toggleInlineCode();
2913
+ } else {
2914
+ this.contents.toggleCodeBlock();
2915
+ }
2867
2916
  }
2868
2917
 
2869
- createDOM() {
2870
- if (this.uploadError) return this.createDOMForError()
2871
- if (this.pendingPreview) return this.#createDOMForPendingPreview()
2918
+ #toggleInlineCode() {
2919
+ const selection = $getSelection();
2920
+ if (!$isRangeSelection(selection)) return
2872
2921
 
2873
- const figure = this.createAttachmentFigure();
2922
+ if (!selection.isCollapsed()) {
2923
+ const textNodes = selection.getNodes().filter($isTextNode);
2924
+ const applyingCode = !textNodes.every((node) => node.hasFormat("code"));
2874
2925
 
2875
- if (this.isPreviewableAttachment) {
2876
- figure.appendChild(this.#createDOMForImage());
2877
- figure.appendChild(this.#createEditableCaption());
2878
- } else if (this.isVideo) {
2879
- figure.appendChild(this.#createDOMForFile());
2880
- figure.appendChild(this.#createEditableCaption());
2881
- } else {
2882
- figure.appendChild(this.#createDOMForFile());
2883
- figure.appendChild(this.#createDOMForNotImage());
2926
+ if (applyingCode) {
2927
+ this.#stripInlineFormattingFromSelection(selection, textNodes);
2928
+ }
2884
2929
  }
2885
2930
 
2886
- return figure
2931
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
2887
2932
  }
2888
2933
 
2889
- updateDOM(prevNode, dom) {
2890
- if (this.uploadError !== prevNode.uploadError) return true
2934
+ // Strip all inline formatting (bold, italic, etc.) from the selected text
2935
+ // nodes so that applying code produces a single merged <code> element instead
2936
+ // of one per differently-formatted span.
2937
+ #stripInlineFormattingFromSelection(selection, textNodes) {
2938
+ const isBackward = selection.isBackward();
2939
+ const startPoint = isBackward ? selection.focus : selection.anchor;
2940
+ const endPoint = isBackward ? selection.anchor : selection.focus;
2891
2941
 
2892
- const caption = dom.querySelector("figcaption textarea");
2893
- if (caption && this.caption) {
2894
- caption.value = this.caption;
2942
+ for (let i = 0; i < textNodes.length; i++) {
2943
+ const node = textNodes[i];
2944
+ if (node.getFormat() === 0) continue
2945
+
2946
+ const isFirst = i === 0;
2947
+ const isLast = i === textNodes.length - 1;
2948
+ const startOffset = isFirst && startPoint.type === "text" ? startPoint.offset : 0;
2949
+ const endOffset = isLast && endPoint.type === "text" ? endPoint.offset : node.getTextContentSize();
2950
+
2951
+ if (startOffset === 0 && endOffset === node.getTextContentSize()) {
2952
+ node.setFormat(0);
2953
+ } else {
2954
+ const splits = node.splitText(startOffset, endOffset);
2955
+ const target = startOffset === 0 ? splits[0] : splits[1];
2956
+ target.setFormat(0);
2957
+
2958
+ if (isFirst && startPoint.type === "text") {
2959
+ startPoint.set(target.getKey(), 0, "text");
2960
+ }
2961
+ if (isLast && endPoint.type === "text") {
2962
+ endPoint.set(target.getKey(), endOffset - startOffset, "text");
2963
+ }
2964
+ }
2895
2965
  }
2966
+ }
2896
2967
 
2897
- return false
2968
+ dispatchSetCodeLanguage(language) {
2969
+ this.editor.update(() => {
2970
+ if (!this.selection.isInsideCodeBlock) return
2971
+
2972
+ const codeNode = this.selection.nearestNodeOfType(CodeNode);
2973
+ if (!codeNode) return
2974
+
2975
+ codeNode.setLanguage(language);
2976
+ });
2898
2977
  }
2899
2978
 
2900
- getTextContent() {
2901
- return `[${this.caption || this.fileName}]\n\n`
2979
+ dispatchInsertHorizontalDivider() {
2980
+ this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
2981
+ this.editor.focus();
2902
2982
  }
2903
2983
 
2904
- isInline() {
2905
- return this.isAttached() && !this.getParent().is($getNearestRootOrShadowRoot(this))
2984
+ dispatchSetFormatHeadingLarge() {
2985
+ this.contents.applyHeadingFormat("h2");
2906
2986
  }
2907
2987
 
2908
- exportDOM() {
2909
- const attachment = createElement(this.tagName, {
2910
- sgid: this.sgid,
2911
- previewable: this.previewable || null,
2912
- url: this.src,
2913
- alt: this.altText,
2914
- caption: this.caption,
2915
- "content-type": this.contentType,
2916
- filename: this.fileName,
2917
- filesize: this.fileSize,
2918
- width: this.width,
2919
- height: this.height,
2920
- presentation: "gallery"
2921
- });
2988
+ dispatchSetFormatHeadingMedium() {
2989
+ this.contents.applyHeadingFormat("h3");
2990
+ }
2922
2991
 
2923
- return { element: attachment }
2992
+ dispatchSetFormatHeadingSmall() {
2993
+ this.contents.applyHeadingFormat("h4");
2924
2994
  }
2925
2995
 
2926
- exportJSON() {
2927
- return {
2928
- type: "action_text_attachment",
2929
- version: 1,
2930
- tagName: this.tagName,
2931
- sgid: this.sgid,
2932
- src: this.src,
2933
- previewable: this.previewable,
2934
- altText: this.altText,
2935
- caption: this.caption,
2936
- contentType: this.contentType,
2937
- fileName: this.fileName,
2938
- fileSize: this.fileSize,
2939
- width: this.width,
2940
- height: this.height
2941
- }
2996
+ dispatchSetFormatParagraph() {
2997
+ this.contents.applyParagraphFormat();
2942
2998
  }
2943
2999
 
2944
- decorate() {
2945
- return null
3000
+ dispatchClearFormatting() {
3001
+ this.contents.clearFormatting();
2946
3002
  }
2947
3003
 
2948
- createDOMForError() {
2949
- const figure = this.createAttachmentFigure();
2950
- figure.classList.add("attachment--error");
2951
- figure.appendChild(createElement("div", { innerText: `Error uploading ${this.fileName || "file"}` }));
2952
- return figure
3004
+ dispatchUploadImage() {
3005
+ this.#dispatchUploadAttachment("image/*,video/*");
2953
3006
  }
2954
3007
 
2955
- createAttachmentFigure(previewable = this.isPreviewableAttachment) {
2956
- const figure = createAttachmentFigure(this.contentType, previewable, this.fileName);
2957
- figure.draggable = true;
2958
- figure.dataset.lexicalNodeKey = this.__key;
3008
+ dispatchUploadFile() {
3009
+ this.#dispatchUploadAttachment();
3010
+ }
2959
3011
 
2960
- const deleteButton = createElement("lexxy-node-delete-button");
2961
- figure.appendChild(deleteButton);
3012
+ #dispatchUploadAttachment(accept = null) {
3013
+ const attributes = {
3014
+ type: "file",
3015
+ multiple: true,
3016
+ style: "display: none;",
3017
+ onchange: ({ target: { files } }) => {
3018
+ this.contents.uploadFiles(files, { selectLast: true });
3019
+ }
3020
+ };
2962
3021
 
2963
- return figure
3022
+ if (accept) attributes.accept = accept;
3023
+
3024
+ const input = createElement("input", attributes);
3025
+
3026
+ // Append and remove to make testable
3027
+ this.editorElement.appendChild(input);
3028
+ input.click();
3029
+ setTimeout(() => input.remove(), 1000);
2964
3030
  }
2965
3031
 
2966
- get isPreviewableAttachment() {
2967
- return this.isPreviewableImage || this.previewable
3032
+ dispatchInsertTable() {
3033
+ this.editor.dispatchCommand(INSERT_TABLE_COMMAND, { "rows": 3, "columns": 3, "includeHeaders": true });
2968
3034
  }
2969
3035
 
2970
- get isPreviewableImage() {
2971
- return isPreviewableImage(this.contentType)
3036
+ dispatchUndo() {
3037
+ this.editor.dispatchCommand(UNDO_COMMAND, undefined);
2972
3038
  }
2973
3039
 
2974
- get isVideo() {
2975
- return this.contentType.startsWith("video/")
3040
+ dispatchRedo() {
3041
+ this.editor.dispatchCommand(REDO_COMMAND, undefined);
2976
3042
  }
2977
3043
 
2978
- #createDOMForPendingPreview() {
2979
- const figure = this.createAttachmentFigure(false);
2980
- figure.appendChild(this.#createDOMForFile());
2981
- figure.appendChild(this.#createDOMForNotImage());
2982
- this.#pollForPreview(figure);
2983
- return figure
3044
+ dispose() {
3045
+ this.#listeners.dispose();
2984
3046
  }
2985
3047
 
2986
- patchAndRewriteHistory(patch) {
2987
- this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
2988
- [this.getKey()]: { patch }
2989
- });
3048
+ #registerCommands() {
3049
+ for (const command of COMMANDS) {
3050
+ const methodName = `dispatch${capitalize(command)}`;
3051
+ this.#registerCommandHandler(command, 0, this[methodName].bind(this));
3052
+ }
3053
+
3054
+ this.#registerCommandHandler(PASTE_COMMAND, COMMAND_PRIORITY_LOW, this.dispatchPaste.bind(this));
2990
3055
  }
2991
3056
 
2992
- replaceAndRewriteHistory(node) {
2993
- this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
2994
- [this.getKey()]: { replace: node }
2995
- });
3057
+ #registerCommandHandler(command, priority, handler) {
3058
+ this.#listeners.track(this.editor.registerCommand(command, handler, priority));
2996
3059
  }
2997
3060
 
2998
- #createDOMForImage(options = {}) {
2999
- const initialSrc = this.previewSrc || this.src;
3000
- const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
3061
+ #registerKeyboardCommands() {
3062
+ this.#registerCommandHandler(KEY_ARROW_RIGHT_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleArrowRightKey.bind(this));
3063
+ this.#registerCommandHandler(KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleTabKey.bind(this));
3064
+ }
3001
3065
 
3002
- if (this.previewable && !this.isPreviewableImage) {
3003
- img.onerror = () => this.#swapPreviewToFileDOM(img);
3004
- }
3066
+ #handleArrowRightKey(event) {
3067
+ const selection = $getSelection();
3068
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
3069
+ if (this.selection.isInsideCodeBlock || !selection.hasFormat("code")) return false
3005
3070
 
3006
- if (this.previewSrc) {
3007
- this.#preloadAndSwapSrc(img);
3071
+ const anchorNode = selection.anchor.getNode();
3072
+ if (!$isTextNode(anchorNode) || selection.anchor.offset !== anchorNode.getTextContentSize()) return false
3073
+ if (anchorNode.getNextSibling() !== null) return false
3074
+
3075
+ event.preventDefault();
3076
+ selection.toggleFormat("code");
3077
+ return true
3078
+ }
3079
+
3080
+ #registerDragAndDropHandlers() {
3081
+ if (this.editorElement.supportsAttachments) {
3082
+ this.dragCounter = 0;
3083
+ const root = this.editor.getRootElement();
3084
+ this.#listeners.track(
3085
+ registerEventListener(root, "dragover", this.#handleDragOver.bind(this)),
3086
+ registerEventListener(root, "drop", this.#handleDrop.bind(this)),
3087
+ registerEventListener(root, "dragenter", this.#handleDragEnter.bind(this)),
3088
+ registerEventListener(root, "dragleave", this.#handleDragLeave.bind(this))
3089
+ );
3008
3090
  }
3009
-
3010
- const container = createElement("div", { className: "attachment__container" });
3011
- container.appendChild(img);
3012
- return container
3013
3091
  }
3014
3092
 
3015
- #preloadAndSwapSrc(img) {
3016
- const previewSrc = this.previewSrc;
3017
- const serverImage = new Image();
3018
-
3019
- serverImage.onload = () => this.#handleImageLoaded(img, previewSrc);
3020
- serverImage.onerror = () => this.#handleImageLoadError(previewSrc);
3021
- serverImage.src = this.src;
3022
- }
3093
+ #handleDragEnter(event) {
3094
+ if (this.#isInternalDrag(event)) return
3023
3095
 
3024
- #handleImageLoaded(img, previewSrc) {
3025
- img.src = this.src;
3026
- this.patchAndRewriteHistory({ previewSrc: null });
3027
- this.#revokePreviewSrc(previewSrc);
3096
+ this.dragCounter++;
3097
+ if (this.dragCounter === 1) {
3098
+ this.#saveSelectionBeforeDrag();
3099
+ this.editor.getRootElement().classList.add("lexxy-editor--drag-over");
3100
+ }
3028
3101
  }
3029
3102
 
3030
- #handleImageLoadError(previewSrc) {
3031
- this.patchAndRewriteHistory({
3032
- previewSrc: null,
3033
- uploadError: true
3034
- });
3035
- this.#revokePreviewSrc(previewSrc);
3036
- }
3103
+ #handleDragLeave(event) {
3104
+ if (this.#isInternalDrag(event)) return
3037
3105
 
3038
- #revokePreviewSrc(previewSrc) {
3039
- if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc);
3106
+ this.dragCounter--;
3107
+ if (this.dragCounter === 0) {
3108
+ this.#selectionBeforeDrag = null;
3109
+ this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
3110
+ }
3040
3111
  }
3041
3112
 
3042
- #swapPreviewToFileDOM(img) {
3043
- const figure = img.closest("figure.attachment");
3044
- if (!figure) return
3113
+ #handleDragOver(event) {
3114
+ if (this.#isInternalDrag(event)) return
3045
3115
 
3046
- this.#swapFigureContent(figure, "attachment--preview", "attachment--file", () => {
3047
- figure.appendChild(this.#createDOMForFile());
3048
- figure.appendChild(this.#createDOMForNotImage());
3049
- });
3116
+ event.preventDefault();
3050
3117
  }
3051
3118
 
3052
- #pollForPreview(figure) {
3053
- let attempt = 0;
3054
- const maxAttempts = 10;
3119
+ #handleDrop(event) {
3120
+ if (this.#isInternalDrag(event)) return
3055
3121
 
3056
- const tryLoad = () => {
3057
- if (!this.editor.read(() => this.isAttached())) return
3122
+ event.preventDefault();
3058
3123
 
3059
- const img = new Image();
3060
- const cacheBustedSrc = `${this.src}${this.src.includes("?") ? "&" : "?"}_=${Date.now()}`;
3124
+ this.dragCounter = 0;
3125
+ this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
3061
3126
 
3062
- img.onload = () => {
3063
- if (!this.editor.read(() => this.isAttached())) return
3127
+ const dataTransfer = event.dataTransfer;
3128
+ if (!dataTransfer) return
3064
3129
 
3065
- // The placeholder is a file-type icon SVG (86×100). A real thumbnail
3066
- // generated from PDF/video content is significantly larger.
3067
- if (img.naturalWidth > 150 && img.naturalHeight > 150) {
3068
- this.#swapToPreviewDOM(figure, cacheBustedSrc);
3069
- } else {
3070
- retry();
3071
- }
3072
- };
3073
- img.onerror = () => retry();
3074
- img.src = cacheBustedSrc;
3075
- };
3130
+ const files = Array.from(dataTransfer.files);
3131
+ if (!files.length) return
3076
3132
 
3077
- const retry = () => {
3078
- attempt++;
3079
- if (attempt < maxAttempts && this.editor.read(() => this.isAttached())) {
3080
- const delay = Math.min(2000 * Math.pow(1.5, attempt), 15000);
3081
- setTimeout(tryLoad, delay);
3082
- }
3083
- };
3133
+ this.#restoreSelectionBeforeDrag();
3134
+ this.contents.uploadFiles(files, { selectLast: true });
3084
3135
 
3085
- // Give the server time to start processing before the first attempt
3086
- setTimeout(tryLoad, 3000);
3136
+ this.editor.focus();
3087
3137
  }
3088
3138
 
3089
- #swapToPreviewDOM(figure, previewSrc) {
3090
- this.#swapFigureContent(figure, "attachment--file", "attachment--preview", () => {
3091
- const img = createElement("img", { src: previewSrc, draggable: false, alt: this.altText });
3092
- img.onerror = () => this.#swapPreviewToFileDOM(img);
3093
- const container = createElement("div", { className: "attachment__container" });
3094
- container.appendChild(img);
3095
- figure.appendChild(container);
3096
- figure.appendChild(this.#createEditableCaption());
3139
+ #saveSelectionBeforeDrag() {
3140
+ this.editor.getEditorState().read(() => {
3141
+ this.#selectionBeforeDrag = $getSelection()?.clone();
3097
3142
  });
3098
-
3099
- this.patchAndRewriteHistory({ pendingPreview: false });
3100
3143
  }
3101
3144
 
3102
- #swapFigureContent(figure, fromClass, toClass, renderContent) {
3103
- figure.className = figure.className.replace(fromClass, toClass);
3145
+ #restoreSelectionBeforeDrag() {
3146
+ if (!this.#selectionBeforeDrag) return
3104
3147
 
3105
- for (const child of [ ...figure.querySelectorAll(".attachment__container, .attachment__icon, figcaption") ]) {
3106
- child.remove();
3107
- }
3148
+ this.editor.update(() => {
3149
+ $setSelection(this.#selectionBeforeDrag);
3150
+ });
3108
3151
 
3109
- renderContent();
3152
+ this.#selectionBeforeDrag = null;
3110
3153
  }
3111
3154
 
3112
- get #imageDimensions() {
3113
- if (this.width && this.height) {
3114
- return { width: this.width, height: this.height }
3115
- } else {
3116
- return {}
3117
- }
3155
+ #isInternalDrag(event) {
3156
+ return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
3118
3157
  }
3119
3158
 
3120
- #createDOMForFile() {
3121
- const extension = this.fileName ? this.fileName.split(".").pop().toLowerCase() : "unknown";
3122
- return createElement("span", { className: "attachment__icon", textContent: `${extension}` })
3159
+ #handleTabKey(event) {
3160
+ if (this.selection.isInsideList) {
3161
+ return this.#handleTabForList(event)
3162
+ } else if (this.selection.isInsideCodeBlock) {
3163
+ return this.#handleTabForCode()
3164
+ }
3165
+ return false
3123
3166
  }
3124
3167
 
3125
- #createDOMForNotImage() {
3126
- const figcaption = createElement("figcaption", { className: "attachment__caption" });
3127
-
3128
- const nameTag = createElement("strong", { className: "attachment__name", textContent: this.caption || this.fileName });
3129
-
3130
- figcaption.appendChild(nameTag);
3131
-
3132
- if (this.fileSize) {
3133
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.fileSize) });
3134
- figcaption.appendChild(sizeSpan);
3135
- }
3168
+ #handleTabForList(event) {
3169
+ if (event.shiftKey && !this.selection.isIndentedList) return false
3136
3170
 
3137
- return figcaption
3171
+ event.preventDefault();
3172
+ const command = event.shiftKey? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND;
3173
+ return this.editor.dispatchCommand(command)
3138
3174
  }
3139
3175
 
3140
- #createEditableCaption() {
3141
- const caption = createElement("figcaption", { className: "attachment__caption" });
3142
- const input = createElement("textarea", {
3143
- value: this.caption,
3144
- placeholder: this.fileName,
3145
- rows: "1"
3146
- });
3147
-
3148
- input.addEventListener("focusin", () => input.placeholder = "Add caption...");
3149
- input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
3150
- input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
3151
- input.addEventListener("copy", (event) => event.stopPropagation());
3152
- input.addEventListener("cut", (event) => event.stopPropagation());
3153
- input.addEventListener("paste", (event) => event.stopPropagation());
3176
+ #handleTabForCode() {
3177
+ const selection = $getSelection();
3178
+ return $isRangeSelection(selection) && selection.isCollapsed()
3179
+ }
3154
3180
 
3155
- caption.appendChild(input);
3181
+ }
3156
3182
 
3157
- return caption
3158
- }
3183
+ function capitalize(str) {
3184
+ return str.charAt(0).toUpperCase() + str.slice(1)
3185
+ }
3159
3186
 
3160
- #handleCaptionInputBlurred(event) {
3161
- this.#updateCaptionValueFromInput(event.target);
3162
- }
3187
+ function debounce(fn, wait) {
3188
+ let timeout;
3163
3189
 
3164
- #updateCaptionValueFromInput(input) {
3165
- input.placeholder = this.fileName;
3166
- this.editor.update(() => {
3167
- this.getWritable().caption = input.value;
3168
- });
3190
+ return (...args) => {
3191
+ clearTimeout(timeout);
3192
+ timeout = setTimeout(() => fn(...args), wait);
3169
3193
  }
3194
+ }
3170
3195
 
3171
- #handleCaptionInputKeydown(event) {
3172
- if (event.key === "Enter") {
3173
- event.preventDefault();
3174
- event.target.blur();
3196
+ function debounceAsync(fn, wait) {
3197
+ let timeout;
3175
3198
 
3176
- this.editor.update(() => {
3177
- // Place the cursor after the current image
3178
- this.selectNext(0, 0);
3179
- }, {
3180
- tag: HISTORY_MERGE_TAG
3181
- });
3182
- }
3199
+ return (...args) => {
3200
+ clearTimeout(timeout);
3183
3201
 
3184
- // Stop all keydown events from bubbling to the Lexical root element.
3185
- // The caption textarea is outside Lexical's content model and should
3186
- // handle its own keyboard events natively (Ctrl+A, Ctrl+C, Ctrl+X, etc.).
3187
- event.stopPropagation();
3202
+ return new Promise((resolve, reject) => {
3203
+ timeout = setTimeout(async () => {
3204
+ try {
3205
+ const result = await fn(...args);
3206
+ resolve(result);
3207
+ } catch (err) {
3208
+ reject(err);
3209
+ }
3210
+ }, wait);
3211
+ })
3188
3212
  }
3189
3213
  }
3190
3214
 
3191
- function $createActionTextAttachmentNode(...args) {
3192
- return new ActionTextAttachmentNode(...args)
3215
+ function delay(ms) {
3216
+ return new Promise((resolve) => setTimeout(resolve, ms))
3193
3217
  }
3194
3218
 
3195
- function $isActionTextAttachmentNode(node) {
3196
- return node instanceof ActionTextAttachmentNode
3219
+ function nextFrame() {
3220
+ return new Promise(requestAnimationFrame)
3197
3221
  }
3198
3222
 
3199
3223
  class Selection {
@@ -4782,7 +4806,7 @@ class Contents {
4782
4806
  this.editor.update(() => {
4783
4807
  if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4784
4808
 
4785
- const nodes = $generateNodesFromDOM(this.editor, doc);
4809
+ const nodes = $generateFilteredNodesFromDOM(this.editorElement, doc);
4786
4810
  if (!this.#insertUploadNodes(nodes)) {
4787
4811
  this.insertAtCursor(...nodes);
4788
4812
  }
@@ -5331,16 +5355,19 @@ class Contents {
5331
5355
 
5332
5356
  #createCustomAttachmentNodeWithHtml(html, options = {}) {
5333
5357
  const attachmentConfig = typeof options === "object" ? options : {};
5334
-
5358
+ const contentType = attachmentConfig.contentType || "text/html";
5359
+ if (!this.editorElement.permitsAttachmentContentType(contentType)) {
5360
+ return this.#createHtmlNodeWith(html)
5361
+ }
5335
5362
  return new CustomActionTextAttachmentNode({
5336
5363
  sgid: attachmentConfig.sgid || null,
5337
- contentType: "text/html",
5338
- innerHtml: html
5364
+ contentType,
5365
+ innerHtml: html,
5339
5366
  })
5340
5367
  }
5341
5368
 
5342
5369
  #createHtmlNodeWith(html) {
5343
- const htmlNodes = $generateNodesFromDOM(this.editor, parseHtml(html));
5370
+ const htmlNodes = $generateFilteredNodesFromDOM(this.editorElement, parseHtml(html));
5344
5371
  return htmlNodes[0] || $createParagraphNode()
5345
5372
  }
5346
5373
 
@@ -6920,6 +6947,25 @@ class LexicalEditorElement extends HTMLElement {
6920
6947
  return this.dataset.blobUrlTemplate
6921
6948
  }
6922
6949
 
6950
+ get permittedAttachmentTypes() {
6951
+ const raw = this.config.get("permittedAttachmentTypes");
6952
+ if (raw == null) {
6953
+ return null
6954
+ } else {
6955
+ const tokens = Array.isArray(raw) ? raw : String(raw).split(/\s+/);
6956
+ return Object.freeze(tokens.filter(t => t && t !== "false"))
6957
+ }
6958
+ }
6959
+
6960
+ permitsAttachmentContentType(contentType) {
6961
+ if (!this.supportsAttachments) {
6962
+ return false
6963
+ } else {
6964
+ const list = this.permittedAttachmentTypes;
6965
+ return list === null || list.includes(contentType)
6966
+ }
6967
+ }
6968
+
6923
6969
  get isEmpty() {
6924
6970
  return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
6925
6971
  }
@@ -7042,7 +7088,7 @@ class LexicalEditorElement extends HTMLElement {
7042
7088
 
7043
7089
  #parseHtmlIntoLexicalNodes(html) {
7044
7090
  if (!html) html = "<p></p>";
7045
- const nodes = $generateNodesFromDOM(this.editor, parseHtml(`${html}`));
7091
+ const nodes = $generateFilteredNodesFromDOM(this, parseHtml(`${html}`));
7046
7092
 
7047
7093
  return nodes
7048
7094
  .filter(this.#isNotWhitespaceOnlyNode)
@@ -7074,6 +7120,7 @@ class LexicalEditorElement extends HTMLElement {
7074
7120
  this.#handleEnter();
7075
7121
  this.#registerFocusEvents();
7076
7122
  this.#registerHistoryEvents();
7123
+ this.#registerFileAcceptFilter();
7077
7124
  this.#attachDebugHooks();
7078
7125
  this.#attachToolbar();
7079
7126
  this.#configureSanitizer();
@@ -7081,6 +7128,16 @@ class LexicalEditorElement extends HTMLElement {
7081
7128
  this.#resetBeforeTurboCaches();
7082
7129
  }
7083
7130
 
7131
+ #registerFileAcceptFilter() {
7132
+ this.#listeners.track(
7133
+ registerEventListener(this, "lexxy:file-accept", (event) => {
7134
+ if (!this.permitsAttachmentContentType(event.detail.file.type)) {
7135
+ event.preventDefault();
7136
+ }
7137
+ })
7138
+ );
7139
+ }
7140
+
7084
7141
  #createEditor() {
7085
7142
  this.editorContentElement ||= this.#createEditorContentElement();
7086
7143
  this.appendChild(this.editorContentElement);
@@ -8024,6 +8081,8 @@ class LexicalPromptElement extends HTMLElement {
8024
8081
  }
8025
8082
 
8026
8083
  #addTriggerListener() {
8084
+ if (!this.#promptContentTypePermitted) return
8085
+
8027
8086
  this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => {
8028
8087
  editorState.read(() => {
8029
8088
  if (this.#selection.isInsideCodeBlock) return
@@ -8057,6 +8116,19 @@ class LexicalPromptElement extends HTMLElement {
8057
8116
  }));
8058
8117
  }
8059
8118
 
8119
+ get #promptContentTypePermitted() {
8120
+ const el = this.#editorElement;
8121
+ if (!el.supportsAttachments) {
8122
+ return false
8123
+ } else {
8124
+ const templates = Array.from(this.querySelectorAll("template[type='editor']"));
8125
+ const types = templates.length
8126
+ ? templates.map(t => t.getAttribute("content-type") || this.#defaultPromptContentType)
8127
+ : [ this.#defaultPromptContentType ];
8128
+ return types.some(t => el.permitsAttachmentContentType(t))
8129
+ }
8130
+ }
8131
+
8060
8132
  #addCursorPositionListener() {
8061
8133
  this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => {
8062
8134
  if (this.closed) return
@@ -8363,7 +8435,7 @@ class LexicalPromptElement extends HTMLElement {
8363
8435
  }
8364
8436
 
8365
8437
  #buildEditableTextNodes(template) {
8366
- return $generateNodesFromDOM(this.#editor, parseHtml(`${template.innerHTML}`))
8438
+ return $generateFilteredNodesFromDOM(this.#editorElement, parseHtml(`${template.innerHTML}`))
8367
8439
  }
8368
8440
 
8369
8441
  #insertTemplatesAsAttachments(templates, stringToReplace, fallbackSgid = null) {
@@ -8375,8 +8447,10 @@ class LexicalPromptElement extends HTMLElement {
8375
8447
  }
8376
8448
 
8377
8449
  #buildAttachmentNodes(templates, fallbackSgid = null) {
8378
- return templates.map(
8379
- template => this.#buildAttachmentNode(
8450
+ return templates
8451
+ .filter(template => this.#editorElement.permitsAttachmentContentType(
8452
+ template.getAttribute("content-type") || this.#defaultPromptContentType))
8453
+ .map(template => this.#buildAttachmentNode(
8380
8454
  template.innerHTML,
8381
8455
  template.getAttribute("content-type") || this.#defaultPromptContentType,
8382
8456
  template.getAttribute("sgid") || fallbackSgid