@37signals/lexxy 0.9.9-beta.preview4 → 0.9.9-beta.preview6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lexxy.esm.js CHANGED
@@ -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
- import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $ensureForwardRangeSelection, $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, 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, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, $isParagraphNode, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, $findMatchingParent, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
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, $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
- import { RichTextExtension, $isQuoteNode, $isHeadingNode, QuoteNode, $createHeadingNode, $createQuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
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",
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
1040
1210
  }
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
1211
  }
1094
- };
1212
+ return true
1213
+ }
1095
1214
 
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);
1101
- }
1215
+ function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1216
+ return $isTextNode(node)
1217
+ && node.getTextContent() === " "
1218
+ && index === childCount - 1
1219
+ && previousNode instanceof CustomActionTextAttachmentNode
1220
+ }
1221
+
1222
+ function $splitParagraphsAtLineBreakBoundaries(selection) {
1223
+ $ensureForwardRangeSelection(selection);
1224
+
1225
+ // Split focus first so the anchor split position stays valid.
1226
+ $splitAtNearestLineBreak(selection.focus, "next");
1227
+ $splitAtNearestLineBreak(selection.anchor, "previous");
1228
+ }
1229
+
1230
+ function $splitAtNearestLineBreak(point, direction) {
1231
+ const paragraph = point.getNode().getTopLevelElement();
1232
+ if (!paragraph || !$isParagraphNode(paragraph)) return
1233
+
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
1238
+
1239
+ const lineBreak = lineBreakCaret.origin;
1240
+ const isEdge = lineBreakCaret.getNodeAtCaret() === null;
1241
+
1242
+ if (!isEdge) {
1243
+ $splitNode(paragraph, lineBreak.getIndexWithinParent());
1102
1244
  }
1103
1245
 
1104
- return result
1246
+ lineBreak.remove();
1105
1247
  }
1106
1248
 
1107
- function arePlainHashes(...values) {
1108
- return values.every(value => value && value.constructor == Object)
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
1109
1254
  }
1110
1255
 
1111
- class Configuration {
1112
- #tree = {}
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");
1113
1260
 
1114
- constructor(...configs) {
1115
- this.merge(...configs);
1261
+ class RewritableHistoryExtension extends LexxyExtension {
1262
+ #historyState = null
1263
+
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
+ })
1116
1279
  }
1117
1280
 
1118
- merge(...configs) {
1119
- return this.#tree = configs.reduce(deepMerge, this.#tree)
1281
+ get historyState() {
1282
+ return this.#historyState
1120
1283
  }
1121
1284
 
1122
- get(path) {
1123
- const keys = path.split(".");
1124
- return keys.reduce((node, key) => node[key], this.#tree)
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)
1125
1289
  }
1126
- }
1127
1290
 
1128
- function range(from, to) {
1129
- return [ ...Array(1 + to - from).keys() ].map(i => i + from)
1130
- }
1291
+ #rewriteHistory(rewrites) {
1292
+ this.#applyRewritesImmediatelyToCurrentState(rewrites);
1293
+ this.#applyRewritesToHistory(rewrites);
1131
1294
 
1132
- const global = new Configuration({
1133
- attachmentTagName: "action-text-attachment",
1134
- attachmentContentTypeNamespace: "actiontext",
1135
- authenticatedUploads: false,
1136
- extensions: []
1137
- });
1295
+ return true
1296
+ }
1138
1297
 
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": []
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
1303
+
1304
+ if (patch) Object.assign(node.getWritable(), patch);
1305
+ if (replace) node.replace(replace);
1156
1306
  }
1157
- }
1307
+ }, { discrete: true, tag: this.#getBackgroundUpdateTags() });
1158
1308
  }
1159
- });
1160
1309
 
1161
- var Lexxy = {
1162
- global,
1163
- presets,
1164
- configure({ global: newGlobal, ...newPresets }) {
1165
- if (newGlobal) {
1166
- global.merge(newGlobal);
1310
+ #applyRewritesToHistory(rewrites) {
1311
+ const nodeKeys = Object.keys(rewrites);
1312
+
1313
+ for (const entry of this.#allHistoryEntries) {
1314
+ if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue
1315
+
1316
+ const editorState = entry.editorState = safeCloneEditorState(entry.editorState);
1317
+
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
+ }
1167
1328
  }
1168
- presets.merge(newPresets);
1169
1329
  }
1170
- };
1171
1330
 
1172
- function setSanitizerConfig(allowedTags) {
1173
- DOMPurify.clearConfig();
1174
- DOMPurify.setConfig(buildConfig(allowedTags));
1175
- }
1331
+ #entryHasSomeKeys(entry, nodeKeys) {
1332
+ return nodeKeys.some(key => entry.editorState._nodeMap.has(key))
1333
+ }
1176
1334
 
1177
- function sanitize(html) {
1178
- return DOMPurify.sanitize(html)
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
+ }
1179
1348
  }
