@37signals/lexxy 0.7.0-beta → 0.7.2-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lexxy.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- import Prism from 'prismjs';
1
+ import 'prismjs';
2
2
  import 'prismjs/components/prism-clike';
3
3
  import 'prismjs/components/prism-markup';
4
4
  import 'prismjs/components/prism-markup-templating';
@@ -10,12 +10,15 @@ import 'prismjs/components/prism-json';
10
10
  import 'prismjs/components/prism-diff';
11
11
  import DOMPurify from 'dompurify';
12
12
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
13
- import { $isTextNode, TextNode, $isRangeSelection, SKIP_DOM_SELECTION_TAG, $getSelection, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_DELETE_COMMAND, KEY_BACKSPACE_COMMAND, SELECTION_CHANGE_COMMAND, $createNodeSelection, $setSelection, $createParagraphNode, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, $insertNodes, $createLineBreakNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, createEditor, BLUR_COMMAND, FOCUS_COMMAND, KEY_DOWN_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
13
+ import { $isTextNode, TextNode, $isRangeSelection, SKIP_DOM_SELECTION_TAG, $getSelection, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_DELETE_COMMAND, KEY_BACKSPACE_COMMAND, SELECTION_CHANGE_COMMAND, $createNodeSelection, $setSelection, $createParagraphNode, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, $insertNodes, $createLineBreakNode, createCommand, createState, defineExtension, $setState, $getState, $hasUpdateTag, PASTE_TAG, CLEAR_HISTORY_COMMAND, $addUpdateTag, BLUR_COMMAND, FOCUS_COMMAND, KEY_DOWN_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
14
14
  import { $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListNode, $getListDepth, $createListNode, ListItemNode, registerList } from '@lexical/list';
15
- import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
15
+ import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, RichTextExtension, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
16
16
  import { $isCodeNode, CodeNode, normalizeCodeLang, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
17
17
  import { $isLinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
18
18
  import { $getTableCellNodeFromLexicalNode, TableNode, INSERT_TABLE_COMMAND, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, TableCellNode, TableRowNode, registerTablePlugin, registerTableSelectionObserver, setScrollableTablesActive, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $getElementForTableNode, $isTableCellNode, TableCellHeaderStates } from '@lexical/table';
19
+ import { createElement, createAttachmentFigure, isPreviewableImage, dispatchCustomEvent, parseHtml, dispatch, generateDomId } from './lexxy_helpers.esm.js';
20
+ export { highlightCode as highlightAll, highlightCode } from './lexxy_helpers.esm.js';
21
+ import { buildEditorFromExtensions } from '@lexical/extension';
19
22
  import { registerPlainText } from '@lexical/plain-text';
20
23
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
21
24
  import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
@@ -61,8 +64,15 @@ class Configuration {
61
64
  }
62
65
  }
63
66
 
67
+ function range(from, to) {
68
+ return [ ...Array(1 + to - from).keys() ].map(i => i + from)
69
+ }
70
+
64
71
  const global = new Configuration({
65
- attachmentTagName: "action-text-attachment"
72
+ attachmentTagName: "action-text-attachment",
73
+ attachmentContentTypeNamespace: "actiontext",
74
+ authenticatedUploads: false,
75
+ extensions: []
66
76
  });
67
77
 
68
78
  const presets = new Configuration({
@@ -72,6 +82,16 @@ const presets = new Configuration({
72
82
  multiLine: true,
73
83
  richText: true,
74
84
  toolbar: true,
85
+ highlight: {
86
+ buttons: {
87
+ color: range(1, 9).map(n => `var(--highlight-${n})`),
88
+ "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
89
+ },
90
+ permit: {
91
+ color: [],
92
+ "background-color": []
93
+ }
94
+ }
75
95
  }
76
96
  });
77
97
 
@@ -166,15 +186,21 @@ function isPrintableCharacter(event) {
166
186
  return event.key.length === 1
167
187
  }
168
188
 
169
- function extendTextNodeConversion(conversionName, callback = (textNode => textNode)) {
189
+ function extendTextNodeConversion(conversionName, ...callbacks) {
170
190
  return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
171
191
  ...conversionOutput,
172
192
  forChild: (lexicalNode, parentNode) => {
173
193
  const originalForChild = conversionOutput?.forChild ?? (x => x);
174
194
  let childNode = originalForChild(lexicalNode, parentNode);
175
195
 
176
- if ($isTextNode(childNode)) childNode = callback(childNode, element) ?? childNode;
177
- return childNode
196
+
197
+ if ($isTextNode(childNode)) {
198
+ childNode = callbacks.reduce(
199
+ (childNode, callback) => callback(childNode, element) ?? childNode,
200
+ childNode
201
+ );
202
+ return childNode
203
+ }
178
204
  }
179
205
  }))
180
206
  }
@@ -206,6 +232,58 @@ function hasHighlightStyles(cssOrStyles) {
206
232
  return !!(styles.color || styles["background-color"])
207
233
  }
208
234
 
235
+ class StyleCanonicalizer {
236
+ constructor(property, allowedValues= []) {
237
+ this._property = property;
238
+ this._allowedValues = allowedValues;
239
+ this._canonicalValues = this.#allowedValuesIdentityObject;
240
+ }
241
+
242
+ applyCanonicalization(css) {
243
+ const styles = { ...getStyleObjectFromCSS(css) };
244
+
245
+ styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
246
+ if (!styles[this._property]) {
247
+ delete styles[this._property];
248
+ }
249
+
250
+ return getCSSFromStyleObject(styles)
251
+ }
252
+
253
+ getCanonicalAllowedValue(value) {
254
+ return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
255
+ }
256
+
257
+ // Private
258
+
259
+ get #allowedValuesIdentityObject() {
260
+ return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
261
+ }
262
+
263
+ #resolveCannonicalValue(value) {
264
+ let index = this.#computedAllowedValues.indexOf(value);
265
+ index ||= this.#computedAllowedValues.indexOf(getComputedStyleForProperty(this._property, value));
266
+ return index === -1 ? null : this._allowedValues[index]
267
+ }
268
+
269
+ get #computedAllowedValues() {
270
+ return this._computedAllowedValues ||= this._allowedValues.map(
271
+ value => getComputedStyleForProperty(this._property, value)
272
+ )
273
+ }
274
+ }
275
+
276
+ function getComputedStyleForProperty(property, value) {
277
+ const style = `${property}: ${value};`;
278
+
279
+ // the element has to be attached to the DOM have computed styles
280
+ const element = document.body.appendChild(createElement("span", { style: "display: none;" + style }));
281
+ const computedStyle = window.getComputedStyle(element).getPropertyValue(property);
282
+ element.remove();
283
+
284
+ return computedStyle
285
+ }
286
+
209
287
  function handleRollingTabIndex(elements, event) {
210
288
  const previousActiveElement = document.activeElement;
211
289
 
@@ -629,8 +707,8 @@ class LexicalToolbarElement extends HTMLElement {
629
707
  <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.65422 0.711575C7.1856 0.242951 6.42579 0.242951 5.95717 0.711575C5.48853 1.18021 5.48853 1.94 5.95717 2.40864L8.70864 5.16011L2.85422 11.0145C1.44834 12.4204 1.44833 14.6998 2.85422 16.1057L7.86011 21.1115C9.26599 22.5174 11.5454 22.5174 12.9513 21.1115L19.6542 14.4087C20.1228 13.94 20.1228 13.1802 19.6542 12.7115L11.8544 4.91171L11.2542 4.31158L7.65422 0.711575ZM4.55127 12.7115L10.4057 6.85716L17.1087 13.56H4.19981C4.19981 13.253 4.31696 12.9459 4.55127 12.7115ZM23.6057 20.76C23.6057 22.0856 22.5311 23.16 21.2057 23.16C19.8802 23.16 18.8057 22.0856 18.8057 20.76C18.8057 19.5408 19.8212 18.5339 20.918 17.4462C21.0135 17.3516 21.1096 17.2563 21.2057 17.16C21.3018 17.2563 21.398 17.3516 21.4935 17.4462C22.5903 18.5339 23.6057 19.5408 23.6057 20.76Z"/></svg>
630
708
  </summary>
631
709
  <lexxy-highlight-dropdown class="lexxy-editor__toolbar-dropdown-content">
632
- <div data-button-group="color" data-values="var(--highlight-1); var(--highlight-2); var(--highlight-3); var(--highlight-4); var(--highlight-5); var(--highlight-6); var(--highlight-7); var(--highlight-8); var(--highlight-9)"></div>
633
- <div data-button-group="background-color" data-values="var(--highlight-bg-1); var(--highlight-bg-2); var(--highlight-bg-3); var(--highlight-bg-4); var(--highlight-bg-5); var(--highlight-bg-6); var(--highlight-bg-7); var(--highlight-bg-8); var(--highlight-bg-9)"></div>
710
+ <div data-button-group="color"></div>
711
+ <div data-button-group="background-color"></div>
634
712
  <button data-command="removeHighlight" class="lexxy-editor__toolbar-dropdown-reset">Remove all coloring</button>
635
713
  </lexxy-highlight-dropdown>
636
714
  </details>
@@ -773,59 +851,6 @@ var theme = {
773
851
  }
774
852
  };
775
853
 
776
- function createElement(name, properties, content = "") {
777
- const element = document.createElement(name);
778
- for (const [ key, value ] of Object.entries(properties || {})) {
779
- if (key in element) {
780
- element[key] = value;
781
- } else if (value !== null && value !== undefined) {
782
- element.setAttribute(key, value);
783
- }
784
- }
785
- if (content) {
786
- element.innerHTML = content;
787
- }
788
- return element
789
- }
790
-
791
- function parseHtml(html) {
792
- const parser = new DOMParser();
793
- return parser.parseFromString(html, "text/html")
794
- }
795
-
796
- function createAttachmentFigure(contentType, isPreviewable, fileName) {
797
- const extension = fileName ? fileName.split(".").pop().toLowerCase() : "unknown";
798
- return createElement("figure", {
799
- className: `attachment attachment--${isPreviewable ? "preview" : "file"} attachment--${extension}`,
800
- "data-content-type": contentType
801
- })
802
- }
803
-
804
- function isPreviewableImage(contentType) {
805
- return contentType.startsWith("image/") && !contentType.includes("svg")
806
- }
807
-
808
- function dispatchCustomEvent(element, name, detail) {
809
- const event = new CustomEvent(name, {
810
- detail: detail,
811
- bubbles: true,
812
- });
813
- element.dispatchEvent(event);
814
- }
815
-
816
- function sanitize(html) {
817
- return DOMPurify.sanitize(html, buildConfig())
818
- }
819
-
820
- function dispatch(element, eventName, detail = null, cancelable = false) {
821
- return element.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail, cancelable }))
822
- }
823
-
824
- function generateDomId(prefix) {
825
- const randomPart = Math.random().toString(36).slice(2, 10);
826
- return `${prefix}-${randomPart}`
827
- }
828
-
829
854
  function bytesToHumanSize(bytes) {
830
855
  if (bytes === 0) return "0 B"
831
856
  const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
@@ -849,9 +874,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
849
874
 
850
875
  static importDOM() {
851
876
  return {
852
- [Lexxy.global.get("attachmentTagName")]: (attachment) => {
877
+ [this.TAG_NAME]: () => {
853
878
  return {
854
- conversion: () => ({
879
+ conversion: (attachment) => ({
855
880
  node: new ActionTextAttachmentNode({
856
881
  sgid: attachment.getAttribute("sgid"),
857
882
  src: attachment.getAttribute("url"),
@@ -864,13 +889,12 @@ class ActionTextAttachmentNode extends DecoratorNode {
864
889
  width: attachment.getAttribute("width"),
865
890
  height: attachment.getAttribute("height")
866
891
  })
867
- }),
868
- priority: 1
892
+ }), priority: 1
869
893
  }
870
894
  },
871
- "img": (img) => {
895
+ "img": () => {
872
896
  return {
873
- conversion: () => ({
897
+ conversion: (img) => ({
874
898
  node: new ActionTextAttachmentNode({
875
899
  src: img.getAttribute("src"),
876
900
  caption: img.getAttribute("alt") || "",
@@ -878,32 +902,37 @@ class ActionTextAttachmentNode extends DecoratorNode {
878
902
  width: img.getAttribute("width"),
879
903
  height: img.getAttribute("height")
880
904
  })
881
- }),
882
- priority: 1
905
+ }), priority: 1
883
906
  }
884
907
  },
885
- "video": (video) => {
886
- const videoSource = video.getAttribute("src") || video.querySelector("source")?.src;
887
- const fileName = videoSource?.split("/")?.pop();
888
- const contentType = video.querySelector("source")?.getAttribute("content-type") || "video/*";
889
-
908
+ "video": () => {
890
909
  return {
891
- conversion: () => ({
892
- node: new ActionTextAttachmentNode({
893
- src: videoSource,
894
- fileName: fileName,
895
- contentType: contentType
896
- })
897
- }),
898
- priority: 1
910
+ conversion: (video) => {
911
+ const videoSource = video.getAttribute("src") || video.querySelector("source")?.src;
912
+ const fileName = videoSource?.split("/")?.pop();
913
+ const contentType = video.querySelector("source")?.getAttribute("content-type") || "video/*";
914
+
915
+ return {
916
+ node: new ActionTextAttachmentNode({
917
+ src: videoSource,
918
+ fileName: fileName,
919
+ contentType: contentType
920
+ })
921
+ }
922
+ }, priority: 1
899
923
  }
900
924
  }
901
925
  }
902
926
  }
903
927
 
904
- constructor({ sgid, src, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) {
928
+ static get TAG_NAME() {
929
+ return Lexxy.global.get("attachmentTagName")
930
+ }
931
+
932
+ constructor({ tagName, sgid, src, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) {
905
933
  super(key);
906
934
 
935
+ this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
907
936
  this.sgid = sgid;
908
937
  this.src = src;
909
938
  this.previewable = previewable;
@@ -919,7 +948,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
919
948
  createDOM() {
920
949
  const figure = this.createAttachmentFigure();
921
950
 
922
- figure.addEventListener("click", (event) => {
951
+ figure.addEventListener("click", () => {
923
952
  this.#select(figure);
924
953
  });
925
954
 
@@ -939,7 +968,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
939
968
  }
940
969
 
941
970
  getTextContent() {
942
- return `[${ this.caption || this.fileName }]\n\n`
971
+ return `[${this.caption || this.fileName}]\n\n`
943
972
  }
944
973
 
945
974
  isInline() {
@@ -947,7 +976,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
947
976
  }
948
977
 
949
978
  exportDOM() {
950
- const attachment = createElement(Lexxy.global.get("attachmentTagName"), {
979
+ const attachment = createElement(this.tagName, {
951
980
  sgid: this.sgid,
952
981
  previewable: this.previewable || null,
953
982
  url: this.src,
@@ -968,6 +997,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
968
997
  return {
969
998
  type: "action_text_attachment",
970
999
  version: 1,
1000
+ tagName: this.tagName,
971
1001
  sgid: this.sgid,
972
1002
  src: this.src,
973
1003
  previewable: this.previewable,
@@ -1105,8 +1135,9 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
1105
1135
  return null
1106
1136
  }
1107
1137
 
1108
- constructor({ file, uploadUrl, blobUrlTemplate, editor, progress }, key) {
1109
- super({ contentType: file.type }, key);
1138
+ constructor(node, key) {
1139
+ const { file, uploadUrl, blobUrlTemplate, editor, progress } = node;
1140
+ super({ ...node, contentType: file.type }, key);
1110
1141
  this.file = file;
1111
1142
  this.uploadUrl = uploadUrl;
1112
1143
  this.blobUrlTemplate = blobUrlTemplate;
@@ -1191,11 +1222,17 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
1191
1222
 
1192
1223
  async #startUpload(progressBar, figure) {
1193
1224
  const { DirectUpload } = await import('@rails/activestorage');
1225
+ const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
1194
1226
 
1195
1227
  const upload = new DirectUpload(this.file, this.uploadUrl, this);
1196
1228
 
1197
1229
  upload.delegate = {
1230
+ directUploadWillCreateBlobWithXHR: (request) => {
1231
+ if (shouldAuthenticateUploads) request.withCredentials = true;
1232
+ },
1198
1233
  directUploadWillStoreFileWithXHR: (request) => {
1234
+ if (shouldAuthenticateUploads) request.withCredentials = true;
1235
+
1199
1236
  request.upload.addEventListener("progress", (event) => {
1200
1237
  this.editor.update(() => {
1201
1238
  progressBar.value = Math.round(event.loaded / event.total * 100);
@@ -1226,11 +1263,12 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
1226
1263
  const image = figure.querySelector("img");
1227
1264
 
1228
1265
  const src = this.blobUrlTemplate
1229
- .replace(":signed_id", blob.signed_id)
1230
- .replace(":filename", encodeURIComponent(blob.filename));
1266
+ .replace(":signed_id", blob.signed_id)
1267
+ .replace(":filename", encodeURIComponent(blob.filename));
1231
1268
  const latest = $getNodeByKey(this.getKey());
1232
1269
  if (latest) {
1233
1270
  latest.replace(new ActionTextAttachmentNode({
1271
+ tagName: this.tagName,
1234
1272
  sgid: blob.attachable_sgid,
1235
1273
  src: blob.previewable ? blob.url : src,
1236
1274
  altText: blob.filename,
@@ -1769,7 +1807,14 @@ class Selection {
1769
1807
 
1770
1808
  placeCursorAtTheEnd() {
1771
1809
  this.editor.update(() => {
1772
- $getRoot().selectEnd();
1810
+ const root = $getRoot();
1811
+ const lastDescendant = root.getLastDescendant();
1812
+
1813
+ if (lastDescendant && $isTextNode(lastDescendant)) {
1814
+ lastDescendant.selectEnd();
1815
+ } else {
1816
+ root.selectEnd();
1817
+ }
1773
1818
  });
1774
1819
  }
1775
1820
 
@@ -2384,6 +2429,10 @@ class Selection {
2384
2429
  }
2385
2430
  }
2386
2431
 
2432
+ function sanitize(html) {
2433
+ return DOMPurify.sanitize(html, buildConfig())
2434
+ }
2435
+
2387
2436
  // Prevent the hardcoded background color
2388
2437
  // A background color value is set by Lexical if background is null:
2389
2438
  // https://github.com/facebook/lexical/blob/5bbbe849bd229e1db0e7b536e6a919520ada7bb2/packages/lexical-table/src/LexicalTableCellNode.ts#L187
@@ -2474,15 +2523,15 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2474
2523
  }
2475
2524
 
2476
2525
  static importDOM() {
2526
+
2477
2527
  return {
2478
- [Lexxy.global.get("attachmentTagName")]: (attachment) => {
2479
- const content = attachment.getAttribute("content");
2480
- if (!attachment.getAttribute("content")) {
2528
+ [this.TAG_NAME]: (element) => {
2529
+ if (!element.getAttribute("content")) {
2481
2530
  return null
2482
2531
  }
2483
2532
 
2484
2533
  return {
2485
- conversion: () => {
2534
+ conversion: (attachment) => {
2486
2535
  // Preserve initial space if present since Lexical removes it
2487
2536
  const nodes = [];
2488
2537
  const previousSibling = attachment.previousSibling;
@@ -2492,7 +2541,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2492
2541
 
2493
2542
  nodes.push(new CustomActionTextAttachmentNode({
2494
2543
  sgid: attachment.getAttribute("sgid"),
2495
- innerHtml: JSON.parse(content),
2544
+ innerHtml: JSON.parse(attachment.getAttribute("content")),
2496
2545
  contentType: attachment.getAttribute("content-type")
2497
2546
  }));
2498
2547
 
@@ -2506,16 +2555,23 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2506
2555
  }
2507
2556
  }
2508
2557
 
2509
- constructor({ sgid, contentType, innerHtml }, key) {
2558
+ static get TAG_NAME() {
2559
+ return Lexxy.global.get("attachmentTagName")
2560
+ }
2561
+
2562
+ constructor({ tagName, sgid, contentType, innerHtml }, key) {
2510
2563
  super(key);
2511
2564
 
2565
+ const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
2566
+
2567
+ this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
2512
2568
  this.sgid = sgid;
2513
- this.contentType = contentType || "application/vnd.actiontext.unknown";
2569
+ this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
2514
2570
  this.innerHtml = innerHtml;
2515
2571
  }
2516
2572
 
2517
2573
  createDOM() {
2518
- const figure = createElement(Lexxy.global.get("attachmentTagName"), { "content-type": this.contentType, "data-lexxy-decorator": true });
2574
+ const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
2519
2575
 
2520
2576
  figure.addEventListener("click", (event) => {
2521
2577
  dispatchCustomEvent(figure, "lexxy:internal:select-node", { key: this.getKey() });
@@ -2539,7 +2595,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2539
2595
  }
2540
2596
 
2541
2597
  exportDOM() {
2542
- const attachment = createElement(Lexxy.global.get("attachmentTagName"), {
2598
+ const attachment = createElement(this.tagName, {
2543
2599
  sgid: this.sgid,
2544
2600
  content: JSON.stringify(this.innerHtml),
2545
2601
  "content-type": this.contentType
@@ -2552,6 +2608,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2552
2608
  return {
2553
2609
  type: "custom_action_text_attachment",
2554
2610
  version: 1,
2611
+ tagName: this.tagName,
2555
2612
  sgid: this.sgid,
2556
2613
  contentType: this.contentType,
2557
2614
  innerHtml: this.innerHtml
@@ -3702,133 +3759,203 @@ class Clipboard {
3702
3759
  }
3703
3760
  }
3704
3761
 
3705
- class Highlighter {
3706
- constructor(editorElement) {
3707
- this.editor = editorElement.editor;
3762
+ class Extensions {
3763
+
3764
+ constructor(lexxyElement) {
3765
+ this.lexxyElement = lexxyElement;
3708
3766
 
3709
- this.#registerHighlightTransform();
3767
+ this.enabledExtensions = this.#initializeExtensions();
3710
3768
  }
3711
3769
 
3712
- toggle(styles) {
3713
- this.editor.update(() => {
3714
- this.#toggleSelectionStyles(styles);
3715
- });
3770
+ get lexicalExtensions() {
3771
+ return this.enabledExtensions.map(ext => ext.lexicalExtension).filter(Boolean)
3716
3772
  }
3717
3773
 
3718
- remove() {
3719
- this.toggle({ "color": null, "background-color": null });
3774
+ initializeToolbars() {
3775
+ if (this.#lexxyToolbar) {
3776
+ this.enabledExtensions.forEach(ext => ext.initializeToobar(this.#lexxyToolbar));
3777
+ }
3720
3778
  }
3721
3779
 
3722
- #registerHighlightTransform() {
3723
- return this.editor.registerNodeTransform(TextNode, (textNode) => {
3724
- this.#syncHighlightWithStyle(textNode);
3725
- })
3780
+ get #lexxyToolbar() {
3781
+ return this.lexxyElement.toolbar
3726
3782
  }
3727
3783
 
3728
- #toggleSelectionStyles(styles) {
3729
- const selection = $getSelection();
3730
- if (!$isRangeSelection(selection)) return
3784
+ #initializeExtensions() {
3785
+ const extensionDefinitions = Lexxy.global.get("extensions");
3731
3786
 
3732
- const patch = {};
3733
- for (const property in styles) {
3734
- const oldValue = $getSelectionStyleValueForProperty(selection, property);
3735
- patch[property] = this.#toggleOrReplace(oldValue, styles[property]);
3787
+ return extensionDefinitions.map(
3788
+ extension => new extension(this.lexxyElement)
3789
+ ).filter(extension => extension.enabled)
3790
+ }
3791
+ }
3792
+
3793
+ const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
3794
+
3795
+ const hasPastedStylesState = createState("hasPastedStyles", {
3796
+ parse: (value) => value || false
3797
+ });
3798
+
3799
+ const HighlightExtension = defineExtension({
3800
+ dependencies: [ RichTextExtension ],
3801
+ name: "lexxy/highlight",
3802
+ config: {
3803
+ color: { buttons: [], permit: [] },
3804
+ "background-color": { buttons: [], permit: [] }
3805
+ },
3806
+ html: {
3807
+ import: {
3808
+ mark: $markConversion
3736
3809
  }
3810
+ },
3811
+ register(editor, config) {
3812
+ const canonicalizers = buildCanonicalizers(config);
3737
3813
 
3738
- $patchStyleText(selection, patch);
3814
+ editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, $toggleSelectionStyles, COMMAND_PRIORITY_NORMAL);
3815
+ editor.registerNodeTransform(TextNode, $syncHighlightWithStyle);
3816
+ editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers));
3739
3817
  }
3818
+ });
3740
3819
 
3741
- #toggleOrReplace(oldValue, newValue) {
3742
- return oldValue === newValue ? null : newValue
3820
+ function $applyHighlightStyle(textNode, element) {
3821
+ const elementStyles = {
3822
+ color: element.style?.color,
3823
+ "background-color": element.style?.backgroundColor
3824
+ };
3825
+
3826
+ if ($hasUpdateTag(PASTE_TAG)) { $setPastedStyles(textNode); }
3827
+ const highlightStyle = getCSSFromStyleObject(elementStyles);
3828
+
3829
+ if (highlightStyle.length) {
3830
+ return textNode.setStyle(textNode.getStyle() + highlightStyle)
3743
3831
  }
3832
+ }
3744
3833
 
3745
- #syncHighlightWithStyle(node) {
3746
- if (hasHighlightStyles(node.getStyle()) !== node.hasFormat("highlight")) {
3747
- node.toggleFormat("highlight");
3748
- }
3834
+ function $markConversion() {
3835
+ return {
3836
+ conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
3837
+ priority: 1
3749
3838
  }
3750
3839
  }
3751
3840
 
3752
- class HighlightNode extends TextNode {
3753
- $config() {
3754
- return this.config("highlight", { extends: TextNode })
3841
+ function buildCanonicalizers(config) {
3842
+ return [
3843
+ new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
3844
+ new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
3845
+ ]
3846
+ }
3847
+
3848
+ function $toggleSelectionStyles(styles) {
3849
+ const selection = $getSelection();
3850
+ if (!$isRangeSelection(selection)) return
3851
+
3852
+ const patch = {};
3853
+ for (const property in styles) {
3854
+ const oldValue = $getSelectionStyleValueForProperty(selection, property);
3855
+ patch[property] = toggleOrReplace(oldValue, styles[property]);
3755
3856
  }
3756
3857
 
3757
- static importDOM() {
3758
- return {
3759
- mark: () => ({
3760
- conversion: extendTextNodeConversion("mark", applyHighlightStyle),
3761
- priority: 1
3762
- })
3763
- }
3858
+ $patchStyleText(selection, patch);
3859
+ }
3860
+
3861
+ function toggleOrReplace(oldValue, newValue) {
3862
+ return oldValue === newValue ? null : newValue
3863
+ }
3864
+
3865
+ function $syncHighlightWithStyle(textNode) {
3866
+ if (hasHighlightStyles(textNode.getStyle()) !== textNode.hasFormat("highlight")) {
3867
+ textNode.toggleFormat("highlight");
3764
3868
  }
3765
3869
  }
3766
3870
 
3767
- function applyHighlightStyle(textNode, element) {
3768
- const textColor = element.style?.color;
3769
- const backgroundColor = element.style?.backgroundColor;
3871
+ function $canonicalizePastedStyles(textNode, canonicalizers = []) {
3872
+ if ($hasPastedStyles(textNode)) {
3873
+ $setPastedStyles(textNode, false);
3770
3874
 
3771
- let highlightStyle = "";
3772
- if (textColor && textColor !== "") highlightStyle += `color: ${textColor};`;
3773
- if (backgroundColor && backgroundColor !== "") highlightStyle += `background-color: ${backgroundColor};`;
3875
+ const canonicalizedCSS = canonicalizers.reduce((css, canonicalizer) => {
3876
+ return canonicalizer.applyCanonicalization(css)
3877
+ }, textNode.getStyle());
3774
3878
 
3775
- if (highlightStyle.length) {
3776
- if (!textNode.hasFormat("highlight")) textNode.toggleFormat("highlight");
3777
- return textNode.setStyle(textNode.getStyle() + highlightStyle)
3879
+ textNode.setStyle(canonicalizedCSS);
3778
3880
  }
3779
3881
  }
3780
3882
 
3781
- const TRIX_LANGUAGE_ATTR = "language";
3883
+ function $setPastedStyles(textNode, value = true) {
3884
+ $setState(textNode, hasPastedStylesState, value);
3885
+ }
3886
+
3887
+ function $hasPastedStyles(textNode) {
3888
+ return $getState(textNode, hasPastedStylesState)
3889
+ }
3890
+
3891
+ class Highlighter {
3782
3892
 
3783
- class TrixTextNode extends TextNode {
3784
- $config() {
3785
- return this.config("trix-text", { extends: TextNode })
3893
+ constructor(editorElement) {
3894
+ this.editorElement = editorElement;
3786
3895
  }
3787
3896
 
3788
- static importDOM() {
3789
- return {
3790
- // em, span, and strong elements are directly styled in trix
3897
+ get editor() {
3898
+ return this.editorElement.editor
3899
+ }
3900
+
3901
+ get lexicalExtension() {
3902
+ return [ HighlightExtension, this.editorElement.config.get("highlight") ]
3903
+ }
3904
+
3905
+ toggle(styles) {
3906
+ this.editor.dispatchCommand(TOGGLE_HIGHLIGHT_COMMAND, styles);
3907
+ }
3908
+
3909
+ remove() {
3910
+ this.toggle({ "color": null, "background-color": null });
3911
+ }
3912
+ }
3913
+
3914
+ const TRIX_LANGUAGE_ATTR = "language";
3915
+
3916
+ const TrixContentExtension = defineExtension({
3917
+ name: "lexxy/trix-content",
3918
+ html: {
3919
+ import: {
3791
3920
  em: (element) => onlyStyledElements(element, {
3792
- conversion: extendTextNodeConversion("i", applyHighlightStyle),
3921
+ conversion: extendTextNodeConversion("i", $applyHighlightStyle),
3793
3922
  priority: 1
3794
3923
  }),
3795
3924
  span: (element) => onlyStyledElements(element, {
3796
- conversion: extendTextNodeConversion("mark", applyHighlightStyle),
3925
+ conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
3797
3926
  priority: 1
3798
3927
  }),
3799
3928
  strong: (element) => onlyStyledElements(element, {
3800
- conversion: extendTextNodeConversion("b", applyHighlightStyle),
3929
+ conversion: extendTextNodeConversion("b", $applyHighlightStyle),
3801
3930
  priority: 1
3802
3931
  }),
3803
- // del => s
3804
3932
  del: () => ({
3805
- conversion: extendTextNodeConversion("s", applyStrikethrough),
3933
+ conversion: extendTextNodeConversion("s", $applyStrikethrough, $applyHighlightStyle),
3806
3934
  priority: 1
3807
3935
  }),
3808
- // read "language" attribute and normalize
3809
3936
  pre: (element) => onlyPreLanguageElements(element, {
3810
- conversion: extendConversion(CodeNode, "pre", applyLanguage),
3937
+ conversion: extendConversion(CodeNode, "pre", $applyLanguage),
3811
3938
  priority: 1
3812
3939
  })
3813
3940
  }
3814
3941
  }
3815
- }
3942
+ });
3816
3943
 
3817
3944
  function onlyStyledElements(element, conversion) {
3818
3945
  const elementHighlighted = element.style.color !== "" || element.style.backgroundColor !== "";
3819
3946
  return elementHighlighted ? conversion : null
3820
3947
  }
3821
3948
 
3822
- function applyStrikethrough(textNode, element) {
3949
+ function $applyStrikethrough(textNode) {
3823
3950
  if (!textNode.hasFormat("strikethrough")) textNode.toggleFormat("strikethrough");
3824
- return applyHighlightStyle(textNode, element)
3951
+ return textNode
3825
3952
  }
3826
3953
 
3827
3954
  function onlyPreLanguageElements(element, conversion) {
3828
3955
  return element.hasAttribute(TRIX_LANGUAGE_ATTR) ? conversion : null
3829
3956
  }
3830
3957
 
3831
- function applyLanguage(conversionOutput, element) {
3958
+ function $applyLanguage(conversionOutput, element) {
3832
3959
  const language = normalizeCodeLang(element.getAttribute(TRIX_LANGUAGE_ATTR));
3833
3960
  conversionOutput.node.setLanguage(language);
3834
3961
  }
@@ -3852,11 +3979,14 @@ class LexicalEditorElement extends HTMLElement {
3852
3979
  connectedCallback() {
3853
3980
  this.id ??= generateDomId("lexxy-editor");
3854
3981
  this.config = new EditorConfiguration(this);
3982
+ this.extensions = new Extensions(this);
3983
+ this.highlighter = new Highlighter(this);
3984
+
3855
3985
  this.editor = this.#createEditor();
3986
+
3856
3987
  this.contents = new Contents(this);
3857
3988
  this.selection = new Selection(this);
3858
3989
  this.clipboard = new Clipboard(this);
3859
- this.highlighter = new Highlighter(this);
3860
3990
 
3861
3991
  CommandDispatcher.configureFor(this);
3862
3992
  this.#initialize();
@@ -3864,6 +3994,8 @@ class LexicalEditorElement extends HTMLElement {
3864
3994
  requestAnimationFrame(() => dispatch(this, "lexxy:initialize"));
3865
3995
  this.toggleAttribute("connected", true);
3866
3996
 
3997
+ this.#handleAutofocus();
3998
+
3867
3999
  this.valueBeforeDisconnect = null;
3868
4000
  }
3869
4001
 
@@ -3888,10 +4020,6 @@ class LexicalEditorElement extends HTMLElement {
3888
4020
  this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
3889
4021
  }
3890
4022
 
3891
- focus() {
3892
- this.editor.focus();
3893
- }
3894
-
3895
4023
  toString() {
3896
4024
  if (!this.cachedStringValue) {
3897
4025
  this.editor?.getEditorState().read(() => {
@@ -3966,6 +4094,10 @@ class LexicalEditorElement extends HTMLElement {
3966
4094
  return parseInt(this.editorContentElement?.getAttribute("tabindex") ?? "0")
3967
4095
  }
3968
4096
 
4097
+ focus() {
4098
+ this.editor.focus(() => this.#onFocus());
4099
+ }
4100
+
3969
4101
  get value() {
3970
4102
  if (!this.cachedValue) {
3971
4103
  this.editor?.getEditorState().read(() => {
@@ -4028,29 +4160,43 @@ class LexicalEditorElement extends HTMLElement {
4028
4160
  }
4029
4161
 
4030
4162
  #createEditor() {
4031
- this.editorContentElement = this.editorContentElement || this.#createEditorContentElement();
4163
+ this.editorContentElement ||= this.#createEditorContentElement();
4032
4164
 
4033
- const editor = createEditor({
4034
- namespace: "LexicalEditor",
4035
- onError(error) {
4036
- throw error
4165
+ const editor = buildEditorFromExtensions({
4166
+ name: "lexxy/core",
4167
+ namespace: "Lexxy",
4168
+ theme: theme,
4169
+ nodes: this.#lexicalNodes
4037
4170
  },
4038
- theme: theme,
4039
- nodes: this.#lexicalNodes
4040
- });
4171
+ ...this.#lexicalExtensions
4172
+ );
4041
4173
 
4042
4174
  editor.setRootElement(this.editorContentElement);
4043
4175
 
4044
4176
  return editor
4045
4177
  }
4046
4178
 
4179
+ get #lexicalExtensions() {
4180
+ const extensions = [ ];
4181
+ const richTextExtensions = [
4182
+ this.highlighter.lexicalExtension,
4183
+ TrixContentExtension
4184
+ ];
4185
+
4186
+ if (this.supportsRichText) {
4187
+ extensions.push(...richTextExtensions);
4188
+ }
4189
+
4190
+ extensions.push(...this.extensions.lexicalExtensions);
4191
+
4192
+ return extensions
4193
+ }
4194
+
4047
4195
  get #lexicalNodes() {
4048
4196
  const nodes = [ CustomActionTextAttachmentNode ];
4049
4197
 
4050
4198
  if (this.supportsRichText) {
4051
4199
  nodes.push(
4052
- TrixTextNode,
4053
- HighlightNode,
4054
4200
  QuoteNode,
4055
4201
  HeadingNode,
4056
4202
  ListNode,
@@ -4239,6 +4385,20 @@ class LexicalEditorElement extends HTMLElement {
4239
4385
  this.editor.registerCommand(FOCUS_COMMAND, () => { dispatch(this, "lexxy:focus"); }, COMMAND_PRIORITY_NORMAL);
4240
4386
  }
4241
4387
 
4388
+ #onFocus() {
4389
+ if (this.isEmpty) {
4390
+ this.selection.placeCursorAtTheEnd();
4391
+ }
4392
+ }
4393
+
4394
+ #handleAutofocus() {
4395
+ if (!document.querySelector(":focus")) {
4396
+ if (this.hasAttribute("autofocus") && document.querySelector("[autofocus]") === this) {
4397
+ this.focus();
4398
+ }
4399
+ }
4400
+ }
4401
+
4242
4402
  #handleTables() {
4243
4403
  if (this.supportsRichText) {
4244
4404
  this.removeTableSelectionObserver = registerTableSelectionObserver(this.editor, true);
@@ -4353,6 +4513,10 @@ class ToolbarDropdown extends HTMLElement {
4353
4513
  return this.closest("lexxy-toolbar")
4354
4514
  }
4355
4515
 
4516
+ get editorElement() {
4517
+ return this.toolbar.editorElement
4518
+ }
4519
+
4356
4520
  get editor() {
4357
4521
  return this.toolbar.editor
4358
4522
  }
@@ -4484,15 +4648,26 @@ const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
4484
4648
  const NO_STYLE = Symbol("no_style");
4485
4649
 
4486
4650
  class HighlightDropdown extends ToolbarDropdown {
4651
+ #initialized = false
4652
+
4487
4653
  connectedCallback() {
4488
4654
  super.connectedCallback();
4655
+ this.#registerToggleHandler();
4656
+ }
4657
+
4658
+ #ensureInitialized() {
4659
+ if (this.#initialized) return
4489
4660
 
4490
4661
  this.#setUpButtons();
4491
- this.#registerHandlers();
4662
+ this.#registerButtonHandlers();
4663
+ this.#initialized = true;
4492
4664
  }
4493
4665
 
4494
- #registerHandlers() {
4666
+ #registerToggleHandler() {
4495
4667
  this.container.addEventListener("toggle", this.#handleToggle.bind(this));
4668
+ }
4669
+
4670
+ #registerButtonHandlers() {
4496
4671
  this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick.bind(this)));
4497
4672
  this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick.bind(this));
4498
4673
  }
@@ -4504,8 +4679,8 @@ class HighlightDropdown extends ToolbarDropdown {
4504
4679
  }
4505
4680
 
4506
4681
  #populateButtonGroup(buttonGroup) {
4507
- const values = buttonGroup.dataset.values?.split("; ") || [];
4508
4682
  const attribute = buttonGroup.dataset.buttonGroup;
4683
+ const values = this.editorElement.config.get(`highlight.buttons.${attribute}`) || [];
4509
4684
  values.forEach((value, index) => {
4510
4685
  buttonGroup.appendChild(this.#createButton(attribute, value, index));
4511
4686
  });
@@ -4523,6 +4698,8 @@ class HighlightDropdown extends ToolbarDropdown {
4523
4698
 
4524
4699
  #handleToggle({ newState }) {
4525
4700
  if (newState === "open") {
4701
+ this.#ensureInitialized();
4702
+
4526
4703
  this.editor.getEditorState().read(() => {
4527
4704
  this.#updateColorButtonStates($getSelection());
4528
4705
  });
@@ -4616,7 +4793,7 @@ class TableHandler extends HTMLElement {
4616
4793
  }
4617
4794
 
4618
4795
  get #tableHandlerButtons() {
4619
- return Array.from(this.buttonsContainer.querySelectorAll("button, details > summary"))
4796
+ return Array.from(this.querySelectorAll("button, details > summary"))
4620
4797
  }
4621
4798
 
4622
4799
  #registerKeyboardShortcuts() {
@@ -4629,7 +4806,7 @@ class TableHandler extends HTMLElement {
4629
4806
 
4630
4807
  #handleKeyDown = (event) => {
4631
4808
  if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "F10") {
4632
- const firstButton = this.buttonsContainer?.querySelector("button, [tabindex]:not([tabindex='-1'])");
4809
+ const firstButton = this.querySelector("button, [tabindex]:not([tabindex='-1'])");
4633
4810
  this.#setFocusStateOnSelectedCell();
4634
4811
  firstButton?.focus();
4635
4812
  } else if (event.key === "Escape") {
@@ -4654,22 +4831,16 @@ class TableHandler extends HTMLElement {
4654
4831
  }
4655
4832
 
4656
4833
  #setUpButtons() {
4657
- this.buttonsContainer = createElement("div", {
4658
- className: "lexxy-table-handle-buttons"
4659
- });
4660
-
4661
- this.buttonsContainer.appendChild(this.#createRowButtonsContainer());
4662
- this.buttonsContainer.appendChild(this.#createColumnButtonsContainer());
4834
+ this.appendChild(this.#createRowButtonsContainer());
4835
+ this.appendChild(this.#createColumnButtonsContainer());
4663
4836
 
4664
4837
  this.moreMenu = this.#createMoreMenu();
4665
- this.buttonsContainer.appendChild(this.moreMenu);
4666
- this.buttonsContainer.addEventListener("keydown", this.#handleTableHandlerKeydown);
4667
-
4668
- this.#editorElement.appendChild(this.buttonsContainer);
4838
+ this.appendChild(this.moreMenu);
4839
+ this.addEventListener("keydown", this.#handleTableHandlerKeydown);
4669
4840
  }
4670
4841
 
4671
4842
  #showTableHandlerButtons() {
4672
- this.buttonsContainer.style.display = "flex";
4843
+ this.style.display = "flex";
4673
4844
  this.#closeMoreMenu();
4674
4845
 
4675
4846
  this.#updateRowColumnCount();
@@ -4677,7 +4848,7 @@ class TableHandler extends HTMLElement {
4677
4848
  }
4678
4849
 
4679
4850
  #hideTableHandlerButtons() {
4680
- this.buttonsContainer.style.display = "none";
4851
+ this.style.display = "none";
4681
4852
  this.#closeMoreMenu();
4682
4853
 
4683
4854
  this.#setTableFocusState(false);
@@ -4693,8 +4864,8 @@ class TableHandler extends HTMLElement {
4693
4864
 
4694
4865
  const relativeTop = tableRect.top - editorRect.top;
4695
4866
  const relativeCenter = (tableRect.left + tableRect.right) / 2 - editorRect.left;
4696
- this.buttonsContainer.style.top = `${relativeTop}px`;
4697
- this.buttonsContainer.style.left = `${relativeCenter}px`;
4867
+ this.style.top = `${relativeTop}px`;
4868
+ this.style.left = `${relativeCenter}px`;
4698
4869
  }
4699
4870
 
4700
4871
  #updateRowColumnCount() {
@@ -5561,30 +5732,57 @@ class LexicalPromptElement extends HTMLElement {
5561
5732
 
5562
5733
  if (!promptItem) { return }
5563
5734
 
5564
- const template = promptItem.querySelector("template[type='editor']");
5735
+ const templates = Array.from(promptItem.querySelectorAll("template[type='editor']"));
5565
5736
  const stringToReplace = `${this.trigger}${this.#editorContents.textBackUntil(this.trigger)}`;
5566
5737
 
5567
5738
  if (this.hasAttribute("insert-editable-text")) {
5568
- this.#insertTemplateAsEditableText(template, stringToReplace);
5739
+ this.#insertTemplatesAsEditableText(templates, stringToReplace);
5569
5740
  } else {
5570
- this.#insertTemplateAsAttachment(promptItem, template, stringToReplace);
5741
+ this.#insertTemplatesAsAttachments(templates, stringToReplace, promptItem.getAttribute("sgid"));
5571
5742
  }
5572
5743
  }
5573
5744
 
5574
- #insertTemplateAsEditableText(template, stringToReplace) {
5745
+ #insertTemplatesAsEditableText(templates, stringToReplace) {
5575
5746
  this.#editor.update(() => {
5576
- const nodes = $generateNodesFromDOM(this.#editor, parseHtml(`${template.innerHTML}`));
5747
+ const nodes = templates.flatMap(template => this.#buildEditableTextNodes(template));
5577
5748
  this.#editorContents.replaceTextBackUntil(stringToReplace, nodes);
5578
5749
  });
5579
5750
  }
5580
5751
 
5581
- #insertTemplateAsAttachment(promptItem, template, stringToReplace) {
5752
+ #buildEditableTextNodes(template) {
5753
+ return $generateNodesFromDOM(this.#editor, parseHtml(`${template.innerHTML}`))
5754
+ }
5755
+
5756
+ #insertTemplatesAsAttachments(templates, stringToReplace, fallbackSgid = null) {
5582
5757
  this.#editor.update(() => {
5583
- const attachmentNode = new CustomActionTextAttachmentNode({ sgid: promptItem.getAttribute("sgid"), contentType: `application/vnd.actiontext.${this.name}`, innerHtml: template.innerHTML });
5584
- this.#editorContents.replaceTextBackUntil(stringToReplace, attachmentNode);
5758
+ const attachmentNodes = this.#buildAttachmentNodes(templates, fallbackSgid);
5759
+ const spacedAttachmentNodes = attachmentNodes.flatMap(node => [ node, this.#getSpacerTextNode() ]).slice(0, -1);
5760
+ this.#editorContents.replaceTextBackUntil(stringToReplace, spacedAttachmentNodes);
5585
5761
  });
5586
5762
  }
5587
5763
 
5764
+ #buildAttachmentNodes(templates, fallbackSgid = null) {
5765
+ return templates.map(
5766
+ template => this.#buildAttachmentNode(
5767
+ template.innerHTML,
5768
+ template.getAttribute("content-type") || this.#defaultPromptContentType,
5769
+ template.getAttribute("sgid") || fallbackSgid
5770
+ ))
5771
+ }
5772
+
5773
+ #getSpacerTextNode() {
5774
+ return $createTextNode(" ")
5775
+ }
5776
+
5777
+ get #defaultPromptContentType() {
5778
+ const attachmentContentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
5779
+ return `application/vnd.${attachmentContentTypeNamespace}.${this.name}`
5780
+ }
5781
+
5782
+ #buildAttachmentNode(innerHtml, contentType, sgid) {
5783
+ return new CustomActionTextAttachmentNode({ sgid, contentType, innerHtml })
5784
+ }
5785
+
5588
5786
  get #editorContents() {
5589
5787
  return this.#editorElement.contents
5590
5788
  }
@@ -5636,13 +5834,12 @@ class CodeLanguagePicker extends HTMLElement {
5636
5834
  this.#updateCodeBlockLanguage(this.languagePickerElement.value);
5637
5835
  });
5638
5836
 
5639
- this.languagePickerElement.style.position = "absolute";
5640
5837
  this.languagePickerElement.setAttribute("nonce", getNonce());
5641
- this.editorElement.appendChild(this.languagePickerElement);
5838
+ this.appendChild(this.languagePickerElement);
5642
5839
  }
5643
5840
 
5644
5841
  #createLanguagePicker() {
5645
- const selectElement = createElement("select", { hidden: true, className: "lexxy-code-language-picker", "aria-label": "Pick a language…", name: "lexxy-code-language" });
5842
+ const selectElement = createElement("select", { className: "lexxy-code-language-picker", "aria-label": "Pick a language…", name: "lexxy-code-language" });
5646
5843
 
5647
5844
  for (const [ value, label ] of Object.entries(this.#languages)) {
5648
5845
  const option = document.createElement("option");
@@ -5739,43 +5936,49 @@ class CodeLanguagePicker extends HTMLElement {
5739
5936
  const editorRect = this.editorElement.getBoundingClientRect();
5740
5937
  const relativeTop = codeRect.top - editorRect.top;
5741
5938
 
5742
- this.languagePickerElement.style.top = `${relativeTop}px`;
5939
+ this.style.top = `${relativeTop}px`;
5743
5940
  }
5744
5941
 
5745
5942
  #showLanguagePicker() {
5746
- this.languagePickerElement.hidden = false;
5943
+ this.hidden = false;
5747
5944
  }
5748
5945
 
5749
5946
  #hideLanguagePicker() {
5750
- this.languagePickerElement.hidden = true;
5947
+ this.hidden = true;
5751
5948
  }
5752
5949
  }
5753
5950
 
5754
5951
  customElements.define("lexxy-code-language-picker", CodeLanguagePicker);
5755
5952
 
5756
- function highlightAll() {
5757
- const elements = document.querySelectorAll("pre[data-language]");
5953
+ class LexxyExtension {
5954
+ #editorElement
5758
5955
 
5759
- elements.forEach(preElement => {
5760
- highlightElement(preElement);
5761
- });
5762
- }
5956
+ constructor(editorElement) {
5957
+ this.#editorElement = editorElement;
5958
+ }
5763
5959
 
5764
- function highlightElement(preElement) {
5765
- const language = preElement.getAttribute("data-language");
5766
- let code = preElement.innerHTML.replace(/<br\s*\/?>/gi, "\n");
5960
+ get editorElement() {
5961
+ return this.#editorElement
5962
+ }
5767
5963
 
5768
- const grammar = Prism.languages?.[language];
5769
- if (!grammar) return
5964
+ get editorConfig() {
5965
+ return this.#editorElement.config
5966
+ }
5770
5967
 
5771
- // unescape HTML entities in the code block
5772
- code = new DOMParser().parseFromString(code, "text/html").body.textContent || "";
5968
+ // optional: defaults to true
5969
+ get enabled() {
5970
+ return true
5971
+ }
5972
+
5973
+ get lexicalExtension() {
5974
+ return null
5975
+ }
5976
+
5977
+ initializeToolbar(_lexxyToolbar) {
5773
5978
 
5774
- const highlightedHtml = Prism.highlight(code, grammar, language);
5775
- const codeElement = createElement("code", { "data-language": language, innerHTML: highlightedHtml });
5776
- preElement.replaceWith(codeElement);
5979
+ }
5777
5980
  }
5778
5981
 
5779
5982
  const configure = Lexxy.configure;
5780
5983
 
5781
- export { configure, highlightAll };
5984
+ export { ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, configure };
@@ -0,0 +1,75 @@
1
+ import Prism from 'prismjs';
2
+
3
+ function createElement(name, properties, content = "") {
4
+ const element = document.createElement(name);
5
+ for (const [ key, value ] of Object.entries(properties || {})) {
6
+ if (key in element) {
7
+ element[key] = value;
8
+ } else if (value !== null && value !== undefined) {
9
+ element.setAttribute(key, value);
10
+ }
11
+ }
12
+ if (content) {
13
+ element.innerHTML = content;
14
+ }
15
+ return element
16
+ }
17
+
18
+ function parseHtml(html) {
19
+ const parser = new DOMParser();
20
+ return parser.parseFromString(html, "text/html")
21
+ }
22
+
23
+ function createAttachmentFigure(contentType, isPreviewable, fileName) {
24
+ const extension = fileName ? fileName.split(".").pop().toLowerCase() : "unknown";
25
+ return createElement("figure", {
26
+ className: `attachment attachment--${isPreviewable ? "preview" : "file"} attachment--${extension}`,
27
+ "data-content-type": contentType
28
+ })
29
+ }
30
+
31
+ function isPreviewableImage(contentType) {
32
+ return contentType.startsWith("image/") && !contentType.includes("svg")
33
+ }
34
+
35
+ function dispatchCustomEvent(element, name, detail) {
36
+ const event = new CustomEvent(name, {
37
+ detail: detail,
38
+ bubbles: true,
39
+ });
40
+ element.dispatchEvent(event);
41
+ }
42
+
43
+ function dispatch(element, eventName, detail = null, cancelable = false) {
44
+ return element.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail, cancelable }))
45
+ }
46
+
47
+ function generateDomId(prefix) {
48
+ const randomPart = Math.random().toString(36).slice(2, 10);
49
+ return `${prefix}-${randomPart}`
50
+ }
51
+
52
+ function highlightCode() {
53
+ const elements = document.querySelectorAll("pre[data-language]");
54
+
55
+ elements.forEach(preElement => {
56
+ highlightElement(preElement);
57
+ });
58
+ }
59
+
60
+ function highlightElement(preElement) {
61
+ const language = preElement.getAttribute("data-language");
62
+ let code = preElement.innerHTML.replace(/<br\s*\/?>/gi, "\n");
63
+
64
+ const grammar = Prism.languages?.[language];
65
+ if (!grammar) return
66
+
67
+ // unescape HTML entities in the code block
68
+ code = new DOMParser().parseFromString(code, "text/html").body.textContent || "";
69
+
70
+ const highlightedHtml = Prism.highlight(code, grammar, language);
71
+ const codeElement = createElement("code", { "data-language": language, innerHTML: highlightedHtml });
72
+ preElement.replaceWith(codeElement);
73
+ }
74
+
75
+ export { createAttachmentFigure, createElement, dispatch, dispatchCustomEvent, generateDomId, highlightCode, isPreviewableImage, parseHtml };
@@ -240,14 +240,6 @@
240
240
  }
241
241
  }
242
242
 
243
- :where([data-lexical-cursor]) {
244
- animation: blink 1s step-end infinite;
245
- block-size: 1lh;
246
- border-inline-start: 1px solid currentColor;
247
- line-height: inherit;
248
- margin-block: 1em;
249
- }
250
-
251
243
  /* Attachments
252
244
  /* ------------------------------------------------------------------------ */
253
245
 
@@ -102,6 +102,20 @@
102
102
  outline: 2px dashed var(--lexxy-color-selected-dark);
103
103
  }
104
104
 
105
+ :where([data-lexical-cursor]) {
106
+ animation: blink 1s infinite;
107
+ block-size: 1lh;
108
+ border-inline-start: 1.5px solid currentColor;
109
+ line-height: inherit;
110
+ margin-block: 0 var(--lexxy-content-margin);
111
+ }
112
+
113
+ @keyframes blink {
114
+ 0% { opacity: 1; }
115
+ 60% { opacity: 1; }
116
+ 100% { opacity: 0;}
117
+ }
118
+
105
119
  /* Toolbar
106
120
  /* -------------------------------------------------------------------------- */
107
121
 
@@ -381,7 +395,7 @@
381
395
  /* Table handle buttons
382
396
  /* -------------------------------------------------------------------------- */
383
397
 
384
- :where(.lexxy-table-handle-buttons) {
398
+ :where(lexxy-table-handler) {
385
399
  --button-size: 2.5lh;
386
400
 
387
401
  color: var(--lexxy-color-ink-inverted);
@@ -502,28 +516,32 @@
502
516
  /* Language picker
503
517
  /* -------------------------------------------------------------------------- */
504
518
 
505
- :where(.lexxy-code-language-picker) {
506
- -webkit-appearance: none;
507
- appearance: none;
508
- background-color: var(--lexxy-color-canvas);
509
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
510
- background-position: center right 1ch;
511
- background-repeat: no-repeat;
512
- background-size: 1ch;
513
- block-size: 1.5lh;
514
- border: 1px solid var(--lexxy-color-ink-lighter);
515
- border-radius: var(--lexxy-radius);
516
- color: var(--lexxy-color-ink);
517
- font-family: var(--lexxy-font-base);
518
- font-size: var(--lexxy-text-small);
519
- font-weight: normal;
519
+ :where(lexxy-code-language-picker) {
520
520
  inset-inline-end: var(--lexxy-editor-padding);
521
- margin: 0.5ch 0.5ch 0 -0.5ch;
522
- padding: 0 2ch 0 1ch;
523
- text-align: start;
521
+ position: absolute;
522
+
523
+ select {
524
+ -webkit-appearance: none;
525
+ appearance: none;
526
+ background-color: var(--lexxy-color-canvas);
527
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
528
+ background-position: center right 1ch;
529
+ background-repeat: no-repeat;
530
+ background-size: 1ch;
531
+ block-size: 1.5lh;
532
+ border: 1px solid var(--lexxy-color-ink-lighter);
533
+ border-radius: var(--lexxy-radius);
534
+ color: var(--lexxy-color-ink);
535
+ font-family: var(--lexxy-font-base);
536
+ font-size: var(--lexxy-text-small);
537
+ font-weight: normal;
538
+ margin: 0.5ch 0.5ch 0 -0.5ch;
539
+ padding: 0 2ch 0 1ch;
540
+ text-align: start;
524
541
 
525
- @media (prefers-color-scheme: dark) {
526
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
542
+ @media (prefers-color-scheme: dark) {
543
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
544
+ }
527
545
  }
528
546
  }
529
547
 
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.7.0-beta",
3
+ "version": "0.7.2-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",
7
- "exports": "./dist/lexxy.esm.js",
7
+ "exports": {
8
+ ".": "./dist/lexxy.esm.js",
9
+ "./helpers": "./dist/lexxy_helpers.esm.js"
10
+ },
8
11
  "files": [
9
12
  "dist"
10
13
  ],