1180
1349
 
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] }`
1350
+ function $cloneNodeWithPatch(node, patch) {
1351
+ const clone = $cloneWithProperties(node);
1352
+ Object.assign(clone, patch);
1353
+ return clone
1187
1354
  }
1188
1355
 
1189
- function extractFileName(string) {
1190
- return string.split("/").pop()
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
1191
1363
  }
1192
1364
 
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
1200
- }
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
1201
1372
  }
1202
1373
 
1203
- class CustomActionTextAttachmentNode extends DecoratorNode {
1374
+ class ActionTextAttachmentNode extends DecoratorNode {
1204
1375
  static getType() {
1205
- return "custom_action_text_attachment"
1376
+ return "action_text_attachment"
1206
1377
  }
1207
1378
 
1208
1379
  static clone(node) {
1209
- return new CustomActionTextAttachmentNode({ ...node }, node.__key)
1380
+ return new ActionTextAttachmentNode({ ...node }, node.__key)
1210
1381
  }
1211
1382
 
1212
1383
  static importJSON(serializedNode) {
1213
- return new CustomActionTextAttachmentNode({ ...serializedNode })
1384
+ return new ActionTextAttachmentNode({ ...serializedNode })
1214
1385
  }
1215
1386
 
1216
1387
  static importDOM() {
1217
1388
  return {
1218
- [this.TAG_NAME]: (element) => {
1219
- if (!element.getAttribute("content")) {
1220
- return null
1221
- }
1222
-
1389
+ [this.TAG_NAME]: () => {
1223
1390
  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
- }
1231
-
1232
- const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
1233
-
1234
- nodes.push(new CustomActionTextAttachmentNode({
1391
+ conversion: (attachment) => ({
1392
+ node: new ActionTextAttachmentNode({
1235
1393
  sgid: attachment.getAttribute("sgid"),
1236
- innerHtml,
1237
- plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
1238
- contentType: attachment.getAttribute("content-type")
1239
- }));
1240
-
1241
- const nextSibling = attachment.nextSibling;
1242
- if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && /^\s/.test(nextSibling.textContent)) {
1243
- nodes.push($createTextNode(" "));
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
+ })
1244
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/*";
1245
1430
 
1246
- return { node: nodes }
1247
- },
1248
- priority: 2
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,1860 +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
1547
 
1324
- function $createNodeSelectionWith(...nodes) {
1325
- const selection = $createNodeSelection();
1326
- nodes.forEach(node => selection.add(node.getKey()));
1327
- return selection
1328
- }
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
1553
+ }
1329
1554
 
1330
- function $isShadowRoot(node) {
1331
- return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
1332
- }
1555
+ createAttachmentFigure(previewable = this.isPreviewableAttachment) {
1556
+ const figure = createAttachmentFigure(this.contentType, previewable, this.fileName);
1557
+ figure.draggable = true;
1558
+ figure.dataset.lexicalNodeKey = this.__key;
1333
1559
 
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
1560
+ const deleteButton = createElement("lexxy-node-delete-button");
1561
+ figure.appendChild(deleteButton);
1562
+
1563
+ return figure
1342
1564
  }
1343
- }
1344
1565
 
1345
- function getListType(node) {
1346
- const list = $getNearestNodeOfType(node, ListNode);
1347
- return list?.getListType() ?? null
1348
- }
1566
+ get isPreviewableAttachment() {
1567
+ return this.isPreviewableImage || this.previewable
1568
+ }
1349
1569
 
1350
- function isEditorFocused(editor) {
1351
- const rootElement = editor.getRootElement();
1352
- return rootElement !== null && rootElement.contains(document.activeElement)
1353
- }
1570
+ get isPreviewableImage() {
1571
+ return isPreviewableImage(this.contentType)
1572
+ }
1354
1573
 
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)
1574
+ get isVideo() {
1575
+ return this.contentType.startsWith("video/")
1360
1576
  }
1361
- }
1362
1577
 
1363
- function $isAtNodeStart(point) {
1364
- return point.offset === 0
1365
- }
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
+ }
1366
1585
 
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);
1586
+ patchAndRewriteHistory(patch) {
1587
+ this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
1588
+ [this.getKey()]: { patch }
1589
+ });
1590
+ }
1373
1591
 
1592
+ replaceAndRewriteHistory(node) {
1593
+ this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
1594
+ [this.getKey()]: { replace: node }
1595
+ });
1596
+ }
1374
1597
 
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
- }
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 });
1385
1601
 
1386
- function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
1387
- return (element) => {
1388
- const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
1389
- if (!converter) return null
1602
+ if (this.previewable && !this.isPreviewableImage) {
1603
+ img.onerror = () => this.#swapPreviewToFileDOM(img);
1604
+ }
1390
1605
 
1391
- const conversionOutput = converter.conversion(element);
1392
- if (!conversionOutput) return conversionOutput
1606
+ if (this.previewSrc) {
1607
+ this.#preloadAndSwapSrc(img);
1608
+ }
1393
1609
 
1394
- return callback(conversionOutput, element) ?? conversionOutput
1610
+ const container = createElement("div", { className: "attachment__container" });
1611
+ container.appendChild(img);
1612
+ return container
1395
1613
  }
1396
- }
1397
1614
 
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
1403
-
1404
- const lastChild = children[children.length - 1];
1405
-
1406
- if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
1407
- if (anchorNode === lastChild) return true
1408
-
1409
- const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
1410
- if (lastLineBreakIndex === -1) return true
1411
-
1412
- const anchorIndex = children.indexOf(anchorNode);
1413
- return anchorIndex > lastLineBreakIndex
1414
- }
1615
+ #preloadAndSwapSrc(img) {
1616
+ const previewSrc = this.previewSrc;
1617
+ const serverImage = new Image();
1415
1618
 
1416
- function $isBlankNode(node) {
1417
- if (node.getTextContent().trim() !== "") return false
1619
+ serverImage.onload = () => this.#handleImageLoaded(img, previewSrc);
1620
+ serverImage.onerror = () => this.#handleImageLoadError(previewSrc);
1621
+ serverImage.src = this.src;
1622
+ }
1418
1623
 
1419
- const children = node.getChildren?.();
1420
- if (!children || children.length === 0) return true
1624
+ #handleImageLoaded(img, previewSrc) {
1625
+ img.src = this.src;
1626
+ this.patchAndRewriteHistory({ previewSrc: null });
1627
+ this.#revokePreviewSrc(previewSrc);
1628
+ }
1421
1629
 
1422
- return children.every(child => {
1423
- if ($isLineBreakNode(child)) return true
1424
- return $isBlankNode(child)
1425
- })
1426
- }
1630
+ #handleImageLoadError(previewSrc) {
1631
+ this.patchAndRewriteHistory({
1632
+ previewSrc: null,
1633
+ uploadError: true
1634
+ });
1635
+ this.#revokePreviewSrc(previewSrc);
1636
+ }
1427
1637
 
1428
- function $trimTrailingBlankNodes(parent) {
1429
- for (const child of $lastToFirstIterator(parent)) {
1430
- if ($isBlankNode(child)) {
1431
- child.remove();
1432
- } else {
1433
- break
1434
- }
1638
+ #revokePreviewSrc(previewSrc) {
1639
+ if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc);
1435
1640
  }
1436
- }
1437
1641
 
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
- }
1642
+ #swapPreviewToFileDOM(img) {
1643
+ const figure = img.closest("figure.attachment");
1644
+ if (!figure) return
1645
+
1646
+ this.#swapFigureContent(figure, "attachment--preview", "attachment--file", () => {
1647
+ figure.appendChild(this.#createDOMForFile());
1648
+ figure.appendChild(this.#createDOMForNotImage());
1649
+ });
1452
1650
  }
1453
- return true
1454
- }
1455
1651
 
1456
- function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1457
- return $isTextNode(node)
1458
- && node.getTextContent() === " "
1459
- && index === childCount - 1
1460
- && previousNode instanceof CustomActionTextAttachmentNode
1461
- }
1652
+ #pollForPreview(figure) {
1653
+ let attempt = 0;
1654
+ const maxAttempts = 10;
1462
1655
 
1463
- // Shared, strictly-contained element used to attach ephemeral nodes when we
1464
- // need to read computed styles (e.g. canonicalizing style values, resolving
1465
- // CSS custom properties). The container is created once and attached to
1466
- // `document.body` once; subsequent child mutations happen *inside* the
1467
- // contained subtree so they do not invalidate style on the rest of the page.
1468
- //
1469
- // Without this, `document.body.appendChild(...)` / `element.remove()` calls
1470
- // forced the browser to re-evaluate every ancestor-dependent selector (`:has()`,
1471
- // descendant combinators, universal sibling rules) across the document on each
1472
- // invocation — a 13,000+ element style recalc per call on a typical Basecamp
1473
- // page.
1656
+ const tryLoad = () => {
1657
+ if (!this.editor.read(() => this.isAttached())) return
1474
1658
 
1475
- let resolverRoot = null;
1659
+ const img = new Image();
1660
+ const cacheBustedSrc = `${this.src}${this.src.includes("?") ? "&" : "?"}_=${Date.now()}`;
1476
1661
 
1477
- function styleResolverRoot() {
1478
- if (resolverRoot && resolverRoot.isConnected) return resolverRoot
1662
+ img.onload = () => {
1663
+ if (!this.editor.read(() => this.isAttached())) return
1479
1664
 
1480
- resolverRoot = document.createElement("div");
1481
- resolverRoot.setAttribute("aria-hidden", "true");
1482
- resolverRoot.setAttribute("data-lexxy-style-resolver", "");
1483
- // `contain: strict` (size, layout, paint, style) isolates everything.
1484
- // The root itself paints nothing (visibility hidden), has zero
1485
- // geometric impact (position fixed, intrinsic size via contain), and
1486
- // never leaks style invalidation to its ancestors.
1487
- resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;";
1488
- document.body.appendChild(resolverRoot);
1489
- return resolverRoot
1490
- }
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
+ };
1491
1676
 
1492
- function isSelectionHighlighted(selection) {
1493
- if (!$isRangeSelection(selection)) return false
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
+ };
1494
1684
 
1495
- if (selection.isCollapsed()) {
1496
- return hasHighlightStyles(selection.style)
1497
- } else {
1498
- return selection.hasFormat("highlight")
1685
+ // Give the server time to start processing before the first attempt
1686
+ setTimeout(tryLoad, 3000);
1499
1687
  }
1500
- }
1501
1688
 
1502
- function getHighlightStyles(selection) {
1503
- if (!$isRangeSelection(selection)) return 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
+ });
1504
1698
 
1505
- let styles = getStyleObjectFromCSS(selection.style);
1506
- if (!styles.color && !styles["background-color"]) {
1507
- const anchorNode = selection.anchor.getNode();
1508
- if ($isTextNode(anchorNode)) {
1509
- styles = getStyleObjectFromCSS(anchorNode.getStyle());
1510
- }
1699
+ this.patchAndRewriteHistory({ pendingPreview: false });
1511
1700
  }
1512
1701
 
1513
- const color = styles.color || null;
1514
- const backgroundColor = styles["background-color"] || null;
1515
- if (!color && !backgroundColor) return null
1702
+ #swapFigureContent(figure, fromClass, toClass, renderContent) {
1703
+ figure.className = figure.className.replace(fromClass, toClass);
1516
1704
 
1517
- return { color, backgroundColor }
1518
- }
1705
+ for (const child of [ ...figure.querySelectorAll(".attachment__container, .attachment__icon, figcaption") ]) {
1706
+ child.remove();
1707
+ }
1519
1708
 
1520
- function hasHighlightStyles(cssOrStyles) {
1521
- const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
1522
- return !!(styles.color || styles["background-color"])
1523
- }
1709
+ renderContent();
1710
+ }
1524
1711
 
1525
- function applyCanonicalizers(styles, canonicalizers = []) {
1526
- return canonicalizers.reduce((css, canonicalizer) => {
1527
- return canonicalizer.applyCanonicalization(css)
1528
- }, styles)
1529
- }
1712
+ get #imageDimensions() {
1713
+ if (this.width && this.height) {
1714
+ return { width: this.width, height: this.height }
1715
+ } else {
1716
+ return {}
1717
+ }
1718
+ }
1530
1719
 
1531
- class StyleCanonicalizer {
1532
- constructor(property, allowedValues= []) {
1533
- this._property = property;
1534
- this._allowedValues = allowedValues;
1535
- this._canonicalValues = this.#allowedValuesIdentityObject;
1720
+ #createDOMForFile() {
1721
+ const extension = this.fileName ? this.fileName.split(".").pop().toLowerCase() : "unknown";
1722
+ return createElement("span", { className: "attachment__icon", textContent: `${extension}` })
1536
1723
  }
1537
1724
 
1538
- applyCanonicalization(css) {
1539
- const styles = { ...getStyleObjectFromCSS(css) };
1725
+ #createDOMForNotImage() {
1726
+ const figcaption = createElement("figcaption", { className: "attachment__caption" });
1540
1727
 
1541
- styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
1542
- if (!styles[this._property]) {
1543
- delete styles[this._property];
1728
+ const nameTag = createElement("strong", { className: "attachment__name", textContent: this.caption || this.fileName });
1729
+
1730
+ figcaption.appendChild(nameTag);
1731
+
1732
+ if (this.fileSize) {
1733
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.fileSize) });
1734
+ figcaption.appendChild(sizeSpan);
1544
1735
  }
1545
1736
 
1546
- return getCSSFromStyleObject(styles)
1737
+ return figcaption
1547
1738
  }
1548
1739
 
1549
- getCanonicalAllowedValue(value) {
1550
- return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
1551
- }
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
+ });
1552
1747
 
1553
- // 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());
1554
1754
 
1555
- get #allowedValuesIdentityObject() {
1556
- return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
1755
+ caption.appendChild(input);
1756
+
1757
+ return caption
1557
1758
  }
1558
1759
 
1559
- #resolveCannonicalValue(value) {
1560
- let index = this.#computedAllowedValues.indexOf(value);
1561
- if (index === -1) {
1562
- index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
1563
- }
1564
- return index === -1 ? null : this._allowedValues[index]
1760
+ #handleCaptionInputBlurred(event) {
1761
+ this.#updateCaptionValueFromInput(event.target);
1565
1762
  }
1566
1763
 
1567
- get #computedAllowedValues() {
1568
- 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
+ });
1569
1769
  }
1570
- }
1571
1770
 
1572
- // Separates DOM writes from layout reads to avoid forced reflows, and attaches
1573
- // resolver elements to a strictly-contained root (outside the normal document
1574
- // flow) so neither the attach nor the detach invalidate styles on the rest of
1575
- // the page. Without containment, appending to `document.body` triggered a
1576
- // page-wide style recalc on every canonicalization pass.
1577
- function computeStyleValues(property, values) {
1578
- const fragment = document.createDocumentFragment();
1771
+ #handleCaptionInputKeydown(event) {
1772
+ if (event.key === "Enter") {
1773
+ event.preventDefault();
1774
+ event.target.blur();
1579
1775
 
1580
- const elements = values.map(value => {
1581
- const element = createElement("span", { style: `display: none; ${property}: ${value};` });
1582
- fragment.appendChild(element);
1583
- return element
1584
- });
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
+ }
1585
1783
 
1586
- 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
+ }
1587
1790
 
1588
- const computed = elements.map(element =>
1589
- window.getComputedStyle(element).getPropertyValue(property)
1590
- );
1791
+ function $createActionTextAttachmentNode(...args) {
1792
+ return new ActionTextAttachmentNode(...args)
1793
+ }
1591
1794
 
1592
- elements.forEach(element => element.remove());
1593
- return computed
1795
+ function $isActionTextAttachmentNode(node) {
1796
+ return node instanceof ActionTextAttachmentNode
1594
1797
  }
1595
1798
 
1596
- class LexxyExtension {
1597
- #editorElement
1799
+ function $generateFilteredNodesFromDOM(editorElement, doc) {
1800
+ const nodes = $generateNodesFromDOM(editorElement.editor, doc);
1801
+ return filterDisallowedAttachmentNodes(nodes, editorElement)
1802
+ }
1598
1803
 
1599
- constructor(editorElement) {
1600
- this.#editorElement = editorElement;
1601
- }
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
+ }
1602
1813
 
1603
- get editorElement() {
1604
- return this.#editorElement
1605
- }
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
+ }
1606
1821
 
1607
- get editorConfig() {
1608
- return this.#editorElement.config
1822
+ class HorizontalDividerNode extends DecoratorNode {
1823
+ static getType() {
1824
+ return "horizontal_divider"
1609
1825
  }
1610
1826
 
1611
- // optional: defaults to true
1612
- get enabled() {
1613
- return true
1827
+ static clone(node) {
1828
+ return new HorizontalDividerNode(node.__key)
1614
1829
  }
1615
1830
 
1616
- get lexicalExtension() {
1617
- return null
1831
+ static importJSON(serializedNode) {
1832
+ return new HorizontalDividerNode()
1618
1833
  }
1619
1834
 
1620
- get allowedElements() {
1621
- return []
1835
+ static importDOM() {
1836
+ return {
1837
+ "hr": (hr) => {
1838
+ return {
1839
+ conversion: () => ({
1840
+ node: new HorizontalDividerNode()
1841
+ }),
1842
+ priority: 1
1843
+ }
1844
+ }
1845
+ }
1622
1846
  }
1623
1847
 
1624
- initializeToolbar(_lexxyToolbar) {
1625
-
1848
+ constructor(key) {
1849
+ super(key);
1626
1850
  }
1627
- }
1628
1851
 
1629
- const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
1630
- const REMOVE_HIGHLIGHT_COMMAND = createCommand();
1631
- const BLANK_STYLES = { "color": null, "background-color": null };
1852
+ createDOM() {
1853
+ const figure = createElement("figure", { className: "horizontal-divider" });
1854
+ const hr = createElement("hr");
1632
1855
 
1633
- const hasPastedStylesState = createState("hasPastedStyles", {
1634
- parse: (value) => value || false
1635
- });
1856
+ figure.appendChild(hr);
1636
1857
 
1637
- // Stores pending highlight ranges extracted during HTML import, keyed by CodeNode key.
1638
- // After the code retokenizer creates fresh CodeHighlightNodes, a mutation listener
1639
- // reads this map and re-applies the highlight styles. Scoped per editor instance
1640
- // so entries don't leak across editors or outlive a torn-down editor.
1641
- const pendingCodeHighlights = new WeakMap();
1858
+ const deleteButton = createElement("lexxy-node-delete-button");
1859
+ figure.appendChild(deleteButton);
1642
1860
 
1643
- class HighlightExtension extends LexxyExtension {
1644
- get enabled() {
1645
- return this.editorElement.supportsRichText
1861
+ return figure
1646
1862
  }
1647
1863
 
1648
- get lexicalExtension() {
1649
- const extension = defineExtension({
1650
- dependencies: [ RichTextExtension ],
1651
- name: "lexxy/highlight",
1652
- config: {
1653
- color: { buttons: [], permit: [] },
1654
- "background-color": { buttons: [], permit: [] }
1655
- },
1656
- html: {
1657
- import: {
1658
- mark: $markConversion
1659
- }
1660
- },
1661
- register(editor, config) {
1662
- // keep the ref to the canonicalizers for optimized css conversion
1663
- const canonicalizers = buildCanonicalizers(config);
1664
-
1665
- // Register the <pre> converter directly in the conversion cache so it
1666
- // coexists with other extensions' "pre" converters (the extension-level
1667
- // html.import uses Object.assign, which means only one "pre" per key).
1668
- $registerPreConversion(editor);
1669
-
1670
- return mergeRegister(
1671
- editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, (styles) => $toggleSelectionStyles(editor, styles), COMMAND_PRIORITY_NORMAL),
1672
- editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(editor, BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
1673
- editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
1674
- editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode),
1675
- editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers)),
1676
- editor.registerMutationListener(CodeNode, (mutations) => {
1677
- $applyPendingCodeHighlights(editor, mutations);
1678
- }, { skipInitialization: true })
1679
- )
1680
- }
1681
- });
1682
-
1683
- return [ extension, this.editorConfig.get("highlight") ]
1864
+ updateDOM() {
1865
+ return true
1684
1866
  }
1685
- }
1686
-
1687
- function $applyHighlightStyle(textNode, element) {
1688
- const elementStyles = {
1689
- color: element.style?.color,
1690
- "background-color": element.style?.backgroundColor
1691
- };
1692
-
1693
- if ($hasUpdateTag(PASTE_TAG)) { $setPastedStyles(textNode); }
1694
- const highlightStyle = getCSSFromStyleObject(elementStyles);
1695
1867
 
1696
- if (highlightStyle.length) {
1697
- return textNode.setStyle(textNode.getStyle() + highlightStyle)
1868
+ getTextContent() {
1869
+ return "┄\n\n"
1698
1870
  }
1699
- }
1700
1871
 
1701
- function $markConversion() {
1702
- return {
1703
- conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
1704
- priority: 1
1872
+ isInline() {
1873
+ return false
1705
1874
  }
1706
- }
1707
-
1708
- // Register a custom <pre> converter directly in the editor's HTML conversion
1709
- // cache. We can't use the extension-level html.import because Object.assign
1710
- // merges all extensions' converters by tag, and a later extension (e.g.
1711
- // TrixContentExtension) would overwrite ours.
1712
- function $registerPreConversion(editor) {
1713
- if (!editor._htmlConversions) return
1714
1875
 
1715
- let preEntries = editor._htmlConversions.get("pre");
1716
- if (!preEntries) {
1717
- preEntries = [];
1718
- editor._htmlConversions.set("pre", preEntries);
1876
+ exportDOM() {
1877
+ const hr = createElement("hr");
1878
+ return { element: hr }
1719
1879
  }
1720
- preEntries.push($preConversionWithHighlightsFactory(editor));
1721
- }
1722
-
1723
- // Returns a <pre> converter factory scoped to a specific editor instance.
1724
- // The factory extracts highlight ranges from <mark> elements before the code
1725
- // retokenizer can destroy them. The ranges are stored in pendingCodeHighlights
1726
- // and applied after retokenization via a mutation listener.
1727
- function $preConversionWithHighlightsFactory(editor) {
1728
- return function $preConversionWithHighlights(domNode) {
1729
- const highlights = extractHighlightRanges(domNode);
1730
- if (highlights.length === 0) return null
1731
1880
 
1881
+ exportJSON() {
1732
1882
  return {
1733
- conversion: (domNode) => {
1734
- const language = domNode.getAttribute("data-language");
1735
- const codeNode = $createCodeNode(language);
1736
- $getPendingHighlights(editor).set(codeNode.getKey(), highlights);
1737
- return { node: codeNode }
1738
- },
1739
- priority: 2
1883
+ type: "horizontal_divider",
1884
+ version: 1
1740
1885
  }
1741
1886
  }
1742
- }
1743
-
1744
- // Walk the DOM tree inside a <pre> element and build a list of
1745
- // { start, end, style } ranges for every <mark> element found.
1746
- function extractHighlightRanges(preElement) {
1747
- const ranges = [];
1748
- const codeElement = preElement.querySelector("code") || preElement;
1749
-
1750
- let offset = 0;
1751
-
1752
- function walk(node) {
1753
- if (node.nodeType === Node.TEXT_NODE) {
1754
- offset += node.textContent.length;
1755
- } else if (node.nodeType === Node.ELEMENT_NODE) {
1756
- // <br> maps to a LineBreakNode (1 character) in Lexical
1757
- if (node.tagName === "BR") {
1758
- offset += 1;
1759
- return
1760
- }
1761
1887
 
1762
- const isMark = node.tagName === "MARK";
1763
- const start = offset;
1888
+ decorate() {
1889
+ return null
1890
+ }
1891
+ }
1764
1892
 
1765
- for (const child of node.childNodes) {
1766
- walk(child);
1767
- }
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);
1768
1902
 
1769
- if (isMark) {
1770
- const style = extractHighlightStyleFromElement(node);
1771
- if (style) {
1772
- ranges.push({ start, end: offset, style });
1773
- }
1774
- }
1903
+ if (!isImport) {
1904
+ const paragraph = $createParagraphNode();
1905
+ hrNode.insertAfter(paragraph);
1906
+ paragraph.select();
1775
1907
  }
1776
- }
1777
-
1778
- for (const child of codeElement.childNodes) {
1779
- walk(child);
1780
- }
1908
+ },
1909
+ type: "multiline-element"
1910
+ };
1781
1911
 
1782
- return ranges
1783
- }
1912
+ const PUNCTUATION_OR_SPACE = /[^\w]/;
1784
1913
 
1785
- function $getPendingHighlights(editor) {
1786
- let map = pendingCodeHighlights.get(editor);
1787
- if (!map) {
1788
- map = new Map();
1789
- pendingCodeHighlights.set(editor, map);
1790
- }
1791
- return map
1792
- }
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
1793
1931
 
1794
- function extractHighlightStyleFromElement(element) {
1795
- const styles = {};
1796
- if (element.style?.color) styles.color = element.style.color;
1797
- if (element.style?.backgroundColor) styles["background-color"] = element.style.backgroundColor;
1798
- const css = getCSSFromStyleObject(styles);
1799
- return css.length > 0 ? css : null
1800
- }
1932
+ return editor.registerUpdateListener(({ tags, dirtyLeaves, editorState, prevEditorState }) => {
1933
+ if (tags.has("historic") || tags.has("collaboration")) return
1934
+ if (editor.isComposing()) return
1801
1935
 
1802
- // Called from the CodeNode mutation listener after the retokenizer has
1803
- // replaced TextNodes with fresh CodeHighlightNodes.
1804
- function $applyPendingCodeHighlights(editor, mutations) {
1805
- const pending = $getPendingHighlights(editor);
1806
- const keysToProcess = [];
1936
+ const selection = editorState.read($getSelection);
1937
+ const prevSelection = prevEditorState.read($getSelection);
1807
1938
 
1808
- for (const [ key, type ] of mutations) {
1809
- if (type !== "destroyed" && pending.has(key)) {
1810
- keysToProcess.push(key);
1811
- }
1812
- }
1939
+ if (!$isRangeSelection(prevSelection) || !$isRangeSelection(selection) || !selection.isCollapsed()) return
1813
1940
 
1814
- if (keysToProcess.length === 0) return
1941
+ const anchorKey = selection.anchor.key;
1942
+ const anchorOffset = selection.anchor.offset;
1815
1943
 
1816
- // Use a deferred update so the retokenizer has finished its
1817
- // skipTransforms update before we touch the nodes.
1818
- editor.update(() => {
1819
- for (const key of keysToProcess) {
1820
- const highlights = pending.get(key);
1821
- pending.delete(key);
1822
- if (!highlights) continue
1944
+ if (!dirtyLeaves.has(anchorKey)) return
1823
1945
 
1824
- const codeNode = $getNodeByKey(key);
1825
- if (!codeNode || !$isCodeNode(codeNode)) continue
1946
+ const anchorNode = editorState.read(() => $getNodeByKey(anchorKey));
1947
+ if (!$isTextNode(anchorNode)) return
1826
1948
 
1827
- $applyHighlightRangesToCodeNode(codeNode, highlights);
1828
- }
1829
- }, { skipTransforms: true, discrete: true });
1830
- }
1949
+ // Only trigger when cursor moved forward (typing)
1950
+ const prevOffset = prevSelection.anchor.key === anchorKey ? prevSelection.anchor.offset : 0;
1951
+ if (anchorOffset <= prevOffset) return
1831
1952
 
1832
- // Apply saved highlight ranges to the CodeHighlightNode children
1833
- // of a CodeNode, splitting nodes at range boundaries as needed.
1834
- // We can't use TextNode.splitText() because it creates TextNode
1835
- // instances (not CodeHighlightNodes) for the split parts. Instead,
1836
- // we manually create CodeHighlightNode replacements.
1837
- function $applyHighlightRangesToCodeNode(codeNode, highlights) {
1838
- if (highlights.length === 0) return
1953
+ const textContent = editorState.read(() => anchorNode.getTextContent());
1839
1954
 
1840
- for (const { start: hlStart, end: hlEnd, style } of highlights) {
1841
- // Rebuild the child-to-offset mapping for each highlight range because
1842
- // earlier ranges may have split nodes, invalidating previous mappings.
1843
- const childRanges = $buildChildRanges(codeNode);
1955
+ // Try each transformer, longest tags first
1956
+ for (const transformer of textFormatTransformers) {
1957
+ const tag = transformer.tag;
1958
+ const tagLen = tag.length;
1844
1959
 
1845
- for (const { node, start: nodeStart, end: nodeEnd } of childRanges) {
1846
- // Skip plain TextNodes: only CodeHighlightNodes can be split into
1847
- // styled replacements here. The retokenizer normally converts any
1848
- // TextNode children back to CodeHighlightNodes before this runs,
1849
- // but the iteration over $buildChildRanges has to keep counting
1850
- // them so character offsets stay aligned with the saved ranges.
1851
- if (!$isCodeHighlightNode(node)) continue
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
1852
1963
 
1853
- // Check if this child overlaps with the highlight range
1854
- const overlapStart = Math.max(hlStart, nodeStart);
1855
- const overlapEnd = Math.min(hlEnd, nodeEnd);
1964
+ const candidateOpenTag = textContent.slice(openTagStart, anchorOffset);
1965
+ if (candidateOpenTag !== tag) continue
1856
1966
 
1857
- if (overlapStart >= overlapEnd) continue
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
1858
1972
 
1859
- // Calculate offsets relative to this node
1860
- const relStart = overlapStart - nodeStart;
1861
- const relEnd = overlapEnd - nodeStart;
1862
- const nodeLength = nodeEnd - nodeStart;
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
1978
+ }
1863
1979
 
1864
- if (relStart === 0 && relEnd === nodeLength) {
1865
- // Entire node is highlighted - apply style directly
1866
- node.setStyle(style);
1867
- $setCodeHighlightFormat(node, true);
1868
- } else {
1869
- // Need to split: replace the node with 2 or 3 CodeHighlightNodes
1870
- const text = node.getTextContent();
1871
- const highlightType = node.getHighlightType();
1872
- const replacements = [];
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
1873
1984
 
1874
- if (relStart > 0) {
1875
- replacements.push($createCodeHighlightNode(text.slice(0, relStart), highlightType));
1876
- }
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
1877
1989
 
1878
- const styledNode = $createCodeHighlightNode(text.slice(relStart, relEnd), highlightType);
1879
- styledNode.setStyle(style);
1880
- $setCodeHighlightFormat(styledNode, true);
1881
- replacements.push(styledNode);
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
1882
1993
 
1883
- if (relEnd < nodeLength) {
1884
- replacements.push($createCodeHighlightNode(text.slice(relEnd), highlightType));
1885
- }
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
1886
1998
 
1887
- for (const replacement of replacements) {
1888
- node.insertBefore(replacement);
1889
- }
1890
- node.remove();
1999
+ // No space immediately after opening tag
2000
+ if (textContent[innerStart] === " ") continue
2001
+
2002
+ // No space immediately before closing tag
2003
+ if (textContent[innerEnd - 1] === " ") continue
2004
+
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
1891
2009
  }
1892
- }
1893
- }
1894
- }
1895
2010
 
1896
- function $buildChildRanges(codeNode) {
1897
- const childRanges = [];
1898
- let charOffset = 0;
2011
+ editor.update(() => {
2012
+ const node = $getNodeByKey(anchorKey);
2013
+ if (!node || !$isTextNode(node)) return
1899
2014
 
1900
- for (const child of codeNode.getChildren()) {
1901
- if ($isCodeHighlightNode(child) || $isTextNode(child)) {
1902
- const text = child.getTextContent();
1903
- childRanges.push({ node: child, start: charOffset, end: charOffset + text.length });
1904
- charOffset += text.length;
1905
- } else {
1906
- // LineBreakNode, TabNode - count as 1 character each (\n, \t)
1907
- charOffset += 1;
1908
- }
1909
- }
2015
+ const parent = node.getParent();
2016
+ if (parent === null || $isCodeNode(parent)) return
1910
2017
 
1911
- return childRanges
2018
+ $applyFormatFromLeadingTag(node, openTagStart, transformer);
2019
+ });
2020
+
2021
+ break // Only apply the first (longest) matching transformer
2022
+ }
2023
+ })
1912
2024
  }
1913
2025
 
1914
- // Extract highlight ranges from the Lexical node tree of a CodeNode.
1915
- // This mirrors extractHighlightRanges (which works on DOM elements during
1916
- // HTML import) but reads from live CodeHighlightNode children instead.
1917
- function $extractHighlightRangesFromCodeNode(codeNode) {
1918
- const ranges = [];
1919
- const childRanges = $buildChildRanges(codeNode);
2026
+ function $applyFormatFromLeadingTag(anchorNode, openTagStart, transformer) {
2027
+ const tag = transformer.tag;
2028
+ const tagLen = tag.length;
2029
+ const textContent = anchorNode.getTextContent();
1920
2030
 
1921
- for (const { node, start, end } of childRanges) {
1922
- const style = node.getStyle();
1923
- if (style && hasHighlightStyles(style)) {
1924
- ranges.push({ start, end, style });
1925
- }
1926
- }
2031
+ const innerStart = openTagStart + tagLen;
2032
+ const closeTagIndex = textContent.indexOf(tag, innerStart);
2033
+ if (closeTagIndex < 0) return
1927
2034
 
1928
- return ranges
1929
- }
2035
+ const inner = textContent.slice(innerStart, closeTagIndex);
2036
+ if (inner.length === 0) return
1930
2037
 
1931
- function buildCanonicalizers(config) {
1932
- return [
1933
- new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
1934
- new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
1935
- ]
1936
- }
2038
+ // Remove both tags and apply format
2039
+ const before = textContent.slice(0, openTagStart);
2040
+ const after = textContent.slice(closeTagIndex + tagLen);
1937
2041
 
1938
- function $toggleSelectionStyles(editor, styles) {
1939
- const selection = $getSelection();
1940
- if (!$isRangeSelection(selection)) return
2042
+ anchorNode.setTextContent(before + inner + after);
1941
2043
 
1942
- const patch = {};
1943
- for (const property in styles) {
1944
- const oldValue = $getSelectionStyleValueForProperty(selection, property);
1945
- patch[property] = toggleOrReplace(oldValue, styles[property]);
1946
- }
2044
+ const nextSelection = $createRangeSelection();
2045
+ $setSelection(nextSelection);
1947
2046
 
1948
- if ($selectionIsInCodeBlock(selection)) {
1949
- $patchCodeHighlightStyles(editor, selection, patch);
1950
- } else {
1951
- $patchStyleText(selection, patch);
1952
- }
1953
- }
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");
1954
2050
 
1955
- function $selectionIsInCodeBlock(selection) {
1956
- const nodes = selection.getNodes();
1957
- return nodes.some((node) => {
1958
- // A text node inside a code block may be either a CodeHighlightNode
1959
- // (after retokenization) or a plain TextNode (after splitText or before
1960
- // the retokenizer has run). Check the parent in both cases.
1961
- if ($isCodeHighlightNode(node) || $isTextNode(node)) {
1962
- return $isCodeNode(node.getParent())
2051
+ for (const format of transformer.format) {
2052
+ if (!nextSelection.hasFormat(format)) {
2053
+ nextSelection.formatText(format);
1963
2054
  }
1964
- return $isCodeNode(node)
1965
- })
1966
- }
2055
+ }
1967
2056
 
1968
- function $patchCodeHighlightStyles(editor, selection, patch) {
1969
- // Capture selection state and node keys before the nested update.
1970
- // Accept both CodeHighlightNode and TextNode children of a CodeNode
1971
- // because splitText creates TextNode instances and the retokenizer
1972
- // may not have converted them back to CodeHighlightNodes yet.
1973
- const nodeKeys = selection.getNodes()
1974
- .filter((node) => ($isCodeHighlightNode(node) || $isTextNode(node)) && $isCodeNode(node.getParent()))
1975
- .map((node) => ({
1976
- key: node.getKey(),
1977
- startOffset: $getNodeSelectionOffsets(node, selection)[0],
1978
- endOffset: $getNodeSelectionOffsets(node, selection)[1],
1979
- textSize: node.getTextContentSize()
1980
- }));
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);
1981
2060
 
1982
- // Use skipTransforms to prevent the code highlighting system from
1983
- // re-tokenizing and wiping out the style changes we apply.
1984
- // Use discrete to force a synchronous commit, ensuring the changes
1985
- // are committed before editor.focus() triggers a second update cycle
1986
- // that would re-run transforms and wipe out the styles.
1987
- editor.update(() => {
1988
- const affectedCodeNodes = new Set();
2061
+ for (const format of transformer.format) {
2062
+ if (nextSelection.hasFormat(format)) {
2063
+ nextSelection.toggleFormat(format);
2064
+ }
2065
+ }
2066
+ }
1989
2067
 
1990
- for (const { key, startOffset, endOffset, textSize } of nodeKeys) {
1991
- const node = $getNodeByKey(key);
1992
- if (!node) continue
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",
2138
+ }
2139
+ };
1993
2140
 
1994
- const parent = node.getParent();
1995
- if (!$isCodeNode(parent)) continue
1996
- if (startOffset === endOffset) continue
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.
1997
2152
 
1998
- affectedCodeNodes.add(parent);
2153
+ let resolverRoot = null;
1999
2154
 
2000
- if (startOffset === 0 && endOffset === textSize) {
2001
- $applyStylePatchToNode(node, patch);
2002
- } else {
2003
- const splitNodes = node.splitText(startOffset, endOffset);
2004
- const targetNode = splitNodes[startOffset === 0 ? 0 : 1];
2005
- $applyStylePatchToNode(targetNode, patch);
2006
- }
2007
- }
2155
+ function styleResolverRoot() {
2156
+ if (resolverRoot && resolverRoot.isConnected) return resolverRoot
2008
2157
 
2009
- // After applying styles, save highlight ranges for each affected CodeNode.
2010
- // The code retokenizer will replace the styled nodes with fresh unstyled
2011
- // tokens when transforms run. The pending highlights are picked up by the
2012
- // CodeNode mutation listener and reapplied after retokenization.
2013
- for (const codeNode of affectedCodeNodes) {
2014
- const ranges = $extractHighlightRangesFromCodeNode(codeNode);
2015
- if (ranges.length > 0) {
2016
- $getPendingHighlights(editor).set(codeNode.getKey(), ranges);
2017
- }
2018
- }
2019
- }, { skipTransforms: true, discrete: true });
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
2020
2168
  }
2021
2169
 
2022
- function $getNodeSelectionOffsets(node, selection) {
2023
- const nodeKey = node.getKey();
2024
- const anchorKey = selection.anchor.key;
2025
- const focusKey = selection.focus.key;
2026
- const textSize = node.getTextContentSize();
2027
-
2028
- const isAnchor = nodeKey === anchorKey;
2029
- const isFocus = nodeKey === focusKey;
2030
-
2031
- // Determine if selection is forward or backward
2032
- const isForward = selection.isBackward() === false;
2033
-
2034
- let start = 0;
2035
- let end = textSize;
2170
+ function isSelectionHighlighted(selection) {
2171
+ if (!$isRangeSelection(selection)) return false
2036
2172
 
2037
- if (isForward) {
2038
- if (isAnchor) start = selection.anchor.offset;
2039
- if (isFocus) end = selection.focus.offset;
2173
+ if (selection.isCollapsed()) {
2174
+ return hasHighlightStyles(selection.style)
2040
2175
  } else {
2041
- if (isFocus) start = selection.focus.offset;
2042
- if (isAnchor) end = selection.anchor.offset;
2176
+ return selection.hasFormat("highlight")
2043
2177
  }
2044
-
2045
- return [ start, end ]
2046
2178
  }
2047
2179
 
2048
- function $applyStylePatchToNode(node, patch) {
2049
- const prevStyles = getStyleObjectFromCSS(node.getStyle());
2050
- const newStyles = { ...prevStyles };
2180
+ function getHighlightStyles(selection) {
2181
+ if (!$isRangeSelection(selection)) return null
2051
2182
 
2052
- for (const [ key, value ] of Object.entries(patch)) {
2053
- if (value === null) {
2054
- delete newStyles[key];
2055
- } else {
2056
- newStyles[key] = value;
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());
2057
2188
  }
2058
2189
  }
2059
2190
 
2060
- const newCSSText = getCSSFromStyleObject(newStyles);
2061
- node.setStyle(newCSSText);
2062
-
2063
- // Sync the highlight format using TextNode's setFormat to bypass
2064
- // CodeHighlightNode's no-op override
2065
- const shouldHaveHighlight = hasHighlightStyles(newCSSText);
2066
- const hasHighlight = node.hasFormat("highlight");
2191
+ const color = styles.color || null;
2192
+ const backgroundColor = styles["background-color"] || null;
2193
+ if (!color && !backgroundColor) return null
2067
2194
 
2068
- if (shouldHaveHighlight !== hasHighlight) {
2069
- $setCodeHighlightFormat(node, shouldHaveHighlight);
2070
- }
2195
+ return { color, backgroundColor }
2071
2196
  }
2072
2197
 
2073
- function $setCodeHighlightFormat(node, shouldHaveHighlight) {
2074
- const writable = node.getWritable();
2075
- const IS_HIGHLIGHT = 1 << 7;
2076
-
2077
- if (shouldHaveHighlight) {
2078
- writable.__format |= IS_HIGHLIGHT;
2079
- } else {
2080
- writable.__format &= ~IS_HIGHLIGHT;
2081
- }
2198
+ function hasHighlightStyles(cssOrStyles) {
2199
+ const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
2200
+ return !!(styles.color || styles["background-color"])
2082
2201
  }
2083
2202
 
2084
- function toggleOrReplace(oldValue, newValue) {
2085
- return oldValue === newValue ? null : newValue
2203
+ function applyCanonicalizers(styles, canonicalizers = []) {
2204
+ return canonicalizers.reduce((css, canonicalizer) => {
2205
+ return canonicalizer.applyCanonicalization(css)
2206
+ }, styles)
2086
2207
  }
2087
2208
 
2088
- function $syncHighlightWithStyle(textNode) {
2089
- if (hasHighlightStyles(textNode.getStyle()) !== textNode.hasFormat("highlight")) {
2090
- textNode.toggleFormat("highlight");
2209
+ class StyleCanonicalizer {
2210
+ constructor(property, allowedValues= []) {
2211
+ this._property = property;
2212
+ this._allowedValues = allowedValues;
2213
+ this._canonicalValues = this.#allowedValuesIdentityObject;
2091
2214
  }
2092
- }
2093
2215
 
2094
- function $syncHighlightWithCodeHighlightNode(node) {
2095
- const parent = node.getParent();
2096
- if (!$isCodeNode(parent)) return
2216
+ applyCanonicalization(css) {
2217
+ const styles = { ...getStyleObjectFromCSS(css) };
2097
2218
 
2098
- const shouldHaveHighlight = hasHighlightStyles(node.getStyle());
2099
- const hasHighlight = node.hasFormat("highlight");
2219
+ styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
2220
+ if (!styles[this._property]) {
2221
+ delete styles[this._property];
2222
+ }
2100
2223
 
2101
- if (shouldHaveHighlight !== hasHighlight) {
2102
- $setCodeHighlightFormat(node, shouldHaveHighlight);
2224
+ return getCSSFromStyleObject(styles)
2103
2225
  }
2104
- }
2105
-
2106
- function $canonicalizePastedStyles(textNode, canonicalizers = []) {
2107
- if ($hasPastedStyles(textNode)) {
2108
- $setPastedStyles(textNode, false);
2109
-
2110
- const canonicalizedCSS = applyCanonicalizers(textNode.getStyle(), canonicalizers);
2111
- textNode.setStyle(canonicalizedCSS);
2112
2226
 
2113
- const selection = $getSelection();
2114
- if (textNode.isSelected(selection)) {
2115
- selection.setStyle(textNode.getStyle());
2116
- selection.setFormat(textNode.getFormat());
2117
- }
2227
+ getCanonicalAllowedValue(value) {
2228
+ return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
2118
2229
  }
2119
- }
2120
-
2121
- function $setPastedStyles(textNode, value = true) {
2122
- $setState(textNode, hasPastedStylesState, value);
2123
- }
2124
-
2125
- function $hasPastedStyles(textNode) {
2126
- return $getState(textNode, hasPastedStylesState)
2127
- }
2128
-
2129
- const COMMANDS = [
2130
- "bold",
2131
- "italic",
2132
- "strikethrough",
2133
- "underline",
2134
- "link",
2135
- "unlink",
2136
- "toggleHighlight",
2137
- "removeHighlight",
2138
- "setFormatHeadingLarge",
2139
- "setFormatHeadingMedium",
2140
- "setFormatHeadingSmall",
2141
- "setFormatParagraph",
2142
- "clearFormatting",
2143
- "insertUnorderedList",
2144
- "insertOrderedList",
2145
- "insertQuoteBlock",
2146
- "insertCodeBlock",
2147
- "setCodeLanguage",
2148
- "insertHorizontalDivider",
2149
- "uploadImage",
2150
- "uploadFile",
2151
2230
 
2152
- "insertTable",
2153
-
2154
- "undo",
2155
- "redo"
2156
- ];
2157
-
2158
- class CommandDispatcher {
2159
- #selectionBeforeDrag = null
2160
- #listeners = new ListenerBin()
2231
+ // Private
2161
2232
 
2162
- static configureFor(editorElement) {
2163
- return new CommandDispatcher(editorElement)
2233
+ get #allowedValuesIdentityObject() {
2234
+ return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
2164
2235
  }
2165
2236
 
2166
- constructor(editorElement) {
2167
- this.editorElement = editorElement;
2168
- this.editor = editorElement.editor;
2169
- this.selection = editorElement.selection;
2170
- this.contents = editorElement.contents;
2171
- this.clipboard = editorElement.clipboard;
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]
2243
+ }
2172
2244
 
2173
- this.#registerCommands();
2174
- this.#registerKeyboardCommands();
2175
- this.#registerDragAndDropHandlers();
2245
+ get #computedAllowedValues() {
2246
+ return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
2176
2247
  }
2248
+ }
2249
+
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();
2177
2257
 
2178
- dispatchPaste(event) {
2179
- return this.clipboard.paste(event)
2180
- }
2258
+ const elements = values.map(value => {
2259
+ const element = createElement("span", { style: `display: none; ${property}: ${value};` });
2260
+ fragment.appendChild(element);
2261
+ return element
2262
+ });
2181
2263
 
2182
- dispatchBold() {
2183
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
2184
- }
2264
+ styleResolverRoot().appendChild(fragment);
2185
2265
 
2186
- dispatchItalic() {
2187
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
2188
- }
2266
+ const computed = elements.map(element =>
2267
+ window.getComputedStyle(element).getPropertyValue(property)
2268
+ );
2189
2269
 
2190
- dispatchStrikethrough() {
2191
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
2192
- }
2270
+ elements.forEach(element => element.remove());
2271
+ return computed
2272
+ }
2193
2273
 
2194
- dispatchUnderline() {
2195
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
2196
- }
2274
+ const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
2275
+ const REMOVE_HIGHLIGHT_COMMAND = createCommand();
2276
+ const BLANK_STYLES = { "color": null, "background-color": null };
2197
2277
 
2198
- dispatchToggleHighlight(styles) {
2199
- this.editor.dispatchCommand(TOGGLE_HIGHLIGHT_COMMAND, styles);
2200
- }
2278
+ const hasPastedStylesState = createState("hasPastedStyles", {
2279
+ parse: (value) => value || false
2280
+ });
2201
2281
 
2202
- dispatchRemoveHighlight() {
2203
- this.editor.dispatchCommand(REMOVE_HIGHLIGHT_COMMAND);
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();
2287
+
2288
+ class HighlightExtension extends LexxyExtension {
2289
+ get enabled() {
2290
+ return this.editorElement.supportsRichText
2204
2291
  }
2205
2292
 
2206
- dispatchLink(url) {
2207
- this.editor.update(() => {
2208
- const selection = $getSelection();
2209
- if (!$isRangeSelection(selection)) return
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);
2210
2309
 
2211
- const anchorNode = selection.anchor.getNode();
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);
2212
2314
 
2213
- if (selection.isCollapsed() && !$getNearestNodeOfType(anchorNode, LinkNode)) {
2214
- const autoLinkNode = $createAutoLinkNode(url);
2215
- const textNode = $createTextNode(url);
2216
- autoLinkNode.append(textNode);
2217
- selection.insertNodes([ autoLinkNode ]);
2218
- } else {
2219
- $toggleLink(url);
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
+ )
2220
2325
  }
2221
2326
  });
2222
- }
2223
-
2224
- dispatchUnlink() {
2225
- this.editor.update(() => {
2226
- // Let adapters signal whether unlink should target a frozen link key.
2227
- if (this.editorElement.adapter.unlinkFrozenNode?.()) {
2228
- return
2229
- }
2230
2327
 
2231
- $toggleLink(null);
2232
- });
2328
+ return [ extension, this.editorConfig.get("highlight") ]
2233
2329
  }
2330
+ }
2234
2331
 
2235
- dispatchInsertUnorderedList() {
2236
- const selection = $getSelection();
2237
- if (!$isRangeSelection(selection)) return
2332
+ function $applyHighlightStyle(textNode, element) {
2333
+ const elementStyles = {
2334
+ color: element.style?.color,
2335
+ "background-color": element.style?.backgroundColor
2336
+ };
2238
2337
 
2239
- const anchorNode = selection.anchor.getNode();
2338
+ if ($hasUpdateTag(PASTE_TAG)) { $setPastedStyles(textNode); }
2339
+ const highlightStyle = getCSSFromStyleObject(elementStyles);
2240
2340
 
2241
- if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
2242
- this.contents.applyParagraphFormat();
2243
- } else {
2244
- this.contents.applyUnorderedListFormat();
2245
- }
2341
+ if (highlightStyle.length) {
2342
+ return textNode.setStyle(textNode.getStyle() + highlightStyle)
2246
2343
  }
2344
+ }
2247
2345
 
2248
- dispatchInsertOrderedList() {
2249
- const selection = $getSelection();
2250
- if (!$isRangeSelection(selection)) return
2251
-
2252
- const anchorNode = selection.anchor.getNode();
2253
-
2254
- if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
2255
- this.contents.applyParagraphFormat();
2256
- } else {
2257
- this.contents.applyOrderedListFormat();
2258
- }
2346
+ function $markConversion() {
2347
+ return {
2348
+ conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
2349
+ priority: 1
2259
2350
  }
2351
+ }
2260
2352
 
2261
- dispatchInsertQuoteBlock() {
2262
- this.contents.toggleBlockquote();
2263
- }
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
2264
2359
 
2265
- dispatchInsertCodeBlock() {
2266
- if (this.selection.hasSelectedWordsInSingleLine) {
2267
- this.#toggleInlineCode();
2268
- } else {
2269
- this.contents.toggleCodeBlock();
2270
- }
2360
+ let preEntries = editor._htmlConversions.get("pre");
2361
+ if (!preEntries) {
2362
+ preEntries = [];
2363
+ editor._htmlConversions.set("pre", preEntries);
2271
2364
  }
2365
+ preEntries.push($preConversionWithHighlightsFactory(editor));
2366
+ }
2272
2367
 
2273
- #toggleInlineCode() {
2274
- const selection = $getSelection();
2275
- if (!$isRangeSelection(selection)) return
2276
-
2277
- if (!selection.isCollapsed()) {
2278
- const textNodes = selection.getNodes().filter($isTextNode);
2279
- const applyingCode = !textNodes.every((node) => node.hasFormat("code"));
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
2280
2376
 
2281
- if (applyingCode) {
2282
- this.#stripInlineFormattingFromSelection(selection, textNodes);
2283
- }
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
2284
2385
  }
2285
-
2286
- this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
2287
2386
  }
2387
+ }
2288
2388
 
2289
- // Strip all inline formatting (bold, italic, etc.) from the selected text
2290
- // nodes so that applying code produces a single merged <code> element instead
2291
- // of one per differently-formatted span.
2292
- #stripInlineFormattingFromSelection(selection, textNodes) {
2293
- const isBackward = selection.isBackward();
2294
- const startPoint = isBackward ? selection.focus : selection.anchor;
2295
- const endPoint = isBackward ? selection.anchor : selection.focus;
2296
-
2297
- for (let i = 0; i < textNodes.length; i++) {
2298
- const node = textNodes[i];
2299
- if (node.getFormat() === 0) continue
2300
-
2301
- const isFirst = i === 0;
2302
- const isLast = i === textNodes.length - 1;
2303
- const startOffset = isFirst && startPoint.type === "text" ? startPoint.offset : 0;
2304
- const endOffset = isLast && endPoint.type === "text" ? endPoint.offset : node.getTextContentSize();
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;
2305
2394
 
2306
- if (startOffset === 0 && endOffset === node.getTextContentSize()) {
2307
- node.setFormat(0);
2308
- } else {
2309
- const splits = node.splitText(startOffset, endOffset);
2310
- const target = startOffset === 0 ? splits[0] : splits[1];
2311
- target.setFormat(0);
2395
+ let offset = 0;
2312
2396
 
2313
- if (isFirst && startPoint.type === "text") {
2314
- startPoint.set(target.getKey(), 0, "text");
2315
- }
2316
- if (isLast && endPoint.type === "text") {
2317
- endPoint.set(target.getKey(), endOffset - startOffset, "text");
2318
- }
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
2319
2405
  }
2320
- }
2321
- }
2322
-
2323
- dispatchSetCodeLanguage(language) {
2324
- this.editor.update(() => {
2325
- if (!this.selection.isInsideCodeBlock) return
2326
-
2327
- const codeNode = this.selection.nearestNodeOfType(CodeNode);
2328
- if (!codeNode) return
2329
2406
 
2330
- codeNode.setLanguage(language);
2331
- });
2332
- }
2407
+ const isMark = node.tagName === "MARK";
2408
+ const start = offset;
2333
2409
 
2334
- dispatchInsertHorizontalDivider() {
2335
- this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
2336
- this.editor.focus();
2337
- }
2410
+ for (const child of node.childNodes) {
2411
+ walk(child);
2412
+ }
2338
2413
 
2339
- dispatchSetFormatHeadingLarge() {
2340
- this.contents.applyHeadingFormat("h2");
2414
+ if (isMark) {
2415
+ const style = extractHighlightStyleFromElement(node);
2416
+ if (style) {
2417
+ ranges.push({ start, end: offset, style });
2418
+ }
2419
+ }
2420
+ }
2341
2421
  }
2342
2422
 
2343
- dispatchSetFormatHeadingMedium() {
2344
- this.contents.applyHeadingFormat("h3");
2423
+ for (const child of codeElement.childNodes) {
2424
+ walk(child);
2345
2425
  }
2346
2426
 
2347
- dispatchSetFormatHeadingSmall() {
2348
- this.contents.applyHeadingFormat("h4");
2349
- }
2427
+ return ranges
2428
+ }
2350
2429
 
2351
- dispatchSetFormatParagraph() {
2352
- this.contents.applyParagraphFormat();
2430
+ function $getPendingHighlights(editor) {
2431
+ let map = pendingCodeHighlights.get(editor);
2432
+ if (!map) {
2433
+ map = new Map();
2434
+ pendingCodeHighlights.set(editor, map);
2353
2435
  }
2436
+ return map
2437
+ }
2354
2438
 
2355
- dispatchClearFormatting() {
2356
- this.contents.clearFormatting();
2357
- }
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
+ }
2358
2446
 
2359
- dispatchUploadImage() {
2360
- this.#dispatchUploadAttachment("image/*,video/*");
2361
- }
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 = [];
2362
2452
 
2363
- dispatchUploadFile() {
2364
- this.#dispatchUploadAttachment();
2453
+ for (const [ key, type ] of mutations) {
2454
+ if (type !== "destroyed" && pending.has(key)) {
2455
+ keysToProcess.push(key);
2456
+ }
2365
2457
  }
2366
2458
 
2367
- #dispatchUploadAttachment(accept = null) {
2368
- const attributes = {
2369
- type: "file",
2370
- multiple: true,
2371
- style: "display: none;",
2372
- onchange: ({ target: { files } }) => {
2373
- this.contents.uploadFiles(files, { selectLast: true });
2374
- }
2375
- };
2376
-
2377
- if (accept) attributes.accept = accept;
2459
+ if (keysToProcess.length === 0) return
2378
2460
 
2379
- 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
2380
2468
 
2381
- // Append and remove to make testable
2382
- this.editorElement.appendChild(input);
2383
- input.click();
2384
- setTimeout(() => input.remove(), 1000);
2385
- }
2469
+ const codeNode = $getNodeByKey(key);
2470
+ if (!codeNode || !$isCodeNode(codeNode)) continue
2386
2471
 
2387
- dispatchInsertTable() {
2388
- this.editor.dispatchCommand(INSERT_TABLE_COMMAND, { "rows": 3, "columns": 3, "includeHeaders": true });
2389
- }
2472
+ $applyHighlightRangesToCodeNode(codeNode, highlights);
2473
+ }
2474
+ }, { skipTransforms: true, discrete: true });
2475
+ }
2390
2476
 
2391
- dispatchUndo() {
2392
- this.editor.dispatchCommand(UNDO_COMMAND, undefined);
2393
- }
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
2394
2484
 
2395
- dispatchRedo() {
2396
- this.editor.dispatchCommand(REDO_COMMAND, undefined);
2397
- }
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);
2398
2489
 
2399
- dispose() {
2400
- this.#listeners.dispose();
2401
- }
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
2402
2497
 
2403
- #registerCommands() {
2404
- for (const command of COMMANDS) {
2405
- const methodName = `dispatch${capitalize(command)}`;
2406
- this.#registerCommandHandler(command, 0, this[methodName].bind(this));
2407
- }
2498
+ // Check if this child overlaps with the highlight range
2499
+ const overlapStart = Math.max(hlStart, nodeStart);
2500
+ const overlapEnd = Math.min(hlEnd, nodeEnd);
2408
2501
 
2409
- this.#registerCommandHandler(PASTE_COMMAND, COMMAND_PRIORITY_LOW, this.dispatchPaste.bind(this));
2410
- }
2502
+ if (overlapStart >= overlapEnd) continue
2411
2503
 
2412
- #registerCommandHandler(command, priority, handler) {
2413
- this.#listeners.track(this.editor.registerCommand(command, handler, priority));
2414
- }
2504
+ // Calculate offsets relative to this node
2505
+ const relStart = overlapStart - nodeStart;
2506
+ const relEnd = overlapEnd - nodeStart;
2507
+ const nodeLength = nodeEnd - nodeStart;
2415
2508
 
2416
- #registerKeyboardCommands() {
2417
- this.#registerCommandHandler(KEY_ARROW_RIGHT_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleArrowRightKey.bind(this));
2418
- this.#registerCommandHandler(KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleTabKey.bind(this));
2419
- }
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 = [];
2420
2518
 
2421
- #handleArrowRightKey(event) {
2422
- const selection = $getSelection();
2423
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
2424
- if (this.selection.isInsideCodeBlock || !selection.hasFormat("code")) return false
2519
+ if (relStart > 0) {
2520
+ replacements.push($createCodeHighlightNode(text.slice(0, relStart), highlightType));
2521
+ }
2425
2522
 
2426
- const anchorNode = selection.anchor.getNode();
2427
- if (!$isTextNode(anchorNode) || selection.anchor.offset !== anchorNode.getTextContentSize()) return false
2428
- 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);
2429
2527
 
2430
- event.preventDefault();
2431
- selection.toggleFormat("code");
2432
- return true
2433
- }
2528
+ if (relEnd < nodeLength) {
2529
+ replacements.push($createCodeHighlightNode(text.slice(relEnd), highlightType));
2530
+ }
2434
2531
 
2435
- #registerDragAndDropHandlers() {
2436
- if (this.editorElement.supportsAttachments) {
2437
- this.dragCounter = 0;
2438
- const root = this.editor.getRootElement();
2439
- this.#listeners.track(
2440
- registerEventListener(root, "dragover", this.#handleDragOver.bind(this)),
2441
- registerEventListener(root, "drop", this.#handleDrop.bind(this)),
2442
- registerEventListener(root, "dragenter", this.#handleDragEnter.bind(this)),
2443
- registerEventListener(root, "dragleave", this.#handleDragLeave.bind(this))
2444
- );
2532
+ for (const replacement of replacements) {
2533
+ node.insertBefore(replacement);
2534
+ }
2535
+ node.remove();
2536
+ }
2445
2537
  }
2446
2538
  }
2539
+ }
2447
2540
 
2448
- #handleDragEnter(event) {
2449
- if (this.#isInternalDrag(event)) return
2541
+ function $buildChildRanges(codeNode) {
2542
+ const childRanges = [];
2543
+ let charOffset = 0;
2450
2544
 
2451
- this.dragCounter++;
2452
- if (this.dragCounter === 1) {
2453
- this.#saveSelectionBeforeDrag();
2454
- 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;
2455
2553
  }
2456
2554
  }
2457
2555
 
2458
- #handleDragLeave(event) {
2459
- if (this.#isInternalDrag(event)) return
2556
+ return childRanges
2557
+ }
2460
2558
 
2461
- this.dragCounter--;
2462
- if (this.dragCounter === 0) {
2463
- this.#selectionBeforeDrag = null;
2464
- 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 });
2465
2570
  }
2466
2571
  }
2467
2572
 
2468
- #handleDragOver(event) {
2469
- if (this.#isInternalDrag(event)) return
2573
+ return ranges
2574
+ }
2470
2575
 
2471
- event.preventDefault();
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
+ }
2582
+
2583
+ function $toggleSelectionStyles(editor, styles) {
2584
+ const selection = $getSelection();
2585
+ if (!$isRangeSelection(selection)) return
2586
+
2587
+ const patch = {};
2588
+ for (const property in styles) {
2589
+ const oldValue = $getSelectionStyleValueForProperty(selection, property);
2590
+ patch[property] = toggleOrReplace(oldValue, styles[property]);
2591
+ }
2592
+
2593
+ if ($selectionIsInCodeBlock(selection)) {
2594
+ $patchCodeHighlightStyles(editor, selection, patch);
2595
+ } else {
2596
+ $patchStyleText(selection, patch);
2472
2597
  }
2598
+ }
2599
+
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
+ }
2612
+
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
+ }));
2473
2626
 
2474
- #handleDrop(event) {
2475
- if (this.#isInternalDrag(event)) return
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();
2476
2634
 
2477
- event.preventDefault();
2635
+ for (const { key, startOffset, endOffset, textSize } of nodeKeys) {
2636
+ const node = $getNodeByKey(key);
2637
+ if (!node) continue
2478
2638
 
2479
- this.dragCounter = 0;
2480
- this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
2639
+ const parent = node.getParent();
2640
+ if (!$isCodeNode(parent)) continue
2641
+ if (startOffset === endOffset) continue
2481
2642
 
2482
- const dataTransfer = event.dataTransfer;
2483
- if (!dataTransfer) return
2643
+ affectedCodeNodes.add(parent);
2484
2644
 
2485
- const files = Array.from(dataTransfer.files);
2486
- if (!files.length) return
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
+ }
2487
2653
 
2488
- this.#restoreSelectionBeforeDrag();
2489
- this.contents.uploadFiles(files, { selectLast: true });
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
+ }
2490
2666
 
2491
- this.editor.focus();
2492
- }
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();
2493
2672
 
2494
- #saveSelectionBeforeDrag() {
2495
- this.editor.getEditorState().read(() => {
2496
- this.#selectionBeforeDrag = $getSelection()?.clone();
2497
- });
2498
- }
2673
+ const isAnchor = nodeKey === anchorKey;
2674
+ const isFocus = nodeKey === focusKey;
2499
2675
 
2500
- #restoreSelectionBeforeDrag() {
2501
- if (!this.#selectionBeforeDrag) return
2676
+ // Determine if selection is forward or backward
2677
+ const isForward = selection.isBackward() === false;
2502
2678
 
2503
- this.editor.update(() => {
2504
- $setSelection(this.#selectionBeforeDrag);
2505
- });
2679
+ let start = 0;
2680
+ let end = textSize;
2506
2681
 
2507
- this.#selectionBeforeDrag = null;
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;
2508
2688
  }
2509
2689
 
2510
- #isInternalDrag(event) {
2511
- return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
2512
- }
2690
+ return [ start, end ]
2691
+ }
2513
2692
 
2514
- #handleTabKey(event) {
2515
- if (this.selection.isInsideList) {
2516
- return this.#handleTabForList(event)
2517
- } else if (this.selection.isInsideCodeBlock) {
2518
- return this.#handleTabForCode()
2693
+ function $applyStylePatchToNode(node, patch) {
2694
+ const prevStyles = getStyleObjectFromCSS(node.getStyle());
2695
+ const newStyles = { ...prevStyles };
2696
+
2697
+ for (const [ key, value ] of Object.entries(patch)) {
2698
+ if (value === null) {
2699
+ delete newStyles[key];
2700
+ } else {
2701
+ newStyles[key] = value;
2519
2702
  }
2520
- return false
2521
2703
  }
2522
2704
 
2523
- #handleTabForList(event) {
2524
- if (event.shiftKey && !this.selection.isIndentedList) return false
2705
+ const newCSSText = getCSSFromStyleObject(newStyles);
2706
+ node.setStyle(newCSSText);
2525
2707
 
2526
- event.preventDefault();
2527
- const command = event.shiftKey? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND;
2528
- return this.editor.dispatchCommand(command)
2529
- }
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");
2530
2712
 
2531
- #handleTabForCode() {
2532
- const selection = $getSelection();
2533
- return $isRangeSelection(selection) && selection.isCollapsed()
2713
+ if (shouldHaveHighlight !== hasHighlight) {
2714
+ $setCodeHighlightFormat(node, shouldHaveHighlight);
2534
2715
  }
2535
-
2536
- }
2537
-
2538
- function capitalize(str) {
2539
- return str.charAt(0).toUpperCase() + str.slice(1)
2540
2716
  }
2541
2717
 
2542
- function debounce(fn, wait) {
2543
- let timeout;
2718
+ function $setCodeHighlightFormat(node, shouldHaveHighlight) {
2719
+ const writable = node.getWritable();
2720
+ const IS_HIGHLIGHT = 1 << 7;
2544
2721
 
2545
- return (...args) => {
2546
- clearTimeout(timeout);
2547
- timeout = setTimeout(() => fn(...args), wait);
2722
+ if (shouldHaveHighlight) {
2723
+ writable.__format |= IS_HIGHLIGHT;
2724
+ } else {
2725
+ writable.__format &= ~IS_HIGHLIGHT;
2548
2726
  }
2549
2727
  }
2550
2728
 
2551
- function debounceAsync(fn, wait) {
2552
- let timeout;
2553
-
2554
- return (...args) => {
2555
- clearTimeout(timeout);
2556
-
2557
- return new Promise((resolve, reject) => {
2558
- timeout = setTimeout(async () => {
2559
- try {
2560
- const result = await fn(...args);
2561
- resolve(result);
2562
- } catch (err) {
2563
- reject(err);
2564
- }
2565
- }, wait);
2566
- })
2567
- }
2729
+ function toggleOrReplace(oldValue, newValue) {
2730
+ return oldValue === newValue ? null : newValue
2568
2731
  }
2569
2732
 
2570
- function delay(ms) {
2571
- return new Promise((resolve) => setTimeout(resolve, ms))
2733
+ function $syncHighlightWithStyle(textNode) {
2734
+ if (hasHighlightStyles(textNode.getStyle()) !== textNode.hasFormat("highlight")) {
2735
+ textNode.toggleFormat("highlight");
2736
+ }
2572
2737
  }
2573
2738
 
2574
- function nextFrame() {
2575
- return new Promise(requestAnimationFrame)
2576
- }
2739
+ function $syncHighlightWithCodeHighlightNode(node) {
2740
+ const parent = node.getParent();
2741
+ if (!$isCodeNode(parent)) return
2577
2742
 
2578
- function dasherize(value) {
2579
- return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
2580
- }
2743
+ const shouldHaveHighlight = hasHighlightStyles(node.getStyle());
2744
+ const hasHighlight = node.hasFormat("highlight");
2581
2745
 
2582
- function isUrl(string) {
2583
- try {
2584
- new URL(string);
2585
- return true
2586
- } catch {
2587
- return false
2746
+ if (shouldHaveHighlight !== hasHighlight) {
2747
+ $setCodeHighlightFormat(node, shouldHaveHighlight);
2588
2748
  }
2589
2749
  }
2590
2750
 
2591
- function normalizeFilteredText(string) {
2592
- return string
2593
- .toLowerCase()
2594
- .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
2595
- }
2596
-
2597
- function filterMatchPosition(text, potentialMatch) {
2598
- const normalizedText = normalizeFilteredText(text);
2599
- const normalizedMatch = normalizeFilteredText(potentialMatch);
2600
-
2601
- if (!normalizedMatch) return 0
2751
+ function $canonicalizePastedStyles(textNode, canonicalizers = []) {
2752
+ if ($hasPastedStyles(textNode)) {
2753
+ $setPastedStyles(textNode, false);
2602
2754
 
2603
- const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
2604
- return match ? match.index : -1
2605
- }
2755
+ const canonicalizedCSS = applyCanonicalizers(textNode.getStyle(), canonicalizers);
2756
+ textNode.setStyle(canonicalizedCSS);
2606
2757
 
2607
- function upcaseFirst(string) {
2608
- return string.charAt(0).toUpperCase() + string.slice(1)
2758
+ const selection = $getSelection();
2759
+ if (textNode.isSelected(selection)) {
2760
+ selection.setStyle(textNode.getStyle());
2761
+ selection.setFormat(textNode.getFormat());
2762
+ }
2763
+ }
2609
2764
  }
2610
2765
 
2611
- function escapeForRegExp(string) {
2612
- return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2766
+ function $setPastedStyles(textNode, value = true) {
2767
+ $setState(textNode, hasPastedStylesState, value);
2613
2768
  }
2614
2769
 
2615
- // Parses a value that may arrive as a boolean or as a string (e.g. from DOM
2616
- // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
2617
- function parseBoolean(value) {
2618
- if (typeof value === "string") return value === "true"
2619
- return Boolean(value)
2770
+ function $hasPastedStyles(textNode) {
2771
+ return $getState(textNode, hasPastedStylesState)
2620
2772
  }
2621
2773
 
2622
- // Payload: Record<nodeKey, { patch?, replace? }>
2623
- // - patch: plain object, shallow-merged into the existing node's properties
2624
- // - replace: a LexicalNode instance that replaces the node
2625
- const REWRITE_HISTORY_COMMAND = createCommand("REWRITE_HISTORY_COMMAND");
2626
-
2627
- class RewritableHistoryExtension extends LexxyExtension {
2628
- #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",
2629
2796
 
2630
- get lexicalExtension() {
2631
- return defineExtension({
2632
- name: "lexxy/rewritable-history",
2633
- dependencies: [ HistoryExtension ],
2634
- register: (editor, _config, state) => {
2635
- const historyOutput = state.getDependency(HistoryExtension).output;
2636
- this.#historyState = historyOutput.historyState.value;
2797
+ "insertTable",
2637
2798
 
2638
- return editor.registerCommand(
2639
- REWRITE_HISTORY_COMMAND,
2640
- (rewrites) => this.#rewriteHistory(rewrites),
2641
- COMMAND_PRIORITY_EDITOR
2642
- )
2643
- }
2644
- })
2799
+ "undo",
2800
+ "redo"
2801
+ ];
2802
+
2803
+ class CommandDispatcher {
2804
+ #selectionBeforeDrag = null
2805
+ #listeners = new ListenerBin()
2806
+
2807
+ static configureFor(editorElement) {
2808
+ return new CommandDispatcher(editorElement)
2645
2809
  }
2646
2810
 
2647
- get historyState() {
2648
- return this.#historyState
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;
2817
+
2818
+ this.#registerCommands();
2819
+ this.#registerKeyboardCommands();
2820
+ this.#registerDragAndDropHandlers();
2649
2821
  }
2650
2822
 
2651
- get #allHistoryEntries() {
2652
- const entries = Array.from(this.#historyState.undoStack);
2653
- if (this.#historyState.current) entries.push(this.#historyState.current);
2654
- return entries.concat(this.#historyState.redoStack)
2823
+ dispatchPaste(event) {
2824
+ return this.clipboard.paste(event)
2655
2825
  }
2656
2826
 
2657
- #rewriteHistory(rewrites) {
2658
- this.#applyRewritesImmediatelyToCurrentState(rewrites);
2659
- this.#applyRewritesToHistory(rewrites);
2827
+ dispatchBold() {
2828
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
2829
+ }
2660
2830
 
2661
- return true
2831
+ dispatchItalic() {
2832
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
2662
2833
  }
2663
2834
 
2664
- #applyRewritesImmediatelyToCurrentState(rewrites) {
2665
- $getEditor().update(() => {
2666
- for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
2667
- const node = $getNodeByKey(nodeKey);
2668
- if (!node) continue
2835
+ dispatchStrikethrough() {
2836
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
2837
+ }
2669
2838
 
2670
- if (patch) Object.assign(node.getWritable(), patch);
2671
- if (replace) node.replace(replace);
2672
- }
2673
- }, { discrete: true, tag: this.#getBackgroundUpdateTags() });
2839
+ dispatchUnderline() {
2840
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
2674
2841
  }
2675
2842
 
2676
- #applyRewritesToHistory(rewrites) {
2677
- const nodeKeys = Object.keys(rewrites);
2843
+ dispatchToggleHighlight(styles) {
2844
+ this.editor.dispatchCommand(TOGGLE_HIGHLIGHT_COMMAND, styles);
2845
+ }
2678
2846
 
2679
- for (const entry of this.#allHistoryEntries) {
2680
- if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue
2847
+ dispatchRemoveHighlight() {
2848
+ this.editor.dispatchCommand(REMOVE_HIGHLIGHT_COMMAND);
2849
+ }
2681
2850
 
2682
- const editorState = entry.editorState = safeCloneEditorState(entry.editorState);
2851
+ dispatchLink(url) {
2852
+ this.editor.update(() => {
2853
+ const selection = $getSelection();
2854
+ if (!$isRangeSelection(selection)) return
2683
2855
 
2684
- for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
2685
- const node = editorState._nodeMap.get(nodeKey);
2686
- if (!node) continue
2856
+ const anchorNode = selection.anchor.getNode();
2687
2857
 
2688
- if (patch) {
2689
- this.#patchNodeInEditorState(editorState, node, patch);
2690
- } else if (replace) {
2691
- this.#replaceNodeInEditorState(editorState, node, replace);
2692
- }
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);
2693
2865
  }
2694
- }
2866
+ });
2695
2867
  }
2696
2868
 
2697
- #entryHasSomeKeys(entry, nodeKeys) {
2698
- return nodeKeys.some(key => entry.editorState._nodeMap.has(key))
2699
- }
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
+ }
2700
2875
 
2701
- #getBackgroundUpdateTags() {
2702
- const tags = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
2703
- if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG); }
2704
- return tags
2876
+ $toggleLink(null);
2877
+ });
2705
2878
  }
2706
2879
 
2707
- #patchNodeInEditorState(editorState, node, patch) {
2708
- editorState._nodeMap.set(node.__key, $cloneNodeWithPatch(node, patch));
2709
- }
2880
+ dispatchInsertUnorderedList() {
2881
+ const selection = $getSelection();
2882
+ if (!$isRangeSelection(selection)) return
2710
2883
 
2711
- #replaceNodeInEditorState(editorState, node, replaceWith) {
2712
- editorState._nodeMap.set(node.__key, $cloneNodeAdoptingKeys(replaceWith, node));
2713
- }
2714
- }
2884
+ const anchorNode = selection.anchor.getNode();
2715
2885
 
2716
- function $cloneNodeWithPatch(node, patch) {
2717
- const clone = $cloneWithProperties(node);
2718
- Object.assign(clone, patch);
2719
- return clone
2720
- }
2886
+ if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
2887
+ this.contents.applyParagraphFormat();
2888
+ } else {
2889
+ this.contents.applyUnorderedListFormat();
2890
+ }
2891
+ }
2721
2892
 
2722
- function $cloneNodeAdoptingKeys(node, previousNode) {
2723
- const clone = $cloneWithProperties(node);
2724
- clone.__key = previousNode.__key;
2725
- clone.__parent = previousNode.__parent;
2726
- clone.__prev = previousNode.__prev;
2727
- clone.__next = previousNode.__next;
2728
- return clone
2729
- }
2893
+ dispatchInsertOrderedList() {
2894
+ const selection = $getSelection();
2895
+ if (!$isRangeSelection(selection)) return
2730
2896
 
2731
- // EditorState#clone() keeps the same map reference.
2732
- // A new Map is needed to prevent editing Lexical's internal map
2733
- // Warning: this bypasses DEV's safety map freezing
2734
- function safeCloneEditorState(editorState) {
2735
- const clone = editorState.clone();
2736
- clone._nodeMap = new Map(editorState._nodeMap);
2737
- return clone
2738
- }
2897
+ const anchorNode = selection.anchor.getNode();
2739
2898
 
2740
- class ActionTextAttachmentNode extends DecoratorNode {
2741
- static getType() {
2742
- return "action_text_attachment"
2899
+ if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
2900
+ this.contents.applyParagraphFormat();
2901
+ } else {
2902
+ this.contents.applyOrderedListFormat();
2903
+ }
2743
2904
  }
2744
2905
 
2745
- static clone(node) {
2746
- return new ActionTextAttachmentNode({ ...node }, node.__key)
2906
+ dispatchInsertQuoteBlock() {
2907
+ this.contents.toggleBlockquote();
2747
2908
  }
2748
2909
 
2749
- static importJSON(serializedNode) {
2750
- return new ActionTextAttachmentNode({ ...serializedNode })
2910
+ dispatchInsertCodeBlock() {
2911
+ if (this.selection.hasSelectedWordsInSingleLine) {
2912
+ this.#toggleInlineCode();
2913
+ } else {
2914
+ this.contents.toggleCodeBlock();
2915
+ }
2751
2916
  }
2752
2917
 
2753
- static importDOM() {
2754
- return {
2755
- [this.TAG_NAME]: () => {
2756
- return {
2757
- conversion: (attachment) => ({
2758
- node: new ActionTextAttachmentNode({
2759
- sgid: attachment.getAttribute("sgid"),
2760
- src: attachment.getAttribute("url"),
2761
- previewable: attachment.getAttribute("previewable"),
2762
- altText: attachment.getAttribute("alt"),
2763
- caption: attachment.getAttribute("caption"),
2764
- contentType: attachment.getAttribute("content-type"),
2765
- fileName: attachment.getAttribute("filename"),
2766
- fileSize: attachment.getAttribute("filesize"),
2767
- width: attachment.getAttribute("width"),
2768
- height: attachment.getAttribute("height")
2769
- })
2770
- }), priority: 1
2771
- }
2772
- },
2773
- "img": () => {
2774
- return {
2775
- conversion: (img) => {
2776
- const fileName = extractFileName(img.getAttribute("src") ?? "");
2777
- return {
2778
- node: new ActionTextAttachmentNode({
2779
- src: img.getAttribute("src"),
2780
- fileName: fileName,
2781
- caption: img.getAttribute("alt") || "",
2782
- contentType: "image/*",
2783
- width: img.getAttribute("width"),
2784
- height: img.getAttribute("height")
2785
- })
2786
- }
2787
- }, priority: 1
2788
- }
2789
- },
2790
- "video": () => {
2791
- return {
2792
- conversion: (video) => {
2793
- const videoSource = video.getAttribute("src") || video.querySelector("source")?.src;
2794
- const fileName = videoSource?.split("/")?.pop();
2795
- const contentType = video.querySelector("source")?.getAttribute("content-type") || "video/*";
2918
+ #toggleInlineCode() {
2919
+ const selection = $getSelection();
2920
+ if (!$isRangeSelection(selection)) return
2796
2921
 
2797
- return {
2798
- node: new ActionTextAttachmentNode({
2799
- src: videoSource,
2800
- fileName: fileName,
2801
- contentType: contentType
2802
- })
2803
- }
2804
- }, priority: 1
2805
- }
2922
+ if (!selection.isCollapsed()) {
2923
+ const textNodes = selection.getNodes().filter($isTextNode);
2924
+ const applyingCode = !textNodes.every((node) => node.hasFormat("code"));
2925
+
2926
+ if (applyingCode) {
2927
+ this.#stripInlineFormattingFromSelection(selection, textNodes);
2806
2928
  }
2807
2929
  }
2808
- }
2809
2930
 
2810
- static get TAG_NAME() {
2811
- return Lexxy.global.get("attachmentTagName")
2931
+ this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
2812
2932
  }
2813
2933
 
2814
- constructor({ tagName, sgid, src, previewSrc, previewable, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
2815
- super(key);
2816
-
2817
- this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
2818
- this.sgid = sgid;
2819
- this.src = src;
2820
- this.previewSrc = previewSrc;
2821
- this.previewable = parseBoolean(previewable);
2822
- this.pendingPreview = pendingPreview;
2823
- this.altText = altText || "";
2824
- this.caption = caption || "";
2825
- this.contentType = contentType || "";
2826
- this.fileName = fileName || "";
2827
- this.fileSize = fileSize;
2828
- this.width = width;
2829
- this.height = height;
2830
- this.uploadError = uploadError;
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;
2831
2941
 
2832
- this.editor = $getEditor();
2833
- }
2942
+ for (let i = 0; i < textNodes.length; i++) {
2943
+ const node = textNodes[i];
2944
+ if (node.getFormat() === 0) continue
2834
2945
 
2835
- createDOM() {
2836
- if (this.uploadError) return this.createDOMForError()
2837
- if (this.pendingPreview) return this.#createDOMForPendingPreview()
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();
2838
2950
 
2839
- const figure = this.createAttachmentFigure();
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);
2840
2957
 
2841
- if (this.isPreviewableAttachment) {
2842
- figure.appendChild(this.#createDOMForImage());
2843
- figure.appendChild(this.#createEditableCaption());
2844
- } else if (this.isVideo) {
2845
- figure.appendChild(this.#createDOMForFile());
2846
- figure.appendChild(this.#createEditableCaption());
2847
- } else {
2848
- figure.appendChild(this.#createDOMForFile());
2849
- figure.appendChild(this.#createDOMForNotImage());
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
+ }
2850
2965
  }
2851
-
2852
- return figure
2853
2966
  }
2854
2967
 
2855
- updateDOM(prevNode, dom) {
2856
- if (this.uploadError !== prevNode.uploadError) return true
2968
+ dispatchSetCodeLanguage(language) {
2969
+ this.editor.update(() => {
2970
+ if (!this.selection.isInsideCodeBlock) return
2857
2971
 
2858
- const caption = dom.querySelector("figcaption textarea");
2859
- if (caption && this.caption) {
2860
- caption.value = this.caption;
2861
- }
2972
+ const codeNode = this.selection.nearestNodeOfType(CodeNode);
2973
+ if (!codeNode) return
2862
2974
 
2863
- return false
2975
+ codeNode.setLanguage(language);
2976
+ });
2864
2977
  }
2865
2978
 
2866
- getTextContent() {
2867
- return `[${this.caption || this.fileName}]\n\n`
2979
+ dispatchInsertHorizontalDivider() {
2980
+ this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
2981
+ this.editor.focus();
2868
2982
  }
2869
2983
 
2870
- isInline() {
2871
- return this.isAttached() && !this.getParent().is($getNearestRootOrShadowRoot(this))
2984
+ dispatchSetFormatHeadingLarge() {
2985
+ this.contents.applyHeadingFormat("h2");
2872
2986
  }
2873
2987
 
2874
- exportDOM() {
2875
- const attachment = createElement(this.tagName, {
2876
- sgid: this.sgid,
2877
- previewable: this.previewable || null,
2878
- url: this.src,
2879
- alt: this.altText,
2880
- caption: this.caption,
2881
- "content-type": this.contentType,
2882
- filename: this.fileName,
2883
- filesize: this.fileSize,
2884
- width: this.width,
2885
- height: this.height,
2886
- presentation: "gallery"
2887
- });
2988
+ dispatchSetFormatHeadingMedium() {
2989
+ this.contents.applyHeadingFormat("h3");
2990
+ }
2888
2991
 
2889
- return { element: attachment }
2992
+ dispatchSetFormatHeadingSmall() {
2993
+ this.contents.applyHeadingFormat("h4");
2890
2994
  }
2891
2995
 
2892
- exportJSON() {
2893
- return {
2894
- type: "action_text_attachment",
2895
- version: 1,
2896
- tagName: this.tagName,
2897
- sgid: this.sgid,
2898
- src: this.src,
2899
- previewable: this.previewable,
2900
- altText: this.altText,
2901
- caption: this.caption,
2902
- contentType: this.contentType,
2903
- fileName: this.fileName,
2904
- fileSize: this.fileSize,
2905
- width: this.width,
2906
- height: this.height
2907
- }
2996
+ dispatchSetFormatParagraph() {
2997
+ this.contents.applyParagraphFormat();
2908
2998
  }
2909
2999
 
2910
- decorate() {
2911
- return null
3000
+ dispatchClearFormatting() {
3001
+ this.contents.clearFormatting();
2912
3002
  }
2913
3003
 
2914
- createDOMForError() {
2915
- const figure = this.createAttachmentFigure();
2916
- figure.classList.add("attachment--error");
2917
- figure.appendChild(createElement("div", { innerText: `Error uploading ${this.fileName || "file"}` }));
2918
- return figure
3004
+ dispatchUploadImage() {
3005
+ this.#dispatchUploadAttachment("image/*,video/*");
2919
3006
  }
2920
3007
 
2921
- createAttachmentFigure(previewable = this.isPreviewableAttachment) {
2922
- const figure = createAttachmentFigure(this.contentType, previewable, this.fileName);
2923
- figure.draggable = true;
2924
- figure.dataset.lexicalNodeKey = this.__key;
3008
+ dispatchUploadFile() {
3009
+ this.#dispatchUploadAttachment();
3010
+ }
2925
3011
 
2926
- const deleteButton = createElement("lexxy-node-delete-button");
2927
- 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
+ };
2928
3021
 
2929
- return figure
2930
- }
3022
+ if (accept) attributes.accept = accept;
2931
3023
 
2932
- get isPreviewableAttachment() {
2933
- return this.isPreviewableImage || this.previewable
2934
- }
3024
+ const input = createElement("input", attributes);
2935
3025
 
2936
- get isPreviewableImage() {
2937
- return isPreviewableImage(this.contentType)
3026
+ // Append and remove to make testable
3027
+ this.editorElement.appendChild(input);
3028
+ input.click();
3029
+ setTimeout(() => input.remove(), 1000);
2938
3030
  }
2939
3031
 
2940
- get isVideo() {
2941
- return this.contentType.startsWith("video/")
3032
+ dispatchInsertTable() {
3033
+ this.editor.dispatchCommand(INSERT_TABLE_COMMAND, { "rows": 3, "columns": 3, "includeHeaders": true });
2942
3034
  }
2943
3035
 
2944
- #createDOMForPendingPreview() {
2945
- const figure = this.createAttachmentFigure(false);
2946
- figure.appendChild(this.#createDOMForFile());
2947
- figure.appendChild(this.#createDOMForNotImage());
2948
- this.#pollForPreview(figure);
2949
- return figure
3036
+ dispatchUndo() {
3037
+ this.editor.dispatchCommand(UNDO_COMMAND, undefined);
2950
3038
  }
2951
3039
 
2952
- patchAndRewriteHistory(patch) {
2953
- this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
2954
- [this.getKey()]: { patch }
2955
- });
3040
+ dispatchRedo() {
3041
+ this.editor.dispatchCommand(REDO_COMMAND, undefined);
2956
3042
  }
2957
3043
 
2958
- replaceAndRewriteHistory(node) {
2959
- this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
2960
- [this.getKey()]: { replace: node }
2961
- });
3044
+ dispose() {
3045
+ this.#listeners.dispose();
2962
3046
  }
2963
3047
 
2964
- #createDOMForImage(options = {}) {
2965
- const initialSrc = this.previewSrc || this.src;
2966
- const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
2967
-
2968
- if (this.previewable && !this.isPreviewableImage) {
2969
- img.onerror = () => this.#swapPreviewToFileDOM(img);
3048
+ #registerCommands() {
3049
+ for (const command of COMMANDS) {
3050
+ const methodName = `dispatch${capitalize(command)}`;
3051
+ this.#registerCommandHandler(command, 0, this[methodName].bind(this));
2970
3052
  }
2971
3053
 
2972
- if (this.previewSrc) {
2973
- this.#preloadAndSwapSrc(img);
2974
- }
3054
+ this.#registerCommandHandler(PASTE_COMMAND, COMMAND_PRIORITY_LOW, this.dispatchPaste.bind(this));
3055
+ }
2975
3056
 
2976
- const container = createElement("div", { className: "attachment__container" });
2977
- container.appendChild(img);
2978
- return container
3057
+ #registerCommandHandler(command, priority, handler) {
3058
+ this.#listeners.track(this.editor.registerCommand(command, handler, priority));
2979
3059
  }
2980
3060
 
2981
- #preloadAndSwapSrc(img) {
2982
- const previewSrc = this.previewSrc;
2983
- const serverImage = new Image();
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
+ }
2984
3065
 
2985
- serverImage.onload = () => this.#handleImageLoaded(img, previewSrc);
2986
- serverImage.onerror = () => this.#handleImageLoadError(previewSrc);
2987
- serverImage.src = this.src;
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
3070
+
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
2988
3078
  }
2989
3079
 
2990
- #handleImageLoaded(img, previewSrc) {
2991
- img.src = this.src;
2992
- this.patchAndRewriteHistory({ previewSrc: null });
2993
- this.#revokePreviewSrc(previewSrc);
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
+ );
3090
+ }
2994
3091
  }
2995
3092
 
2996
- #handleImageLoadError(previewSrc) {
2997
- this.patchAndRewriteHistory({
2998
- previewSrc: null,
2999
- uploadError: true
3000
- });
3001
- this.#revokePreviewSrc(previewSrc);
3093
+ #handleDragEnter(event) {
3094
+ if (this.#isInternalDrag(event)) return
3095
+
3096
+ this.dragCounter++;
3097
+ if (this.dragCounter === 1) {
3098
+ this.#saveSelectionBeforeDrag();
3099
+ this.editor.getRootElement().classList.add("lexxy-editor--drag-over");
3100
+ }
3002
3101
  }
3003
3102
 
3004
- #revokePreviewSrc(previewSrc) {
3005
- if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc);
3103
+ #handleDragLeave(event) {
3104
+ if (this.#isInternalDrag(event)) return
3105
+
3106
+ this.dragCounter--;
3107
+ if (this.dragCounter === 0) {
3108
+ this.#selectionBeforeDrag = null;
3109
+ this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
3110
+ }
3006
3111
  }
3007
3112
 
3008
- #swapPreviewToFileDOM(img) {
3009
- const figure = img.closest("figure.attachment");
3010
- if (!figure) return
3113
+ #handleDragOver(event) {
3114
+ if (this.#isInternalDrag(event)) return
3011
3115
 
3012
- this.#swapFigureContent(figure, "attachment--preview", "attachment--file", () => {
3013
- figure.appendChild(this.#createDOMForFile());
3014
- figure.appendChild(this.#createDOMForNotImage());
3015
- });
3116
+ event.preventDefault();
3016
3117
  }
3017
3118
 
3018
- #pollForPreview(figure) {
3019
- let attempt = 0;
3020
- const maxAttempts = 10;
3119
+ #handleDrop(event) {
3120
+ if (this.#isInternalDrag(event)) return
3021
3121
 
3022
- const tryLoad = () => {
3023
- if (!this.editor.read(() => this.isAttached())) return
3122
+ event.preventDefault();
3024
3123
 
3025
- const img = new Image();
3026
- const cacheBustedSrc = `${this.src}${this.src.includes("?") ? "&" : "?"}_=${Date.now()}`;
3124
+ this.dragCounter = 0;
3125
+ this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
3027
3126
 
3028
- img.onload = () => {
3029
- if (!this.editor.read(() => this.isAttached())) return
3127
+ const dataTransfer = event.dataTransfer;
3128
+ if (!dataTransfer) return
3030
3129
 
3031
- // The placeholder is a file-type icon SVG (86×100). A real thumbnail
3032
- // generated from PDF/video content is significantly larger.
3033
- if (img.naturalWidth > 150 && img.naturalHeight > 150) {
3034
- this.#swapToPreviewDOM(figure, cacheBustedSrc);
3035
- } else {
3036
- retry();
3037
- }
3038
- };
3039
- img.onerror = () => retry();
3040
- img.src = cacheBustedSrc;
3041
- };
3130
+ const files = Array.from(dataTransfer.files);
3131
+ if (!files.length) return
3042
3132
 
3043
- const retry = () => {
3044
- attempt++;
3045
- if (attempt < maxAttempts && this.editor.read(() => this.isAttached())) {
3046
- const delay = Math.min(2000 * Math.pow(1.5, attempt), 15000);
3047
- setTimeout(tryLoad, delay);
3048
- }
3049
- };
3133
+ this.#restoreSelectionBeforeDrag();
3134
+ this.contents.uploadFiles(files, { selectLast: true });
3050
3135
 
3051
- // Give the server time to start processing before the first attempt
3052
- setTimeout(tryLoad, 3000);
3136
+ this.editor.focus();
3053
3137
  }
3054
3138
 
3055
- #swapToPreviewDOM(figure, previewSrc) {
3056
- this.#swapFigureContent(figure, "attachment--file", "attachment--preview", () => {
3057
- const img = createElement("img", { src: previewSrc, draggable: false, alt: this.altText });
3058
- img.onerror = () => this.#swapPreviewToFileDOM(img);
3059
- const container = createElement("div", { className: "attachment__container" });
3060
- container.appendChild(img);
3061
- figure.appendChild(container);
3062
- figure.appendChild(this.#createEditableCaption());
3139
+ #saveSelectionBeforeDrag() {
3140
+ this.editor.getEditorState().read(() => {
3141
+ this.#selectionBeforeDrag = $getSelection()?.clone();
3063
3142
  });
3064
-
3065
- this.patchAndRewriteHistory({ pendingPreview: false });
3066
3143
  }
3067
3144
 
3068
- #swapFigureContent(figure, fromClass, toClass, renderContent) {
3069
- figure.className = figure.className.replace(fromClass, toClass);
3145
+ #restoreSelectionBeforeDrag() {
3146
+ if (!this.#selectionBeforeDrag) return
3070
3147
 
3071
- for (const child of [ ...figure.querySelectorAll(".attachment__container, .attachment__icon, figcaption") ]) {
3072
- child.remove();
3073
- }
3148
+ this.editor.update(() => {
3149
+ $setSelection(this.#selectionBeforeDrag);
3150
+ });
3074
3151
 
3075
- renderContent();
3152
+ this.#selectionBeforeDrag = null;
3076
3153
  }
3077
3154
 
3078
- get #imageDimensions() {
3079
- if (this.width && this.height) {
3080
- return { width: this.width, height: this.height }
3081
- } else {
3082
- return {}
3083
- }
3155
+ #isInternalDrag(event) {
3156
+ return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
3084
3157
  }
3085
3158
 
3086
- #createDOMForFile() {
3087
- const extension = this.fileName ? this.fileName.split(".").pop().toLowerCase() : "unknown";
3088
- 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
3089
3166
  }
3090
3167
 
3091
- #createDOMForNotImage() {
3092
- const figcaption = createElement("figcaption", { className: "attachment__caption" });
3093
-
3094
- const nameTag = createElement("strong", { className: "attachment__name", textContent: this.caption || this.fileName });
3095
-
3096
- figcaption.appendChild(nameTag);
3097
-
3098
- if (this.fileSize) {
3099
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.fileSize) });
3100
- figcaption.appendChild(sizeSpan);
3101
- }
3168
+ #handleTabForList(event) {
3169
+ if (event.shiftKey && !this.selection.isIndentedList) return false
3102
3170
 
3103
- return figcaption
3171
+ event.preventDefault();
3172
+ const command = event.shiftKey? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND;
3173
+ return this.editor.dispatchCommand(command)
3104
3174
  }
3105
3175
 
3106
- #createEditableCaption() {
3107
- const caption = createElement("figcaption", { className: "attachment__caption" });
3108
- const input = createElement("textarea", {
3109
- value: this.caption,
3110
- placeholder: this.fileName,
3111
- rows: "1"
3112
- });
3113
-
3114
- input.addEventListener("focusin", () => input.placeholder = "Add caption...");
3115
- input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
3116
- input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
3117
- input.addEventListener("copy", (event) => event.stopPropagation());
3118
- input.addEventListener("cut", (event) => event.stopPropagation());
3119
- input.addEventListener("paste", (event) => event.stopPropagation());
3176
+ #handleTabForCode() {
3177
+ const selection = $getSelection();
3178
+ return $isRangeSelection(selection) && selection.isCollapsed()
3179
+ }
3120
3180
 
3121
- caption.appendChild(input);
3181
+ }
3122
3182
 
3123
- return caption
3124
- }
3183
+ function capitalize(str) {
3184
+ return str.charAt(0).toUpperCase() + str.slice(1)
3185
+ }
3125
3186
 
3126
- #handleCaptionInputBlurred(event) {
3127
- this.#updateCaptionValueFromInput(event.target);
3128
- }
3187
+ function debounce(fn, wait) {
3188
+ let timeout;
3129
3189
 
3130
- #updateCaptionValueFromInput(input) {
3131
- input.placeholder = this.fileName;
3132
- this.editor.update(() => {
3133
- this.getWritable().caption = input.value;
3134
- });
3190
+ return (...args) => {
3191
+ clearTimeout(timeout);
3192
+ timeout = setTimeout(() => fn(...args), wait);
3135
3193
  }
3194
+ }
3136
3195
 
3137
- #handleCaptionInputKeydown(event) {
3138
- if (event.key === "Enter") {
3139
- event.preventDefault();
3140
- event.target.blur();
3196
+ function debounceAsync(fn, wait) {
3197
+ let timeout;
3141
3198
 
3142
- this.editor.update(() => {
3143
- // Place the cursor after the current image
3144
- this.selectNext(0, 0);
3145
- }, {
3146
- tag: HISTORY_MERGE_TAG
3147
- });
3148
- }
3199
+ return (...args) => {
3200
+ clearTimeout(timeout);
3149
3201
 
3150
- // Stop all keydown events from bubbling to the Lexical root element.
3151
- // The caption textarea is outside Lexical's content model and should
3152
- // handle its own keyboard events natively (Ctrl+A, Ctrl+C, Ctrl+X, etc.).
3153
- 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
+ })
3154
3212
  }
3155
3213
  }
3156
3214
 
3157
- function $createActionTextAttachmentNode(...args) {
3158
- return new ActionTextAttachmentNode(...args)
3215
+ function delay(ms) {
3216
+ return new Promise((resolve) => setTimeout(resolve, ms))
3159
3217
  }
3160
3218
 
3161
- function $isActionTextAttachmentNode(node) {
3162
- return node instanceof ActionTextAttachmentNode
3219
+ function nextFrame() {
3220
+ return new Promise(requestAnimationFrame)
3163
3221
  }
3164
3222
 
3165
3223
  class Selection {
@@ -4646,7 +4704,6 @@ class NodeInserter {
4646
4704
  static for(selection) {
4647
4705
  const INSERTERS = [
4648
4706
  CodeNodeInserter,
4649
- QuoteNodeInserter,
4650
4707
  ShadowRootNodeInserter,
4651
4708
  NodeSelectionNodeInserter
4652
4709
  ];
@@ -4695,25 +4752,6 @@ class CodeNodeInserter extends NodeInserter {
4695
4752
 
4696
4753
  }
4697
4754
 
4698
- // Lexical will split a QuoteNode when inserting other Elements - we want them simply inserted as-is
4699
- class QuoteNodeInserter extends NodeInserter {
4700
- static handles(selection) {
4701
- return $getNearestNodeOfType(selection.anchor?.getNode(), QuoteNode)
4702
- }
4703
-
4704
- insertNodes(nodes) {
4705
- if (!this.selection.isCollapsed()) { this.selection.removeText(); }
4706
-
4707
- $ensureForwardRangeSelection(this.selection);
4708
- let lastNode = this.selection.focus.getNode();
4709
- for (const node of nodes) {
4710
- lastNode = lastNode.insertAfter(node);
4711
- }
4712
-
4713
- lastNode.selectEnd();
4714
- }
4715
- }
4716
-
4717
4755
  class ShadowRootNodeInserter extends NodeInserter {
4718
4756
  static handles(selection) {
4719
4757
  return $isShadowRoot(selection?.anchor.getNode())
@@ -4768,7 +4806,7 @@ class Contents {
4768
4806
  this.editor.update(() => {
4769
4807
  if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4770
4808
 
4771
- const nodes = $generateNodesFromDOM(this.editor, doc);
4809
+ const nodes = $generateFilteredNodesFromDOM(this.editorElement, doc);
4772
4810
  if (!this.#insertUploadNodes(nodes)) {
4773
4811
  this.insertAtCursor(...nodes);
4774
4812
  }
@@ -4861,7 +4899,7 @@ class Contents {
4861
4899
  } else {
4862
4900
  topLevelElements.filter($isQuoteNode).forEach(node => this.#unwrap(node));
4863
4901
 
4864
- this.#splitParagraphsAtLineBreaks(selection);
4902
+ $splitParagraphsAtLineBreakBoundaries(selection);
4865
4903
 
4866
4904
  const elements = this.#topLevelElementsInSelection(selection);
4867
4905
  if (elements.length === 0) return
@@ -5317,16 +5355,19 @@ class Contents {
5317
5355
 
5318
5356
  #createCustomAttachmentNodeWithHtml(html, options = {}) {
5319
5357
  const attachmentConfig = typeof options === "object" ? options : {};
5320
-
5358
+ const contentType = attachmentConfig.contentType || "text/html";
5359
+ if (!this.editorElement.permitsAttachmentContentType(contentType)) {
5360
+ return this.#createHtmlNodeWith(html)
5361
+ }
5321
5362
  return new CustomActionTextAttachmentNode({
5322
5363
  sgid: attachmentConfig.sgid || null,
5323
- contentType: "text/html",
5324
- innerHtml: html
5364
+ contentType,
5365
+ innerHtml: html,
5325
5366
  })
5326
5367
  }
5327
5368
 
5328
5369
  #createHtmlNodeWith(html) {
5329
- const htmlNodes = $generateNodesFromDOM(this.editor, parseHtml(html));
5370
+ const htmlNodes = $generateFilteredNodesFromDOM(this.editorElement, parseHtml(html));
5330
5371
  return htmlNodes[0] || $createParagraphNode()
5331
5372
  }
5332
5373
 
@@ -5364,7 +5405,13 @@ class Clipboard {
5364
5405
  paste(event) {
5365
5406
  const clipboardData = event.clipboardData;
5366
5407
 
5367
- if (!clipboardData || this.#isPastingIntoCodeBlock()) return false
5408
+ if (!clipboardData) return false
5409
+
5410
+ if (this.#isPastingIntoCodeBlock()) {
5411
+ this.#pastePlainTextIntoCodeBlock(clipboardData);
5412
+ event.preventDefault();
5413
+ return true
5414
+ }
5368
5415
 
5369
5416
  if (this.#isPlainTextOrURLPasted(clipboardData)) {
5370
5417
  this.#pastePlainText(clipboardData);
@@ -5413,6 +5460,16 @@ class Clipboard {
5413
5460
  return result
5414
5461
  }
5415
5462
 
5463
+ #pastePlainTextIntoCodeBlock(clipboardData) {
5464
+ const text = clipboardData.getData("text/plain");
5465
+ if (!text) return
5466
+
5467
+ this.editor.update(() => {
5468
+ const selection = $getSelection();
5469
+ if ($isRangeSelection(selection)) selection.insertRawText(text);
5470
+ }, { tag: PASTE_TAG });
5471
+ }
5472
+
5416
5473
  #pastePlainText(clipboardData) {
5417
5474
  const item = clipboardData.items[0];
5418
5475
  item.getAsString((text) => {
@@ -6510,39 +6567,16 @@ class EarlyEscapeCodeNode extends CodeNode {
6510
6567
  }
6511
6568
 
6512
6569
  insertNewAfter(selection, restoreSelection) {
6513
- if (!selection.isCollapsed()) return super.insertNewAfter(selection, restoreSelection)
6514
-
6515
- // Clamp element-type selection offsets that may have been invalidated
6516
- // by the code retokenizer. The retokenizer's $updateAndRetainSelection
6517
- // restores the element offset verbatim after re-tokenizing, but when
6518
- // highlight splits changed the child count before retokenization, the
6519
- // restored offset can exceed the current child count. Without clamping,
6520
- // CodeNode.insertNewAfter passes the stale offset to splice(), which
6521
- // throws "start + deleteCount > oldSize".
6522
- this.#clampSelectionOffset(selection);
6523
-
6524
- if (this.#isCursorAtStart(selection)) {
6525
- this.insertBefore($createParagraphNode());
6526
- return null
6527
- }
6528
-
6529
- if (this.#isCursorOnEmptyLastLine(selection)) {
6530
- $trimTrailingBlankNodes(this);
6531
-
6532
- const paragraph = $createParagraphNode();
6533
- this.insertAfter(paragraph);
6534
- return paragraph
6535
- }
6536
-
6537
- return super.insertNewAfter(selection, restoreSelection)
6538
- }
6539
-
6540
- #clampSelectionOffset(selection) {
6541
- const childrenSize = this.getChildrenSize();
6542
- for (const point of [ selection.anchor, selection.focus ]) {
6543
- if (point.type === "element" && point.key === this.__key && point.offset > childrenSize) {
6544
- point.set(this.__key, childrenSize, "element");
6545
- }
6570
+ if ($hasUpdateTag(PASTE_TAG) || !selection.isCollapsed()) {
6571
+ return super.insertNewAfter(selection, restoreSelection)
6572
+ } else if (this.#isCursorAtStart(selection)) {
6573
+ return this.#insertParagraphBefore()
6574
+ } else if (this.#isCursorOnWhitespaceOnlyLastLine(selection)) {
6575
+ return this.#insertBlankLineBelow(selection, restoreSelection)
6576
+ } else if (this.#isCursorOnEmptyLastLine(selection)) {
6577
+ return this.#escapeToNewParagraphAfter()
6578
+ } else {
6579
+ return super.insertNewAfter(selection, restoreSelection)
6546
6580
  }
6547
6581
  }
6548
6582
 
@@ -6561,6 +6595,32 @@ class EarlyEscapeCodeNode extends CodeNode {
6561
6595
  return textContent === "" || textContent.endsWith("\n")
6562
6596
  }
6563
6597
 
6598
+ #isCursorOnWhitespaceOnlyLastLine(selection) {
6599
+ if (!$isCursorOnLastLine(selection)) return false
6600
+
6601
+ const textContent = this.getTextContent();
6602
+ const lastNewlineIndex = textContent.lastIndexOf("\n");
6603
+ const lastLine = lastNewlineIndex === -1 ? textContent : textContent.slice(lastNewlineIndex + 1);
6604
+ return lastLine.length > 0 && lastLine.trim() === ""
6605
+ }
6606
+
6607
+ #insertParagraphBefore() {
6608
+ this.insertBefore($createParagraphNode());
6609
+ return null
6610
+ }
6611
+
6612
+ #insertBlankLineBelow(selection, restoreSelection) {
6613
+ super.insertNewAfter(selection, restoreSelection);
6614
+ this.getLastChild().remove();
6615
+ return null
6616
+ }
6617
+
6618
+ #escapeToNewParagraphAfter() {
6619
+ $trimTrailingBlankNodes(this);
6620
+ const paragraph = $createParagraphNode();
6621
+ this.insertAfter(paragraph);
6622
+ return paragraph
6623
+ }
6564
6624
  }
6565
6625
 
6566
6626
  class EarlyEscapeListItemNode extends ListItemNode {
@@ -6887,6 +6947,25 @@ class LexicalEditorElement extends HTMLElement {
6887
6947
  return this.dataset.blobUrlTemplate
6888
6948
  }
6889
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
+
6890
6969
  get isEmpty() {
6891
6970
  return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
6892
6971
  }
@@ -6965,7 +7044,7 @@ class LexicalEditorElement extends HTMLElement {
6965
7044
  }
6966
7045
 
6967
7046
  get #isContentFocused() {
6968
- return !!this.editorContentElement && this.editorContentElement.contains(document.activeElement)
7047
+ return !!this.editor && isEditorFocused(this.editor)
6969
7048
  }
6970
7049
 
6971
7050
  get value() {
@@ -6979,14 +7058,24 @@ class LexicalEditorElement extends HTMLElement {
6979
7058
  }
6980
7059
 
6981
7060
  set value(html) {
7061
+ const editorHasFocus = this.#isContentFocused;
7062
+
6982
7063
  this.editor.update(() => {
7064
+ if (editorHasFocus) {
7065
+ // Address Safari inconsistently placing the cursor in the contenteditable by forcing focus back onto the editor
7066
+ // Use direct `editor.focus` to bypass the pre-existing focus optimization and skip the callback
7067
+ $onUpdate(() => this.editor.focus());
7068
+ } else {
7069
+ $addUpdateTag(SKIP_DOM_SELECTION_TAG);
7070
+ }
7071
+
6983
7072
  $getRoot()
6984
7073
  .clear()
6985
7074
  .selectEnd()
6986
7075
  .insertNodes(this.#parseHtmlIntoLexicalNodes(html));
6987
7076
 
6988
7077
  this.#toggleEmptyStatus();
6989
- }, { discrete: true, tag: SKIP_DOM_SELECTION_TAG });
7078
+ }, { discrete: true });
6990
7079
  }
6991
7080
 
6992
7081
  get canUndo() {
@@ -6999,7 +7088,7 @@ class LexicalEditorElement extends HTMLElement {
6999
7088
 
7000
7089
  #parseHtmlIntoLexicalNodes(html) {
7001
7090
  if (!html) html = "<p></p>";
7002
- const nodes = $generateNodesFromDOM(this.editor, parseHtml(`${html}`));
7091
+ const nodes = $generateFilteredNodesFromDOM(this, parseHtml(`${html}`));
7003
7092
 
7004
7093
  return nodes
7005
7094
  .filter(this.#isNotWhitespaceOnlyNode)
@@ -7031,6 +7120,7 @@ class LexicalEditorElement extends HTMLElement {
7031
7120
  this.#handleEnter();
7032
7121
  this.#registerFocusEvents();
7033
7122
  this.#registerHistoryEvents();
7123
+ this.#registerFileAcceptFilter();
7034
7124
  this.#attachDebugHooks();
7035
7125
  this.#attachToolbar();
7036
7126
  this.#configureSanitizer();
@@ -7038,6 +7128,16 @@ class LexicalEditorElement extends HTMLElement {
7038
7128
  this.#resetBeforeTurboCaches();
7039
7129
  }
7040
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
+
7041
7141
  #createEditor() {
7042
7142
  this.editorContentElement ||= this.#createEditorContentElement();
7043
7143
  this.appendChild(this.editorContentElement);
@@ -7981,6 +8081,8 @@ class LexicalPromptElement extends HTMLElement {
7981
8081
  }
7982
8082
 
7983
8083
  #addTriggerListener() {
8084
+ if (!this.#promptContentTypePermitted) return
8085
+
7984
8086
  this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => {
7985
8087
  editorState.read(() => {
7986
8088
  if (this.#selection.isInsideCodeBlock) return
@@ -8014,6 +8116,19 @@ class LexicalPromptElement extends HTMLElement {
8014
8116
  }));
8015
8117
  }
8016
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
+
8017
8132
  #addCursorPositionListener() {
8018
8133
  this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => {
8019
8134
  if (this.closed) return
@@ -8320,7 +8435,7 @@ class LexicalPromptElement extends HTMLElement {
8320
8435
  }
8321
8436
 
8322
8437
  #buildEditableTextNodes(template) {
8323
- return $generateNodesFromDOM(this.#editor, parseHtml(`${template.innerHTML}`))
8438
+ return $generateFilteredNodesFromDOM(this.#editorElement, parseHtml(`${template.innerHTML}`))
8324
8439
  }
8325
8440
 
8326
8441
  #insertTemplatesAsAttachments(templates, stringToReplace, fallbackSgid = null) {
@@ -8332,8 +8447,10 @@ class LexicalPromptElement extends HTMLElement {
8332
8447
  }
8333
8448
 
8334
8449
  #buildAttachmentNodes(templates, fallbackSgid = null) {
8335
- return templates.map(
8336
- template => this.#buildAttachmentNode(
8450
+ return templates
8451
+ .filter(template => this.#editorElement.permitsAttachmentContentType(
8452
+ template.getAttribute("content-type") || this.#defaultPromptContentType))
8453
+ .map(template => this.#buildAttachmentNode(
8337
8454
  template.innerHTML,
8338
8455
  template.getAttribute("content-type") || this.#defaultPromptContentType,
8339
8456
  template.getAttribute("sgid") || fallbackSgid