@spfx-fast/sharepoint-ui 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +23 -3
  2. package/QUICKSTART.md +133 -0
  3. package/README.md +3 -1
  4. package/components.json +168 -28
  5. package/dist/appearance.js +4 -4
  6. package/dist/elements/alert.js +26 -2
  7. package/dist/elements/avatar.js +1 -1
  8. package/dist/elements/button.js +1 -1
  9. package/dist/elements/carousel.js +5 -2
  10. package/dist/elements/color-picker.js +1 -1
  11. package/dist/elements/combobox.js +5 -5
  12. package/dist/elements/data-grid-model.d.ts +3 -0
  13. package/dist/elements/data-grid.d.ts +2 -1
  14. package/dist/elements/data-grid.js +19 -10
  15. package/dist/elements/date-picker.js +1 -1
  16. package/dist/elements/dialog.js +1 -1
  17. package/dist/elements/file-upload.js +1 -1
  18. package/dist/elements/floating-position.d.ts +10 -0
  19. package/dist/elements/floating-position.js +53 -0
  20. package/dist/elements/info.js +1 -1
  21. package/dist/elements/input.js +2 -2
  22. package/dist/elements/link.js +1 -1
  23. package/dist/elements/message-bar.js +33 -5
  24. package/dist/elements/nav.js +4 -1
  25. package/dist/elements/overflow.d.ts +1 -0
  26. package/dist/elements/overflow.js +2 -2
  27. package/dist/elements/popover.js +1 -1
  28. package/dist/elements/rating.js +3 -3
  29. package/dist/elements/rich-text-editor.d.ts +15 -1
  30. package/dist/elements/rich-text-editor.js +938 -79
  31. package/dist/elements/search.js +3 -10
  32. package/dist/elements/select.js +2 -2
  33. package/dist/elements/shared.d.ts +4 -1
  34. package/dist/elements/shared.js +14 -1
  35. package/dist/elements/sharepoint-field-basic-controls.d.ts +1 -0
  36. package/dist/elements/sharepoint-field-basic-controls.js +14 -0
  37. package/dist/elements/sharepoint-fields.d.ts +29 -0
  38. package/dist/elements/sharepoint-fields.js +460 -0
  39. package/dist/elements/spin-button.js +2 -2
  40. package/dist/elements/tag-picker.js +29 -11
  41. package/dist/elements/textarea.js +2 -2
  42. package/dist/elements/toaster.js +2 -1
  43. package/dist/elements/tooltip.js +18 -1
  44. package/dist/elements/tree.js +1 -1
  45. package/dist/elements.d.ts +3 -2
  46. package/dist/elements.js +4 -1
  47. package/dist/index.d.ts +6 -4
  48. package/dist/index.js +4 -2
  49. package/dist/react.d.ts +142 -10
  50. package/dist/react.js +306 -46
  51. package/dist/sharepoint-fields.d.ts +135 -0
  52. package/dist/sharepoint-fields.js +402 -0
  53. package/dist/theme.d.ts +9 -1
  54. package/dist/theme.js +78 -1
  55. package/package.json +11 -2
  56. package/skills/sharepoint-ui/SKILL.md +1 -1
  57. package/skills/sharepoint-ui/references/sharepoint-patterns.md +1 -1
@@ -1,6 +1,42 @@
1
1
  import { base, syncHostAccessibleName } from "./shared.js";
2
- const ALLOWED = new Set(["P", "DIV", "SPAN", "BR", "HR", "STRONG", "B", "EM", "I", "U", "S", "DEL", "INS", "UL", "OL", "LI", "A", "H1", "H2", "H3", "H4", "H5", "H6", "BLOCKQUOTE", "PRE", "TABLE", "THEAD", "TBODY", "TFOOT", "TR", "TH", "TD", "CAPTION", "SUP", "SUB", "CODE", "IMG"]);
2
+ export { getRichTextEditorFieldOptions } from "../sharepoint-fields.js";
3
+ const toolbarIconPaths = {
4
+ bold: "M6 4h5a3 3 0 0 1 0 6H6z M6 10h6a3 3 0 0 1 0 6H6z", italic: "M10 4h6 M8 16h6 M13 4 9 16", underline: "M6 4v5a4 4 0 0 0 8 0V4 M5 16h10", strikeThrough: "M3 10h14 M6 5c1-2 7-2 8 0 M6 15c1 2 7 2 8 0",
5
+ subscript: "M5 4l8 8m0-8L5 12m9 3h4m-3 0v3", superscript: "M5 4l8 8m0-8L5 12m9-5h4m-3 0V4",
6
+ justifyLeft: "M3 4h14M3 8h9M3 12h14M3 16h9", justifyCenter: "M4 4h12M6 8h8M4 12h12M6 16h8", justifyRight: "M3 4h14M8 8h9M3 12h14M8 16h9", justifyFull: "M3 4h14M3 8h14M3 12h14M3 16h14",
7
+ removeFormat: "M4 4l12 12 M6 6l4 4m2 2 2 2 M8 4h8 M5 16h5",
8
+ insertUnorderedList: "M8 5h9 M8 10h9 M8 15h9 M4 5h.01 M4 10h.01 M4 15h.01", insertOrderedList: "M9 5h8 M9 10h8 M9 15h8 M4 4h1v3 M3.5 7h2 M3.5 10.5c0-1 2-1 2 0 0 .6-2 1.5-2 2.5h2 M4 14h1a1 1 0 0 1 0 2H4",
9
+ undo: "M9 7 5 10l4 3 M5 10h7a4 4 0 0 1 4 4", redo: "M11 7 15 10l-4 3 M15 10H8a4 4 0 0 0-4 4",
10
+ insertTable: "M3 4h14v12H3z M3 8h14 M3 12h14 M8 4v12 M13 4v12", insertImage: "M3 4h14v12H3z M4 14l4-4 3 3 2-2 3 3 M7 7h.01",
11
+ insertRowAbove: "M3 9h14v7H3z M3 12h14 M10 3v4 M8 5h4", insertRowBelow: "M3 4h14v7H3z M3 8h14 M10 13v4 M8 15h4",
12
+ insertColumnBefore: "M6 4h11v12H6z M10 4v12 M14 4v12 M3 10h3 M4.5 8.5v3", insertColumnAfter: "M3 4h11v12H3z M7 4v12 M11 4v12 M14 10h3 M15.5 8.5v3",
13
+ deleteRow: "M3 5h14v10H3z M3 10h14 M8 5v5 M13 5v5 M8 13h4", deleteColumn: "M5 3v14h10V3z M10 3v14 M5 8h5 M5 13h5 M12 8v4", deleteTable: "M3 4h14v12H3z M3 8h14 M3 12h14 M8 4v12 M13 4v12 M10 9v4 M8.5 11h3",
14
+ createLink: "M7 10h6 M9 7l1-1a3 3 0 0 1 4 4l-2 2a3 3 0 0 1-4 0 M11 13l-1 1a3 3 0 0 1-4-4l2-2a3 3 0 0 1 4 0"
15
+ };
16
+ function createToolbarIcon(command) {
17
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
18
+ icon.setAttribute("viewBox", "0 0 20 20");
19
+ icon.setAttribute("width", "20");
20
+ icon.setAttribute("height", "20");
21
+ icon.setAttribute("aria-hidden", "true");
22
+ icon.setAttribute("focusable", "false");
23
+ icon.setAttribute("fill", "none");
24
+ icon.setAttribute("stroke", "currentColor");
25
+ icon.setAttribute("stroke-width", "1.7");
26
+ icon.setAttribute("stroke-linecap", "round");
27
+ icon.setAttribute("stroke-linejoin", "round");
28
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
29
+ path.setAttribute("d", toolbarIconPaths[command] ?? "M4 10h12");
30
+ icon.append(path);
31
+ return icon;
32
+ }
33
+ const ALLOWED = new Set(["P", "DIV", "SPAN", "BR", "HR", "STRONG", "B", "EM", "I", "U", "S", "STRIKE", "DEL", "INS", "UL", "OL", "LI", "A", "H1", "H2", "H3", "H4", "H5", "H6", "BLOCKQUOTE", "PRE", "TABLE", "THEAD", "TBODY", "TFOOT", "TR", "TH", "TD", "CAPTION", "SUP", "SUB", "CODE", "IMG"]);
34
+ const HTML_ONLY = new Set(["MAIN", "SECTION", "ARTICLE", "HEADER", "FOOTER", "ASIDE", "FIGURE", "FIGCAPTION", "DETAILS", "SUMMARY", "DL", "DT", "DD", "ABBR", "CITE", "Q", "MARK", "SMALL", "TIME", "WBR"]);
35
+ const RESTRICTED = new Set(["P", "DIV", "SPAN", "BR", "STRONG", "B", "EM", "I", "U", "S", "STRIKE", "DEL", "INS", "UL", "OL", "LI", "H1", "H2", "H3", "H4", "H5", "H6", "BLOCKQUOTE", "PRE", "SUP", "SUB", "CODE"]);
3
36
  const DROP_CONTENT = new Set(["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED", "TEMPLATE"]);
37
+ const HTML_STYLE_PROPERTIES = new Set(["background-color", "color", "direction", "font-family", "font-size", "font-style", "font-weight", "letter-spacing", "line-height", "list-style-type", "margin-block-end", "margin-block-start", "margin-inline-end", "margin-inline-start", "text-align", "text-decoration", "text-indent", "text-transform", "vertical-align", "white-space"]);
38
+ const RICH_STYLE_PROPERTIES = new Set(["background-color", "color", "font-family", "font-size", "font-style", "font-weight", "text-align", "text-decoration"]);
39
+ const FONT_SIZE_PX = { "1": "10px", "2": "12px", "3": "16px", "4": "18px", "5": "24px", "6": "32px", "7": "48px" };
4
40
  function safeHref(value) {
5
41
  if (/[\u0000-\u001f\u007f]/.test(value))
6
42
  return false;
@@ -19,8 +55,40 @@ function safeImageSrc(value) {
19
55
  return false;
20
56
  }
21
57
  }
22
- function cleanHtml(html) {
58
+ function safeLanguageTag(value) {
59
+ const tag = value.trim();
60
+ if (!tag || tag.length > 128)
61
+ return false;
62
+ if (typeof Intl !== "undefined" && typeof Intl.getCanonicalLocales === "function") {
63
+ try {
64
+ return Intl.getCanonicalLocales(tag).length === 1;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ return /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/i.test(tag);
71
+ }
72
+ function plainText(html) {
23
73
  const doc = new DOMParser().parseFromString(html, "text/html");
74
+ doc.body.querySelectorAll([...DROP_CONTENT].join(",")).forEach(element => element.remove());
75
+ const read = (node) => [...node.childNodes].map(child => {
76
+ if (child.nodeType === Node.TEXT_NODE)
77
+ return child.textContent ?? "";
78
+ if (child.nodeType !== Node.ELEMENT_NODE)
79
+ return "";
80
+ if (child.tagName === "BR")
81
+ return "\n";
82
+ const value = read(child);
83
+ return /^(ADDRESS|ARTICLE|BLOCKQUOTE|DIV|H[1-6]|LI|P|PRE|SECTION)$/.test(child.tagName) ? `${value}\n` : value;
84
+ }).join("");
85
+ return read(doc.body).replace(/[\t ]*\n[\t ]*/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\n$/, "");
86
+ }
87
+ function cleanHtml(html, mode = "html", allowHyperlink = true) {
88
+ const doc = new DOMParser().parseFromString(html, "text/html");
89
+ if (mode === "plain")
90
+ return plainText(html);
91
+ const allowedTags = mode === "restricted" ? RESTRICTED : mode === "html" ? new Set([...ALLOWED, ...HTML_ONLY]) : ALLOWED;
24
92
  const clean = (node) => {
25
93
  for (const child of [...node.childNodes]) {
26
94
  if (child.nodeType === Node.ELEMENT_NODE) {
@@ -29,19 +97,62 @@ function cleanHtml(html) {
29
97
  child.remove();
30
98
  continue;
31
99
  }
32
- if (!ALLOWED.has(element.tagName)) {
100
+ if (element.tagName === "FONT" && (mode === "rich" || mode === "html")) {
101
+ const span = doc.createElement("span");
102
+ const face = element.getAttribute("face")?.trim();
103
+ const color = element.getAttribute("color")?.trim();
104
+ const size = element.getAttribute("size")?.trim();
105
+ if (face && (typeof CSS === "undefined" || CSS.supports("font-family", face)))
106
+ span.style.fontFamily = face;
107
+ if (color && (typeof CSS === "undefined" || CSS.supports("color", color)))
108
+ span.style.color = color;
109
+ if (size && FONT_SIZE_PX[size])
110
+ span.style.fontSize = FONT_SIZE_PX[size];
111
+ if (element.hasAttribute("style"))
112
+ span.setAttribute("style", [span.getAttribute("style"), element.getAttribute("style")].filter(Boolean).join(";"));
113
+ span.append(...[...element.childNodes]);
114
+ child.replaceWith(span);
115
+ clean(span);
116
+ continue;
117
+ }
118
+ if (!allowedTags.has(element.tagName) || (element.tagName === "A" && !allowHyperlink)) {
33
119
  clean(element);
34
120
  child.replaceWith(...[...child.childNodes]);
35
121
  continue;
36
122
  }
37
123
  for (const attr of [...element.attributes]) {
124
+ if (attr.name === "style" && (mode === "html" || mode === "rich")) {
125
+ const allowedStyles = mode === "html" ? HTML_STYLE_PROPERTIES : RICH_STYLE_PROPERTIES;
126
+ const safeStyles = [...element.style].filter(property => {
127
+ const value = element.style.getPropertyValue(property).trim();
128
+ return allowedStyles.has(property) && !/(?:url|expression|@import|javascript:|var\s*\()/i.test(value) && (typeof CSS === "undefined" || CSS.supports(property, value));
129
+ }).map(property => `${property}: ${element.style.getPropertyValue(property).trim()}`).join("; ");
130
+ if (safeStyles)
131
+ element.setAttribute("style", safeStyles);
132
+ else
133
+ element.removeAttribute("style");
134
+ continue;
135
+ }
38
136
  const link = element.tagName === "A" && attr.name === "href" && safeHref(attr.value);
137
+ const linkTitle = element.tagName === "A" && attr.name === "title" && attr.value.length <= 512;
138
+ const linkTarget = element.tagName === "A" && attr.name === "target" && attr.value === "_blank";
139
+ const linkRel = element.tagName === "A" && attr.name === "rel" && /^(noopener noreferrer|noreferrer noopener)$/i.test(attr.value.trim());
39
140
  const span = ["TD", "TH"].includes(element.tagName) && ["colspan", "rowspan"].includes(attr.name) && /^\d{1,2}$/.test(attr.value) && Number(attr.value) > 0;
40
141
  const scope = element.tagName === "TH" && attr.name === "scope" && ["row", "col", "rowgroup", "colgroup"].includes(attr.value);
41
142
  const image = element.tagName === "IMG" && ((attr.name === "src" && safeImageSrc(attr.value)) || attr.name === "alt" || ((attr.name === "width" || attr.name === "height") && /^\d{1,4}$/.test(attr.value)));
42
- if (!link && !span && !scope && !image)
143
+ const direction = attr.name === "dir" && ["ltr", "rtl", "auto"].includes(attr.value.trim().toLowerCase());
144
+ const language = attr.name === "lang" && safeLanguageTag(attr.value);
145
+ if (!link && !linkTitle && !linkTarget && !linkRel && !span && !scope && !image && !direction && !language)
43
146
  element.removeAttribute(attr.name);
44
147
  }
148
+ if (element.tagName === "A") {
149
+ if (element.hasAttribute("href") && element.getAttribute("target") === "_blank")
150
+ element.setAttribute("rel", "noopener noreferrer");
151
+ else {
152
+ element.removeAttribute("target");
153
+ element.removeAttribute("rel");
154
+ }
155
+ }
45
156
  clean(element);
46
157
  }
47
158
  else if (child.nodeType !== Node.TEXT_NODE)
@@ -56,22 +167,41 @@ export function defineRichTextEditor() {
56
167
  return;
57
168
  class SpuiRichTextEditor extends HTMLElement {
58
169
  static formAssociated = true;
59
- static observedAttributes = ["label", "editor-label", "placeholder", "disabled", "read-only", "required", "required-message", "value", "name", "aria-label", "aria-labelledby", "aria-description", "aria-describedby", "aria-invalid", "bold-label", "italic-label", "underline-label", "bullet-list-label", "number-list-label", "link-label", "undo-label", "redo-label", "insert-table-label", "insert-image-label", "image-input-label", "insert-row-above-label", "insert-row-below-label", "insert-column-before-label", "insert-column-after-label", "delete-row-label", "delete-column-label", "delete-table-label", "toolbar-label", "link-prompt"];
170
+ static observedAttributes = ["label", "editor-label", "placeholder", "content-mode", "allow-hyperlink", "disabled", "read-only", "required", "required-message", "value", "name", "aria-label", "aria-labelledby", "aria-description", "aria-describedby", "aria-invalid", "bold-label", "italic-label", "underline-label", "strike-label", "subscript-label", "superscript-label", "align-left-label", "align-center-label", "align-right-label", "align-justify-label", "clear-formatting-label", "format-block-label", "font-family-label", "font-size-label", "paragraph-label", "heading-1-label", "heading-2-label", "heading-3-label", "blockquote-label", "preformatted-label", "bullet-list-label", "number-list-label", "link-label", "text-color-label", "highlight-color-label", "custom-color-label", "theme-accent-label", "color-swatch-label", "undo-label", "redo-label", "insert-table-label", "insert-image-label", "image-input-label", "insert-row-above-label", "insert-row-below-label", "insert-column-before-label", "insert-column-after-label", "delete-row-label", "delete-column-label", "delete-table-label", "toolbar-label", "link-prompt", "link-dialog-label", "link-text-label", "link-title-label", "link-apply-label", "link-remove-label", "link-cancel-label", "link-new-tab-label", "link-invalid-message", "link-picker-label", "link-picker-loading-label", "link-picker-error-message"];
60
171
  editor;
61
172
  internals;
62
173
  formDisabled = false;
63
174
  pendingValue;
64
175
  customError = "";
65
176
  savedRange;
177
+ preserveToolbarSelection = false;
178
+ probingSelection = false;
179
+ probedSelection;
66
180
  /** Uploads to a durable asset store and returns the image URL to persist in editor HTML. */
67
181
  imageUploader;
68
182
  get uploadImage() { return this.imageUploader; }
69
183
  set uploadImage(value) { this.imageUploader = value; this.syncImageButton(); }
184
+ linkPickerValue;
185
+ linkPickerButton;
186
+ pickingLink = false;
187
+ linkEditorVersion = 0;
188
+ get linkPicker() { return this.linkPickerValue; }
189
+ set linkPicker(value) { this.linkPickerValue = value; this.syncLinkPickerButton(); }
70
190
  imageInput;
191
+ linkForm;
192
+ linkUrlInput;
193
+ linkTextInput;
194
+ linkTitleInput;
195
+ linkNewTabInput;
196
+ linkError;
197
+ linkPickerStatus;
198
+ linkRemoveButton;
71
199
  uploadingImage = false;
72
200
  savedCell;
73
201
  selectedTablePath;
74
202
  events;
203
+ get contentMode() { const mode = this.getAttribute("content-mode"); return mode === "plain" || mode === "restricted" || mode === "rich" || mode === "html" ? mode : "html"; }
204
+ get allowHyperlinks() { return this.contentMode !== "plain" && this.contentMode !== "restricted" && this.getAttribute("allow-hyperlink") !== "false"; }
75
205
  constructor() { super(); try {
76
206
  this.internals = this.attachInternals();
77
207
  }
@@ -93,6 +223,10 @@ export function defineRichTextEditor() {
93
223
  return;
94
224
  if (name === "value")
95
225
  this.value = this.getAttribute("value") ?? "";
226
+ else if ((name === "content-mode" || name === "allow-hyperlink") && this.editor) {
227
+ this.value = this.value;
228
+ this.syncContentMode();
229
+ }
96
230
  else if ((name === "disabled" || name === "read-only") && this.editor) {
97
231
  this.syncDisabled();
98
232
  this.syncForm();
@@ -129,11 +263,20 @@ export function defineRichTextEditor() {
129
263
  this.refreshStrings();
130
264
  }
131
265
  syncEditorLabel() { this.editor.setAttribute("aria-label", this.getAttribute("aria-label") ?? this.getAttribute("label") ?? this.getAttribute("editor-label") ?? "Rich text"); }
132
- get value() { return this.editor ? cleanHtml(this.editor.innerHTML) : this.pendingValue ?? this.getAttribute("value") ?? ""; }
133
- set value(value) { this.pendingValue = value; if (this.editor) {
134
- this.editor.innerHTML = cleanHtml(value);
135
- this.syncForm();
136
- } }
266
+ get value() { return this.editor ? (this.contentMode === "plain" ? plainText(this.editor.innerHTML) : cleanHtml(this.editor.innerHTML, this.contentMode, this.allowHyperlinks)) : this.pendingValue ?? this.getAttribute("value") ?? ""; }
267
+ set value(value) {
268
+ this.pendingValue = value;
269
+ if (!this.editor)
270
+ return;
271
+ const normalized = this.contentMode === "plain" ? plainText(value) : cleanHtml(value, this.contentMode, this.allowHyperlinks);
272
+ if (this.value !== normalized) {
273
+ if (this.contentMode === "plain")
274
+ this.editor.textContent = normalized;
275
+ else
276
+ this.editor.innerHTML = normalized;
277
+ }
278
+ this.syncForm(normalized);
279
+ }
137
280
  get form() { return this.internals?.form ?? null; }
138
281
  get validity() { return this.internals?.validity; }
139
282
  get validationMessage() { return this.internals?.validationMessage ?? ""; }
@@ -151,44 +294,201 @@ export function defineRichTextEditor() {
151
294
  syncDisabled() {
152
295
  const disabled = this.formDisabled || this.hasAttribute("disabled");
153
296
  const readOnly = disabled || this.hasAttribute("read-only");
154
- this.editor.contentEditable = String(!readOnly);
297
+ this.editor.contentEditable = readOnly ? "false" : this.contentMode === "plain" ? "plaintext-only" : "true";
155
298
  this.editor.setAttribute("aria-readonly", String(this.hasAttribute("read-only")));
156
299
  this.editor.setAttribute("aria-disabled", String(disabled));
157
- this.shadowRoot?.querySelectorAll("[role=toolbar] button").forEach(button => { button.disabled = readOnly; });
300
+ this.shadowRoot?.querySelectorAll("[role=toolbar] button,[role=toolbar] select,[role=toolbar] input[type=color]").forEach(control => { control.disabled = readOnly; });
301
+ this.shadowRoot?.querySelectorAll("[role=toolbar] .toolbar-color").forEach(picker => {
302
+ const summary = picker.querySelector("summary");
303
+ if (summary) {
304
+ summary.tabIndex = readOnly ? -1 : 0;
305
+ summary.setAttribute("aria-disabled", String(readOnly));
306
+ }
307
+ if (readOnly)
308
+ picker.open = false;
309
+ });
310
+ if (this.linkForm) {
311
+ if (readOnly)
312
+ this.linkForm.hidden = true;
313
+ this.linkForm.querySelectorAll("input,button").forEach(control => { control.disabled = readOnly; });
314
+ }
158
315
  this.syncImageButton();
316
+ this.syncLinkPickerButton();
159
317
  }
160
318
  syncImageButton() {
161
319
  const button = this.shadowRoot?.querySelector('[data-command="insertImage"]');
162
- if (button)
320
+ if (button) {
321
+ button.hidden = !this.imageUploader || this.contentMode === "plain" || this.contentMode === "restricted";
163
322
  button.disabled = this.formDisabled || this.hasAttribute("disabled") || this.hasAttribute("read-only") || !this.imageUploader || this.uploadingImage;
323
+ }
324
+ this.syncToolbarGroups();
325
+ }
326
+ syncToolbarGroups() {
327
+ this.shadowRoot?.querySelectorAll(".toolbar-group:not(.toolbar-group--table):not(.toolbar-group--color):not(.toolbar-group--fonts)").forEach(group => { group.hidden = !group.querySelector("button:not([hidden])"); });
328
+ const colorGroup = this.shadowRoot?.querySelector(".toolbar-group--color");
329
+ if (colorGroup)
330
+ colorGroup.hidden = !["rich", "html"].includes(this.contentMode);
331
+ const fontGroup = this.shadowRoot?.querySelector(".toolbar-group--fonts");
332
+ if (fontGroup)
333
+ fontGroup.hidden = !["rich", "html"].includes(this.contentMode);
334
+ }
335
+ syncContentMode() {
336
+ const toolbar = this.shadowRoot?.querySelector('[role="toolbar"]');
337
+ if (toolbar) {
338
+ toolbar.hidden = this.contentMode === "plain";
339
+ toolbar.querySelectorAll("button[data-command]").forEach(button => {
340
+ const command = button.dataset.command;
341
+ button.hidden = (this.contentMode === "restricted" && (command === "insertTable" || command === "insertImage" || button.hasAttribute("data-table-command"))) || (this.contentMode !== "html" && ["subscript", "superscript", "justifyLeft", "justifyCenter", "justifyRight", "justifyFull"].includes(command ?? "")) || (command === "createLink" && !this.allowHyperlinks);
342
+ });
343
+ const colorGroup = toolbar.querySelector(".toolbar-group--color");
344
+ if (colorGroup)
345
+ colorGroup.hidden = !["rich", "html"].includes(this.contentMode);
346
+ const fontGroup = toolbar.querySelector(".toolbar-group--fonts");
347
+ if (fontGroup)
348
+ fontGroup.hidden = !["rich", "html"].includes(this.contentMode);
349
+ }
350
+ if (this.editor)
351
+ this.editor.style.whiteSpace = this.contentMode === "plain" ? "pre-wrap" : "";
352
+ this.syncDisabled();
353
+ this.syncTableActions();
354
+ this.syncToolbarGroups();
355
+ }
356
+ syncLinkPickerButton() {
357
+ if (this.linkPickerButton) {
358
+ this.linkPickerButton.hidden = !this.linkPickerValue || !this.allowHyperlinks;
359
+ this.linkPickerButton.disabled = this.formDisabled || this.hasAttribute("disabled") || this.hasAttribute("read-only");
360
+ this.linkPickerButton.setAttribute("aria-disabled", String(this.pickingLink));
361
+ this.linkPickerButton.setAttribute("aria-busy", String(this.pickingLink));
362
+ this.linkPickerButton.textContent = this.pickingLink
363
+ ? this.getAttribute("link-picker-loading-label") ?? "Opening picker…"
364
+ : this.getAttribute("link-picker-label") ?? "Browse SharePoint";
365
+ }
366
+ if (this.linkPickerStatus)
367
+ this.linkPickerStatus.textContent = this.pickingLink ? this.getAttribute("link-picker-loading-label") ?? "Opening picker…" : "";
164
368
  }
165
- syncForm() { const value = this.value; this.pendingValue = value; this.internals?.setFormValue(value || null); const active = this.willValidate; const missing = active && this.hasAttribute("required") && !this.editor?.textContent?.trim(); const customError = active && Boolean(this.customError); const flags = { ...(missing ? { valueMissing: true } : {}), ...(customError ? { customError: true } : {}) }; const message = customError ? this.customError : missing ? this.getAttribute("required-message") ?? "Enter some text." : ""; try {
369
+ syncForm(value = this.value) { this.pendingValue = value; this.internals?.setFormValue(value || null); const active = this.willValidate; const missing = active && this.hasAttribute("required") && !this.editor?.textContent?.trim(); const customError = active && Boolean(this.customError); const flags = { ...(missing ? { valueMissing: true } : {}), ...(customError ? { customError: true } : {}) }; const message = customError ? this.customError : missing ? this.getAttribute("required-message") ?? "Enter some text." : ""; try {
166
370
  this.internals?.setValidity(flags, message);
167
371
  }
168
372
  catch { /* Engines without form association remain editable. */ } }
373
+ commitEditorValue(eventName = "spui-input") {
374
+ const value = this.sanitizeEditor();
375
+ this.onSelectionChange();
376
+ this.syncForm(value);
377
+ const event = eventName === "spui-change"
378
+ ? new CustomEvent("spui-change", { detail: { value }, bubbles: true, composed: true })
379
+ : new CustomEvent("spui-input", { detail: { value }, bubbles: true, composed: true });
380
+ this.dispatchEvent(event);
381
+ }
169
382
  sanitizeEditor() {
170
383
  const html = this.editor.innerHTML;
171
- const value = cleanHtml(html);
384
+ const value = cleanHtml(html, this.contentMode, this.allowHyperlinks);
385
+ if (this.contentMode === "plain") {
386
+ if (this.editor.textContent !== value)
387
+ this.editor.textContent = value;
388
+ return value;
389
+ }
172
390
  if (value === html)
173
- return;
391
+ return value;
392
+ const selection = this.captureTextSelection();
174
393
  const cell = this.selectedCell();
175
394
  const tableIndex = cell ? [...this.editor.querySelectorAll("table")].indexOf(cell.closest("table")) : -1;
176
395
  const rowIndex = cell?.closest("tr")?.rowIndex ?? -1;
177
396
  const cellIndex = cell?.cellIndex ?? -1;
178
397
  this.editor.innerHTML = value;
398
+ if (selection)
399
+ this.restoreTextSelection(selection);
179
400
  const restoredCell = tableIndex >= 0 && rowIndex >= 0 && cellIndex >= 0
180
401
  ? this.editor.querySelectorAll("table")[tableIndex]?.rows[rowIndex]?.cells[cellIndex]
181
402
  : undefined;
182
- this.savedCell = restoredCell;
183
- if (restoredCell) {
403
+ this.savedCell = restoredCell ?? this.selectedCell();
404
+ if (!selection && restoredCell) {
184
405
  const range = document.createRange();
185
406
  range.selectNodeContents(restoredCell);
186
407
  range.collapse(true);
187
408
  this.savedRange = range;
188
409
  }
189
- else
410
+ else if (!selection)
190
411
  this.savedRange = undefined;
191
412
  this.syncTableActions();
413
+ return value;
414
+ }
415
+ captureTextSelection() {
416
+ const selection = this.selection();
417
+ const anchorNode = selection?.anchorNode;
418
+ const focusNode = selection?.focusNode;
419
+ if (anchorNode && focusNode && this.editor.contains(anchorNode) && this.editor.contains(focusNode)) {
420
+ return { anchor: this.selectionPoint(anchorNode, selection.anchorOffset), focus: this.selectionPoint(focusNode, selection.focusOffset) };
421
+ }
422
+ const range = this.savedRange;
423
+ if (!range || !this.editor.contains(range.commonAncestorContainer))
424
+ return undefined;
425
+ return { anchor: this.selectionPoint(range.startContainer, range.startOffset), focus: this.selectionPoint(range.endContainer, range.endOffset) };
426
+ }
427
+ selectionPoint(node, nodeOffset) {
428
+ const path = [];
429
+ for (let current = node; current !== this.editor;) {
430
+ const parent = current.parentNode;
431
+ if (!parent)
432
+ break;
433
+ path.unshift(Array.from(parent.childNodes).findIndex(child => child === current));
434
+ current = parent;
435
+ }
436
+ return {
437
+ offset: this.textOffset(node, nodeOffset), path, nodeOffset,
438
+ ...(node.nodeType === Node.TEXT_NODE ? { text: node.textContent ?? "" } : {}),
439
+ ...(node instanceof Element ? { tag: node.tagName } : {})
440
+ };
441
+ }
442
+ textOffset(node, offset) {
443
+ const range = document.createRange();
444
+ range.selectNodeContents(this.editor);
445
+ range.setEnd(node, offset);
446
+ return range.toString().length;
447
+ }
448
+ textPoint(offset) {
449
+ const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT);
450
+ let remaining = Math.max(0, offset);
451
+ let last;
452
+ while (walker.nextNode()) {
453
+ const node = walker.currentNode;
454
+ const length = node.textContent?.length ?? 0;
455
+ last = node;
456
+ if (remaining <= length)
457
+ return { node, offset: remaining };
458
+ remaining -= length;
459
+ }
460
+ return last ? { node: last, offset: last.textContent?.length ?? 0 } : { node: this.editor, offset: 0 };
461
+ }
462
+ restoreTextSelection(snapshot) {
463
+ const anchor = this.restoreSelectionPoint(snapshot.anchor);
464
+ const focus = this.restoreSelectionPoint(snapshot.focus);
465
+ const selection = this.selection();
466
+ if (selection?.setBaseAndExtent) {
467
+ selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
468
+ this.savedRange = selection.rangeCount ? selection.getRangeAt(0).cloneRange() : undefined;
469
+ return;
470
+ }
471
+ const range = document.createRange();
472
+ const forward = snapshot.anchor.offset <= snapshot.focus.offset;
473
+ const start = forward ? anchor : focus;
474
+ const end = forward ? focus : anchor;
475
+ range.setStart(start.node, start.offset);
476
+ range.setEnd(end.node, end.offset);
477
+ selection?.removeAllRanges();
478
+ selection?.addRange(range);
479
+ this.savedRange = range.cloneRange();
480
+ }
481
+ restoreSelectionPoint(point) {
482
+ let node = this.editor;
483
+ for (const index of point.path)
484
+ node = node?.childNodes[index];
485
+ if (node && point.text !== undefined && node.nodeType === Node.TEXT_NODE && node.textContent === point.text) {
486
+ return { node, offset: Math.min(point.nodeOffset, point.text.length) };
487
+ }
488
+ if (node && point.text === undefined && node instanceof Element && node.tagName === point.tag && point.nodeOffset <= node.childNodes.length) {
489
+ return { node, offset: point.nodeOffset };
490
+ }
491
+ return this.textPoint(point.offset);
192
492
  }
193
493
  selectedCell() {
194
494
  const node = this.savedRange?.startContainer;
@@ -209,25 +509,79 @@ export function defineRichTextEditor() {
209
509
  }
210
510
  selection() {
211
511
  const root = this.editor.getRootNode();
212
- return root instanceof ShadowRoot ? root.getSelection?.() ?? document.getSelection() : document.getSelection();
512
+ return root instanceof ShadowRoot ? root.getSelection?.() ?? this.editor.ownerDocument.getSelection() : this.editor.ownerDocument.getSelection();
213
513
  }
214
514
  onSelectionChange = () => {
215
515
  const selection = this.selection();
216
- if (selection?.rangeCount) {
217
- const range = selection.getRangeAt(0);
218
- if (this.editor.contains(range.commonAncestorContainer)) {
219
- this.savedRange = range.cloneRange();
220
- const node = range.startContainer;
221
- const element = node instanceof Element ? node : node.parentElement;
222
- const cell = element?.closest("td,th");
223
- if (cell && this.editor.contains(cell)) {
224
- this.savedCell = cell;
225
- this.rememberCell(cell);
516
+ let range = selection?.rangeCount ? selection.getRangeAt(0) : undefined;
517
+ if (!range || !this.editor.contains(range.commonAncestorContainer)) {
518
+ const InputEventConstructor = this.editor.ownerDocument.defaultView?.InputEvent;
519
+ const touchFirst = this.editor.ownerDocument.defaultView?.matchMedia("(pointer: coarse)").matches ?? false;
520
+ if (!touchFirst && this.editor.matches(":focus") && typeof InputEventConstructor?.prototype.getTargetRanges === "function" && !this.probingSelection) {
521
+ this.probedSelection = undefined;
522
+ this.probingSelection = true;
523
+ try {
524
+ this.editor.ownerDocument.execCommand("indent");
226
525
  }
526
+ finally {
527
+ this.probingSelection = false;
528
+ }
529
+ range = this.probedSelection;
227
530
  }
228
531
  }
532
+ if (!range || !this.editor.contains(range.commonAncestorContainer))
533
+ return;
534
+ if (range.collapsed && this.preserveToolbarSelection && this.savedRange && !this.savedRange.collapsed)
535
+ return;
536
+ if (!range.collapsed)
537
+ this.preserveToolbarSelection = false;
538
+ this.savedRange = range.cloneRange();
539
+ const node = range.startContainer;
540
+ const element = node instanceof Element ? node : node.parentElement;
541
+ const cell = element?.closest("td,th");
542
+ if (cell && this.editor.contains(cell)) {
543
+ this.savedCell = cell;
544
+ this.rememberCell(cell);
545
+ }
546
+ this.syncToolbarState();
229
547
  this.syncTableActions();
230
548
  };
549
+ syncToolbarState() {
550
+ const document = this.editor.ownerDocument;
551
+ const toolbar = this.shadowRoot?.querySelector('[role="toolbar"]');
552
+ if (!toolbar)
553
+ return;
554
+ for (const button of toolbar.querySelectorAll('button[data-command="bold"],button[data-command="italic"],button[data-command="underline"],button[data-command="strikeThrough"],button[data-command="subscript"],button[data-command="superscript"],button[data-command^="justify"],button[data-command="insertUnorderedList"],button[data-command="insertOrderedList"]')) {
555
+ try {
556
+ button.setAttribute("aria-pressed", String(document.queryCommandState(button.dataset.command)));
557
+ }
558
+ catch {
559
+ button.setAttribute("aria-pressed", "false");
560
+ }
561
+ }
562
+ const format = toolbar.querySelector('select[data-command="formatBlock"]');
563
+ if (format) {
564
+ try {
565
+ const block = document.queryCommandValue("formatBlock").replace(/[<>]/g, "").toUpperCase();
566
+ format.value = [...format.options].some(option => option.value === block) ? block : "P";
567
+ }
568
+ catch {
569
+ format.value = "P";
570
+ }
571
+ }
572
+ const selection = this.selection();
573
+ const node = selection?.anchorNode && this.editor.contains(selection.anchorNode) ? selection.anchorNode : this.savedRange?.startContainer;
574
+ const element = node instanceof Element ? node : node?.parentElement;
575
+ if (element && this.editor.contains(element)) {
576
+ const computed = this.editor.ownerDocument.defaultView?.getComputedStyle(element);
577
+ const family = toolbar.querySelector('select[data-command="fontFamily"]');
578
+ const size = toolbar.querySelector('select[data-command="fontSize"]');
579
+ if (family && computed)
580
+ family.value = [...family.options].some(option => option.value === computed.fontFamily) ? computed.fontFamily : "";
581
+ if (size && computed)
582
+ size.value = Object.entries(FONT_SIZE_PX).find(([, px]) => px === computed.fontSize)?.[0] ?? "";
583
+ }
584
+ }
231
585
  onEditorClick = (event) => {
232
586
  const cell = event.composedPath().find((node) => node instanceof HTMLTableCellElement && this.editor.contains(node));
233
587
  if (cell) {
@@ -242,19 +596,94 @@ export function defineRichTextEditor() {
242
596
  const hasSpans = Boolean(table && [...table.querySelectorAll("td,th")].some(cell => cell.rowSpan !== 1 || cell.colSpan !== 1));
243
597
  const rowCount = table?.rows.length ?? 0;
244
598
  const columnCount = table ? Math.min(...[...table.rows].map(row => row.cells.length)) : 0;
599
+ const tableGroup = this.shadowRoot?.querySelector(".toolbar-group--table");
600
+ if (tableGroup)
601
+ tableGroup.hidden = this.contentMode === "restricted" || this.contentMode === "plain" || !cell || !table;
245
602
  this.shadowRoot?.querySelectorAll("button[data-table-command]").forEach(button => {
246
603
  const command = button.dataset.tableCommand;
247
604
  button.disabled = !cell || !table || hasSpans || (command === "deleteRow" && rowCount <= 1) || (command === "deleteColumn" && columnCount <= 1);
248
605
  });
249
606
  }
250
607
  restoreSelection() {
251
- this.editor.focus();
252
608
  const selection = this.selection();
609
+ const liveRange = selection?.rangeCount ? selection.getRangeAt(0) : undefined;
610
+ if ((!liveRange || !this.editor.contains(liveRange.commonAncestorContainer)) && this.editor.matches(":focus"))
611
+ return;
612
+ const keepUnfocusedSelection = Boolean(this.savedRange && !this.savedRange.collapsed && !this.editor.matches(":focus"));
613
+ if (!keepUnfocusedSelection)
614
+ this.editor.focus();
253
615
  if (!selection || !this.savedRange || !this.editor.contains(this.savedRange.commonAncestorContainer))
254
616
  return;
255
617
  selection.removeAllRanges();
256
618
  selection.addRange(this.savedRange);
257
619
  }
620
+ wrapUnfocusedSelection(range, createWrapper) {
621
+ const selectionRoot = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
622
+ ? range.commonAncestorContainer.parentNode ?? this.editor
623
+ : range.commonAncestorContainer;
624
+ const walker = this.editor.ownerDocument.createTreeWalker(selectionRoot, NodeFilter.SHOW_TEXT);
625
+ const nodes = [];
626
+ while (walker.nextNode()) {
627
+ const node = walker.currentNode;
628
+ if (range.intersectsNode(node))
629
+ nodes.push(node);
630
+ }
631
+ const wrappers = [];
632
+ for (const node of nodes.reverse()) {
633
+ const start = node === range.startContainer ? range.startOffset : 0;
634
+ const end = node === range.endContainer ? range.endOffset : node.length;
635
+ if (end <= start)
636
+ continue;
637
+ const part = this.editor.ownerDocument.createRange();
638
+ part.setStart(node, start);
639
+ part.setEnd(node, end);
640
+ const wrapper = createWrapper();
641
+ wrapper.append(part.extractContents());
642
+ part.insertNode(wrapper);
643
+ wrappers.push(wrapper);
644
+ }
645
+ if (!wrappers.length)
646
+ return false;
647
+ wrappers.reverse();
648
+ const selected = this.editor.ownerDocument.createRange();
649
+ selected.setStartBefore(wrappers[0]);
650
+ selected.setEndAfter(wrappers[wrappers.length - 1]);
651
+ const selection = this.selection();
652
+ selection?.removeAllRanges();
653
+ selection?.addRange(selected);
654
+ this.savedRange = selected.cloneRange();
655
+ return true;
656
+ }
657
+ applyStyleToUnfocusedSelection(range, property, value) {
658
+ return this.wrapUnfocusedSelection(range, () => {
659
+ const span = this.editor.ownerDocument.createElement("span");
660
+ span.style.setProperty(property, value);
661
+ return span;
662
+ });
663
+ }
664
+ applyInlineStyle(property, value) {
665
+ const editorFocused = this.editor.matches(":focus");
666
+ if (editorFocused)
667
+ this.restoreSelection();
668
+ const selection = this.selection();
669
+ const liveRange = selection?.rangeCount ? selection.getRangeAt(0) : undefined;
670
+ const range = liveRange && this.editor.contains(liveRange.commonAncestorContainer) ? liveRange : this.savedRange;
671
+ if (!range || range.collapsed || !this.editor.contains(range.commonAncestorContainer))
672
+ return;
673
+ if (!editorFocused) {
674
+ const cssValue = property === "font-size" ? FONT_SIZE_PX[value] ?? value : value;
675
+ if (this.applyStyleToUnfocusedSelection(range, property, cssValue))
676
+ this.commitEditorValue();
677
+ return;
678
+ }
679
+ const command = property === "color" ? "foreColor" : property === "background-color" ? "hiliteColor" : property === "font-family" ? "fontName" : "fontSize";
680
+ const document = this.editor.ownerDocument;
681
+ document.execCommand("styleWithCSS", false, "true");
682
+ const applied = document.execCommand(command, false, value);
683
+ if (command === "hiliteColor" && !applied)
684
+ document.execCommand("backColor", false, value);
685
+ this.commitEditorValue();
686
+ }
258
687
  editTable(command) {
259
688
  const cell = this.selectedCell();
260
689
  const table = cell?.closest("table");
@@ -316,11 +745,13 @@ export function defineRichTextEditor() {
316
745
  }
317
746
  else
318
747
  this.editor.focus();
319
- this.syncForm();
320
- this.syncTableActions();
321
- this.dispatchEvent(new CustomEvent("spui-input", { detail: { value: this.value }, bubbles: true, composed: true }));
748
+ this.commitEditorValue();
322
749
  }
323
750
  command(command, value) {
751
+ if ((this.contentMode === "plain" && !["undo", "redo"].includes(command)) || (this.contentMode === "restricted" && ["insertTable", "insertImage", "insertRowAbove", "insertRowBelow", "insertColumnBefore", "insertColumnAfter", "deleteRow", "deleteColumn", "deleteTable", "foreColor", "hiliteColor", "fontFamily", "fontSize"].includes(command)) || (this.contentMode !== "html" && ["subscript", "superscript", "justifyLeft", "justifyCenter", "justifyRight", "justifyFull"].includes(command)) || (command === "createLink" && !this.allowHyperlinks))
752
+ return;
753
+ if (command === "formatBlock" && !["P", "H1", "H2", "H3", "BLOCKQUOTE", "PRE"].includes(value?.toUpperCase() ?? ""))
754
+ return;
324
755
  if (command === "insertImage") {
325
756
  this.imageInput?.click();
326
757
  return;
@@ -329,20 +760,176 @@ export function defineRichTextEditor() {
329
760
  this.editTable(command);
330
761
  return;
331
762
  }
332
- this.restoreSelection();
333
763
  if (command === "createLink") {
334
- const href = window.prompt(this.getAttribute("link-prompt") ?? "Link URL");
335
- if (!href || !safeHref(href))
336
- return;
337
- value = href.trim();
764
+ this.openLinkEditor();
765
+ return;
338
766
  }
767
+ const editorFocused = this.editor.matches(":focus");
768
+ this.restoreSelection();
339
769
  if (command === "insertTable")
340
770
  value = '<table><caption><br></caption><thead><tr><th scope="col"><br></th><th scope="col"><br></th></tr></thead><tbody><tr><td><br></td><td><br></td></tr><tr><td><br></td><td><br></td></tr></tbody></table><p><br></p>';
341
- this.editor.ownerDocument.execCommand(command === "insertTable" ? "insertHTML" : command, false, value);
342
- this.sanitizeEditor();
343
- this.syncForm();
344
- this.onSelectionChange();
345
- this.dispatchEvent(new CustomEvent("spui-input", { detail: { value: this.value }, bubbles: true, composed: true }));
771
+ const document = this.editor.ownerDocument;
772
+ const applied = document.execCommand(command === "insertTable" ? "insertHTML" : command, false, command === "formatBlock" ? `<${value?.toLowerCase() ?? "p"}>` : value);
773
+ const tag = { bold: "strong", italic: "em", underline: "u", strikeThrough: "s" }[command];
774
+ if (!editorFocused && !applied && tag && this.savedRange && !this.savedRange.collapsed && this.wrapUnfocusedSelection(this.savedRange, () => document.createElement(tag))) {
775
+ this.commitEditorValue();
776
+ return;
777
+ }
778
+ if (command === "hiliteColor" && !applied)
779
+ this.editor.ownerDocument.execCommand("backColor", false, value);
780
+ this.commitEditorValue();
781
+ }
782
+ linkAtRange(range) {
783
+ const node = range?.startContainer;
784
+ const element = node instanceof Element ? node : node?.parentElement;
785
+ const link = element?.closest("a");
786
+ return link && this.editor.contains(link) ? link : undefined;
787
+ }
788
+ openLinkEditor() {
789
+ if (!this.linkForm || this.formDisabled || this.hasAttribute("disabled") || this.hasAttribute("read-only"))
790
+ return;
791
+ const selection = this.selection();
792
+ const activeRange = selection?.rangeCount ? selection.getRangeAt(0) : undefined;
793
+ if (activeRange && this.editor.contains(activeRange.commonAncestorContainer))
794
+ this.savedRange = activeRange.cloneRange();
795
+ if (!this.savedRange || !this.editor.contains(this.savedRange.commonAncestorContainer)) {
796
+ const range = this.editor.ownerDocument.createRange();
797
+ range.selectNodeContents(this.editor);
798
+ range.collapse(false);
799
+ this.savedRange = range;
800
+ }
801
+ const link = this.linkAtRange(this.savedRange);
802
+ this.linkUrlInput.value = link?.getAttribute("href") ?? "";
803
+ this.linkTextInput.value = link?.textContent ?? this.savedRange.toString();
804
+ this.linkTitleInput.value = link?.getAttribute("title") ?? "";
805
+ this.linkNewTabInput.checked = link?.getAttribute("target") === "_blank";
806
+ this.linkRemoveButton.hidden = !link;
807
+ this.linkError.hidden = true;
808
+ this.linkUrlInput.removeAttribute("aria-invalid");
809
+ this.linkForm.hidden = false;
810
+ this.linkEditorVersion++;
811
+ this.linkUrlInput.focus();
812
+ this.linkUrlInput.select();
813
+ }
814
+ closeLinkEditor(restoreEditor) {
815
+ if (!this.linkForm)
816
+ return;
817
+ this.linkForm.hidden = true;
818
+ this.linkEditorVersion++;
819
+ this.linkError.hidden = true;
820
+ this.linkUrlInput.removeAttribute("aria-invalid");
821
+ if (restoreEditor)
822
+ this.restoreSelection();
823
+ else
824
+ this.shadowRoot?.querySelector('button[data-command="createLink"]')?.focus();
825
+ }
826
+ async browseLink() {
827
+ const picker = this.linkPickerValue;
828
+ if (!picker || this.pickingLink || this.formDisabled || this.hasAttribute("disabled") || this.hasAttribute("read-only"))
829
+ return;
830
+ const version = this.linkEditorVersion;
831
+ const existing = this.linkAtRange(this.savedRange);
832
+ const currentLink = existing ? {
833
+ href: existing.getAttribute("href") ?? "",
834
+ text: this.linkTextInput?.value ?? existing.textContent ?? "",
835
+ title: this.linkTitleInput?.value ?? existing.getAttribute("title") ?? "",
836
+ openInNewTab: this.linkNewTabInput?.checked ?? existing.getAttribute("target") === "_blank",
837
+ } : null;
838
+ this.pickingLink = true;
839
+ this.linkError.hidden = true;
840
+ this.syncLinkPickerButton();
841
+ try {
842
+ const result = await picker(currentLink);
843
+ if (!result || !this.isConnected || version !== this.linkEditorVersion || this.linkForm?.hidden)
844
+ return;
845
+ if (typeof result.href !== "string" || !safeHref(result.href))
846
+ throw new Error(this.getAttribute("link-invalid-message") ?? "Enter a valid HTTP, HTTPS, or mailto link.");
847
+ this.linkUrlInput.value = result.href;
848
+ if (typeof result.text === "string")
849
+ this.linkTextInput.value = result.text;
850
+ if (typeof result.title === "string")
851
+ this.linkTitleInput.value = result.title.slice(0, 512);
852
+ if (typeof result.openInNewTab === "boolean")
853
+ this.linkNewTabInput.checked = result.openInNewTab;
854
+ this.linkTextInput.focus();
855
+ }
856
+ catch (error) {
857
+ if (!this.isConnected || version !== this.linkEditorVersion || this.linkForm?.hidden)
858
+ return;
859
+ this.linkError.textContent = this.getAttribute("link-picker-error-message") ?? "Unable to open the SharePoint link picker.";
860
+ this.linkError.hidden = false;
861
+ this.dispatchEvent(new CustomEvent("spui-link-picker-error", { detail: { error }, bubbles: true, composed: true }));
862
+ }
863
+ finally {
864
+ this.pickingLink = false;
865
+ this.syncLinkPickerButton();
866
+ }
867
+ }
868
+ applyLink() {
869
+ const href = this.linkUrlInput.value.trim();
870
+ if (!safeHref(href)) {
871
+ this.linkError.textContent = this.getAttribute("link-invalid-message") ?? "Enter a valid HTTP, HTTPS, or mailto link.";
872
+ this.linkError.hidden = false;
873
+ this.linkUrlInput.setAttribute("aria-invalid", "true");
874
+ this.linkUrlInput.focus();
875
+ return;
876
+ }
877
+ const normalizedHref = new URL(href, this.editor.ownerDocument.baseURI).href;
878
+ const displayText = this.linkTextInput.value.trim() || href;
879
+ const title = this.linkTitleInput.value.trim();
880
+ const range = this.savedRange?.cloneRange();
881
+ const existing = this.linkAtRange(range);
882
+ let link = existing;
883
+ if (existing) {
884
+ existing.setAttribute("href", normalizedHref);
885
+ }
886
+ else if (range?.collapsed) {
887
+ link = this.editor.ownerDocument.createElement("a");
888
+ link.href = normalizedHref;
889
+ range.insertNode(link);
890
+ }
891
+ else {
892
+ this.restoreSelection();
893
+ const created = this.editor.ownerDocument.execCommand("createLink", false, normalizedHref);
894
+ const selection = this.selection();
895
+ link = this.linkAtRange(range) ?? this.linkAtRange(selection?.rangeCount ? selection.getRangeAt(0) : undefined);
896
+ if (!created && !link && range && !range.collapsed) {
897
+ link = this.editor.ownerDocument.createElement("a");
898
+ link.href = normalizedHref;
899
+ link.append(range.extractContents());
900
+ range.insertNode(link);
901
+ }
902
+ }
903
+ if (link) {
904
+ if (link.textContent !== displayText)
905
+ link.textContent = displayText;
906
+ if (title)
907
+ link.setAttribute("title", title);
908
+ else
909
+ link.removeAttribute("title");
910
+ if (this.linkNewTabInput.checked) {
911
+ link.setAttribute("target", "_blank");
912
+ link.setAttribute("rel", "noopener noreferrer");
913
+ }
914
+ else {
915
+ link.removeAttribute("target");
916
+ link.removeAttribute("rel");
917
+ }
918
+ const caret = this.editor.ownerDocument.createRange();
919
+ caret.selectNodeContents(link);
920
+ caret.collapse(false);
921
+ this.savedRange = caret;
922
+ }
923
+ this.closeLinkEditor(true);
924
+ this.commitEditorValue();
925
+ }
926
+ removeLink() {
927
+ const link = this.linkAtRange(this.savedRange);
928
+ if (!link)
929
+ return;
930
+ link.replaceWith(...link.childNodes);
931
+ this.closeLinkEditor(true);
932
+ this.commitEditorValue();
346
933
  }
347
934
  async insertUploadedImage(file) {
348
935
  if (!this.imageUploader || !file.type.startsWith("image/") || file.size > 10 * 1024 * 1024 || this.uploadingImage)
@@ -350,13 +937,16 @@ export function defineRichTextEditor() {
350
937
  this.uploadingImage = true;
351
938
  this.syncImageButton();
352
939
  try {
353
- const src = await this.imageUploader(file);
940
+ const uploaded = await this.imageUploader(file);
941
+ const src = typeof uploaded === "string" ? uploaded : uploaded.src;
942
+ if (typeof uploaded !== "string" && typeof uploaded.alt !== "string")
943
+ throw new Error("Image upload results must include alt text; use an empty string only for decorative images.");
354
944
  if (!safeImageSrc(src))
355
945
  throw new Error("Image upload must return an HTTP or HTTPS URL.");
356
946
  this.restoreSelection();
357
947
  const image = document.createElement("img");
358
948
  image.src = new URL(src, document.baseURI).href;
359
- image.alt = file.name.replace(/\\.[^.]*$/, "").slice(0, 200);
949
+ image.alt = (typeof uploaded === "string" ? file.name.replace(/\.[^.]*$/, "") : uploaded.alt).slice(0, 200);
360
950
  const selection = this.selection();
361
951
  const range = selection?.rangeCount ? selection.getRangeAt(0) : undefined;
362
952
  if (range && this.editor.contains(range.commonAncestorContainer)) {
@@ -370,8 +960,7 @@ export function defineRichTextEditor() {
370
960
  else
371
961
  this.editor.append(image);
372
962
  this.savedRange = range?.cloneRange();
373
- this.syncForm();
374
- this.dispatchEvent(new CustomEvent("spui-input", { detail: { value: this.value }, bubbles: true, composed: true }));
963
+ this.commitEditorValue();
375
964
  }
376
965
  catch (error) {
377
966
  this.dispatchEvent(new CustomEvent("spui-image-upload-error", { detail: { fileName: file.name, error }, bubbles: true, composed: true }));
@@ -391,24 +980,144 @@ export function defineRichTextEditor() {
391
980
  toolbar.setAttribute("role", "toolbar");
392
981
  toolbar.setAttribute("aria-orientation", "horizontal");
393
982
  toolbar.part.add("toolbar");
394
- const actions = [["Bold", "bold"], ["Italic", "italic"], ["Underline", "underline"], ["Bulleted list", "insertUnorderedList", undefined, "• List"], ["Numbered list", "insertOrderedList", undefined, "1. List"], ["Undo", "undo"], ["Redo", "redo"], ["Insert table", "insertTable"], ["Insert image", "insertImage", undefined, undefined, "insert-image-label"], ["Insert row above", "insertRowAbove", undefined, undefined, "insert-row-above-label"], ["Insert row below", "insertRowBelow", undefined, undefined, "insert-row-below-label"], ["Insert column before", "insertColumnBefore", undefined, undefined, "insert-column-before-label"], ["Insert column after", "insertColumnAfter", undefined, undefined, "insert-column-after-label"], ["Delete row", "deleteRow", undefined, undefined, "delete-row-label"], ["Delete column", "deleteColumn", undefined, undefined, "delete-column-label"], ["Delete table", "deleteTable", undefined, undefined, "delete-table-label"], ["Link", "createLink"]];
395
- for (const [title, command, value, short, labelAttribute] of actions) {
983
+ toolbar.addEventListener("pointerdown", () => {
984
+ const selection = this.selection();
985
+ const range = selection?.rangeCount ? selection.getRangeAt(0) : undefined;
986
+ if (range && !range.collapsed && this.editor.contains(range.commonAncestorContainer)) {
987
+ this.savedRange = range.cloneRange();
988
+ this.preserveToolbarSelection = true;
989
+ }
990
+ }, true);
991
+ const format = document.createElement("select");
992
+ format.className = "toolbar-format";
993
+ format.dataset.command = "formatBlock";
994
+ format.dataset.labelAttribute = "format-block-label";
995
+ format.setAttribute("aria-label", this.getAttribute("format-block-label") ?? "Text style");
996
+ for (const [value, attribute, fallback] of [["P", "paragraph-label", "Paragraph"], ["H1", "heading-1-label", "Heading 1"], ["H2", "heading-2-label", "Heading 2"], ["H3", "heading-3-label", "Heading 3"], ["BLOCKQUOTE", "blockquote-label", "Quote"], ["PRE", "preformatted-label", "Preformatted"]]) {
997
+ const option = document.createElement("option");
998
+ option.value = value;
999
+ option.dataset.labelAttribute = attribute;
1000
+ option.textContent = this.getAttribute(attribute) ?? fallback;
1001
+ format.append(option);
1002
+ }
1003
+ format.addEventListener("change", () => this.command("formatBlock", format.value));
1004
+ toolbar.append(format);
1005
+ const fontGroup = document.createElement("div");
1006
+ fontGroup.className = "toolbar-group toolbar-group--fonts";
1007
+ const addFontSelect = (command, labelAttribute, fallback, options) => {
1008
+ const select = document.createElement("select");
1009
+ select.className = "toolbar-format toolbar-font-select";
1010
+ select.dataset.command = command;
1011
+ select.dataset.labelAttribute = labelAttribute;
1012
+ select.setAttribute("aria-label", this.getAttribute(labelAttribute) ?? fallback);
1013
+ for (const [value, text] of options) {
1014
+ const option = document.createElement("option");
1015
+ option.value = value;
1016
+ option.textContent = text;
1017
+ if (!value) {
1018
+ option.disabled = true;
1019
+ option.selected = true;
1020
+ }
1021
+ select.append(option);
1022
+ }
1023
+ select.addEventListener("change", () => this.applyInlineStyle(command === "fontFamily" ? "font-family" : "font-size", select.value));
1024
+ fontGroup.append(select);
1025
+ };
1026
+ addFontSelect("fontFamily", "font-family-label", "Font family", [["", "Font family"], ["Arial", "Arial"], ["Calibri", "Calibri"], ["Georgia", "Georgia"], ["Segoe UI", "Segoe UI"], ["Tahoma", "Tahoma"], ["Times New Roman", "Times New Roman"], ["Verdana", "Verdana"]]);
1027
+ addFontSelect("fontSize", "font-size-label", "Font size", [["", "Font size"], ["1", "10 px"], ["2", "12 px"], ["3", "16 px"], ["4", "18 px"], ["5", "24 px"], ["6", "32 px"], ["7", "48 px"]]);
1028
+ toolbar.append(fontGroup);
1029
+ const actions = [["Bold", "bold"], ["Italic", "italic"], ["Underline", "underline"], ["Strikethrough", "strikeThrough", undefined, undefined, "strike-label"], ["Subscript", "subscript", undefined, undefined, "subscript-label"], ["Superscript", "superscript", undefined, undefined, "superscript-label"], ["Align left", "justifyLeft", undefined, undefined, "align-left-label"], ["Align center", "justifyCenter", undefined, undefined, "align-center-label"], ["Align right", "justifyRight", undefined, undefined, "align-right-label"], ["Justify text", "justifyFull", undefined, undefined, "align-justify-label"], ["Bulleted list", "insertUnorderedList", undefined, "• List"], ["Numbered list", "insertOrderedList", undefined, "1. List"], ["Clear formatting", "removeFormat", undefined, undefined, "clear-formatting-label"], ["Undo", "undo"], ["Redo", "redo"], ["Insert table", "insertTable"], ["Insert image", "insertImage", undefined, undefined, "insert-image-label"], ["Insert row above", "insertRowAbove", undefined, undefined, "insert-row-above-label"], ["Insert row below", "insertRowBelow", undefined, undefined, "insert-row-below-label"], ["Insert column before", "insertColumnBefore", undefined, undefined, "insert-column-before-label"], ["Insert column after", "insertColumnAfter", undefined, undefined, "insert-column-after-label"], ["Delete row", "deleteRow", undefined, undefined, "delete-row-label"], ["Delete column", "deleteColumn", undefined, undefined, "delete-column-label"], ["Delete table", "deleteTable", undefined, undefined, "delete-table-label"], ["Link", "createLink"]];
1030
+ const toolbarGroups = new Map();
1031
+ for (const [title, command, value, , labelAttribute] of actions) {
1032
+ const isTableAction = command.startsWith("insertRow") || command.startsWith("insertColumn") || command.startsWith("delete");
1033
+ const groupName = isTableAction ? "table" : ["bold", "italic", "underline", "strikeThrough", "subscript", "superscript", "removeFormat"].includes(command) ? "formatting" : command.startsWith("justify") ? "paragraph" : ["insertUnorderedList", "insertOrderedList"].includes(command) ? "lists" : ["undo", "redo"].includes(command) ? "history" : "insert";
1034
+ let group = toolbarGroups.get(groupName);
1035
+ if (!group) {
1036
+ group = document.createElement("div");
1037
+ group.className = `toolbar-group toolbar-group--${groupName}`;
1038
+ if (groupName === "table")
1039
+ group.hidden = true;
1040
+ toolbarGroups.set(groupName, group);
1041
+ toolbar.append(group);
1042
+ }
396
1043
  const button = document.createElement("button");
397
1044
  button.type = "button";
398
1045
  button.dataset.command = command;
399
- button.dataset.fallback = short ?? title;
400
- if (labelAttribute) {
1046
+ button.dataset.fallback = title;
1047
+ if (labelAttribute)
401
1048
  button.dataset.labelAttribute = labelAttribute;
402
- if (command.startsWith("insertRow") || command.startsWith("insertColumn") || command.startsWith("delete"))
403
- button.dataset.tableCommand = command;
404
- }
405
- button.tabIndex = toolbar.children.length === 0 ? 0 : -1;
406
- button.textContent = title;
1049
+ if (isTableAction)
1050
+ button.dataset.tableCommand = command;
1051
+ button.tabIndex = toolbar.querySelector("button[data-command],select[data-command]") ? -1 : 0;
1052
+ button.append(createToolbarIcon(command));
407
1053
  button.setAttribute("aria-label", title);
1054
+ button.title = title;
408
1055
  button.addEventListener("mousedown", event => event.preventDefault());
409
1056
  button.addEventListener("click", () => this.command(command, value));
410
- toolbar.append(button);
1057
+ group.append(button);
411
1058
  }
1059
+ const colorGroup = document.createElement("div");
1060
+ colorGroup.className = "toolbar-group toolbar-group--color";
1061
+ for (const [attribute, fallback, command, color, colors] of [
1062
+ ["text-color-label", "Text color", "foreColor", "#323130", ["#000000", "#323130", "#605e5c", "#a4262c", "#d83b01", "#ca5010", "#8e562e", "#498205", "#038387", "#0078d4", "#5c2d91", "#b4009e"]],
1063
+ ["highlight-color-label", "Highlight color", "hiliteColor", "#fff2cc", ["#fff2cc", "#fff4ce", "#fed9cc", "#fce100", "#e2f2d5", "#c8e6e0", "#cfe4fa", "#e7d8f5", "#f4d5ef", "#f3f2f1", "#d2d0ce", "#ffffff"]]
1064
+ ]) {
1065
+ const picker = document.createElement("details");
1066
+ picker.className = "toolbar-color";
1067
+ picker.dataset.labelAttribute = attribute;
1068
+ picker.dataset.command = command;
1069
+ picker.part.add(command === "foreColor" ? "text-color" : "highlight-color");
1070
+ const summary = document.createElement("summary");
1071
+ summary.setAttribute("aria-label", this.getAttribute(attribute) ?? fallback);
1072
+ summary.title = this.getAttribute(attribute) ?? fallback;
1073
+ const glyph = document.createElement("span");
1074
+ glyph.className = "toolbar-color__glyph";
1075
+ glyph.setAttribute("aria-hidden", "true");
1076
+ glyph.textContent = command === "foreColor" ? "A" : "▰";
1077
+ const swatch = document.createElement("span");
1078
+ swatch.className = "toolbar-color__current";
1079
+ swatch.style.setProperty("--spui-picked-color", color);
1080
+ swatch.setAttribute("aria-hidden", "true");
1081
+ const name = document.createElement("span");
1082
+ name.className = "toolbar-color__name";
1083
+ name.textContent = this.getAttribute(attribute) ?? fallback;
1084
+ summary.append(glyph, swatch, name);
1085
+ summary.addEventListener("mousedown", this.onSelectionChange);
1086
+ picker.append(summary);
1087
+ const palette = document.createElement("div");
1088
+ palette.className = "toolbar-color__palette";
1089
+ palette.setAttribute("role", "group");
1090
+ palette.setAttribute("aria-label", this.getAttribute(attribute) ?? fallback);
1091
+ for (const value of ["theme-accent", ...colors]) {
1092
+ const isThemeAccent = value === "theme-accent";
1093
+ const option = document.createElement("button");
1094
+ option.type = "button";
1095
+ option.className = "toolbar-color__swatch";
1096
+ option.style.setProperty("--spui-picked-color", isThemeAccent ? "var(--spui-ui-accent,#0078d4)" : value);
1097
+ option.dataset.color = value;
1098
+ option.addEventListener("mousedown", this.onSelectionChange);
1099
+ option.addEventListener("click", () => { const selected = isThemeAccent ? getComputedStyle(this).getPropertyValue("--spui-ui-accent").trim() || "#0078d4" : value; swatch.style.setProperty("--spui-picked-color", selected); this.command(command, selected); picker.open = false; });
1100
+ palette.append(option);
1101
+ }
1102
+ const custom = document.createElement("label");
1103
+ custom.className = "toolbar-color__custom";
1104
+ custom.dataset.labelAttribute = "custom-color-label";
1105
+ custom.append(document.createTextNode(this.getAttribute("custom-color-label") ?? "Custom color"));
1106
+ const input = document.createElement("input");
1107
+ input.type = "color";
1108
+ input.value = color;
1109
+ input.dataset.command = command;
1110
+ input.dataset.labelAttribute = attribute;
1111
+ input.setAttribute("aria-label", `${this.getAttribute(attribute) ?? fallback}: ${this.getAttribute("custom-color-label") ?? "custom color"}`);
1112
+ input.addEventListener("mousedown", this.onSelectionChange);
1113
+ input.addEventListener("input", () => swatch.style.setProperty("--spui-picked-color", input.value));
1114
+ input.addEventListener("change", () => { swatch.style.setProperty("--spui-picked-color", input.value); this.command(command, input.value); picker.open = false; });
1115
+ custom.append(input);
1116
+ palette.append(custom);
1117
+ picker.append(palette);
1118
+ colorGroup.append(picker);
1119
+ }
1120
+ toolbar.append(colorGroup);
412
1121
  this.imageInput = document.createElement("input");
413
1122
  this.imageInput.type = "file";
414
1123
  this.imageInput.accept = "image/*";
@@ -417,21 +1126,97 @@ export function defineRichTextEditor() {
417
1126
  this.imageInput.addEventListener("change", () => { const file = this.imageInput?.files?.[0]; if (file)
418
1127
  void this.insertUploadedImage(file); this.imageInput.value = ""; });
419
1128
  toolbar.append(this.imageInput);
420
- toolbar.addEventListener("focusin", event => { const active = event.target; toolbar.querySelectorAll("button").forEach(button => { button.tabIndex = button === active ? 0 : -1; }); });
1129
+ const linkForm = document.createElement("form");
1130
+ this.linkForm = linkForm;
1131
+ linkForm.className = "link-editor";
1132
+ linkForm.part.add("link-editor");
1133
+ linkForm.hidden = true;
1134
+ linkForm.setAttribute("aria-label", this.getAttribute("link-dialog-label") ?? "Edit link");
1135
+ const makeLinkField = (attribute, fallback, type = "text") => {
1136
+ const field = document.createElement("label");
1137
+ field.className = "link-field";
1138
+ field.dataset.labelAttribute = attribute;
1139
+ field.append(document.createTextNode(this.getAttribute(attribute) ?? fallback));
1140
+ const input = document.createElement("input");
1141
+ input.type = type;
1142
+ input.part.add(attribute === "link-prompt" ? "link-url" : attribute === "link-text-label" ? "link-text" : "link-title");
1143
+ field.append(input);
1144
+ linkForm.append(field);
1145
+ return input;
1146
+ };
1147
+ this.linkUrlInput = makeLinkField("link-prompt", "Link URL");
1148
+ this.linkUrlInput.inputMode = "url";
1149
+ this.linkUrlInput.setAttribute("autocomplete", "url");
1150
+ this.linkUrlInput.placeholder = "https://";
1151
+ this.linkTextInput = makeLinkField("link-text-label", "Display text");
1152
+ this.linkTitleInput = makeLinkField("link-title-label", "Title");
1153
+ this.linkTitleInput.maxLength = 512;
1154
+ const newTabLabel = document.createElement("label");
1155
+ newTabLabel.className = "link-new-tab";
1156
+ newTabLabel.part.add("link-new-tab");
1157
+ newTabLabel.dataset.labelAttribute = "link-new-tab-label";
1158
+ this.linkNewTabInput = document.createElement("input");
1159
+ this.linkNewTabInput.type = "checkbox";
1160
+ this.linkNewTabInput.part.add("link-new-tab-checkbox");
1161
+ const newTabText = document.createElement("span");
1162
+ newTabText.textContent = this.getAttribute("link-new-tab-label") ?? "Open in new tab";
1163
+ newTabLabel.append(this.linkNewTabInput, newTabText);
1164
+ linkForm.append(newTabLabel);
1165
+ this.linkError = document.createElement("div");
1166
+ this.linkError.className = "link-error";
1167
+ this.linkError.part.add("link-error");
1168
+ this.linkError.id = `rich-text-link-error-${crypto.randomUUID()}`;
1169
+ this.linkError.setAttribute("role", "alert");
1170
+ this.linkError.hidden = true;
1171
+ linkForm.append(this.linkError);
1172
+ this.linkPickerStatus = document.createElement("div");
1173
+ this.linkPickerStatus.className = "link-picker-status";
1174
+ this.linkPickerStatus.setAttribute("role", "status");
1175
+ this.linkPickerStatus.setAttribute("aria-live", "polite");
1176
+ linkForm.append(this.linkPickerStatus);
1177
+ this.linkUrlInput.setAttribute("aria-describedby", this.linkError.id);
1178
+ const linkActions = document.createElement("div");
1179
+ linkActions.className = "link-actions";
1180
+ const makeLinkAction = (action, attribute, fallback, onClick) => {
1181
+ const button = document.createElement("button");
1182
+ button.type = action === "apply" ? "submit" : "button";
1183
+ button.dataset.linkAction = action;
1184
+ button.dataset.labelAttribute = attribute;
1185
+ button.part.add(`link-${action}`);
1186
+ button.textContent = this.getAttribute(attribute) ?? fallback;
1187
+ button.addEventListener("click", onClick);
1188
+ linkActions.append(button);
1189
+ return button;
1190
+ };
1191
+ makeLinkAction("apply", "link-apply-label", "Apply link", () => { });
1192
+ this.linkRemoveButton = makeLinkAction("remove", "link-remove-label", "Remove link", () => this.removeLink());
1193
+ this.linkPickerButton = makeLinkAction("browse", "link-picker-label", "Browse SharePoint", () => { void this.browseLink(); });
1194
+ makeLinkAction("cancel", "link-cancel-label", "Cancel", () => this.closeLinkEditor(false));
1195
+ linkForm.append(linkActions);
1196
+ linkForm.addEventListener("submit", event => { event.preventDefault(); this.applyLink(); });
1197
+ linkForm.addEventListener("keydown", event => { if (event.key === "Escape") {
1198
+ event.preventDefault();
1199
+ event.stopPropagation();
1200
+ this.closeLinkEditor(false);
1201
+ } });
1202
+ toolbar.querySelectorAll("button[data-command],select[data-command],input[type=color][data-command]").forEach((control, index) => { control.tabIndex = index === 0 ? 0 : -1; });
1203
+ toolbar.addEventListener("focusin", event => { const active = event.target; toolbar.querySelectorAll("button[data-command],select[data-command],input[type=color][data-command]").forEach(control => { control.tabIndex = control === active ? 0 : -1; }); });
421
1204
  toolbar.addEventListener("keydown", event => {
422
1205
  const key = event.key;
423
- const buttons = [...toolbar.querySelectorAll("button:not(:disabled)")];
424
- const current = buttons.indexOf(event.target);
1206
+ if ((event.target instanceof HTMLSelectElement || event.target instanceof HTMLInputElement) && ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(key))
1207
+ return;
1208
+ const controls = [...toolbar.querySelectorAll("button[data-command]:not(:disabled):not([hidden]),select[data-command]:not(:disabled):not([hidden]),input[type=color][data-command]:not(:disabled):not([hidden])")];
1209
+ const current = controls.indexOf(event.target);
425
1210
  const rtl = getComputedStyle(this).direction === "rtl";
426
1211
  const step = key === "ArrowRight" ? (rtl ? -1 : 1) : key === "ArrowLeft" ? (rtl ? 1 : -1) : 0;
427
- const index = key === "Home" ? 0 : key === "End" ? buttons.length - 1 : step ? (current + step + buttons.length) % buttons.length : -1;
428
- if (index < 0 || !buttons[index])
1212
+ const index = key === "Home" ? 0 : key === "End" ? controls.length - 1 : step ? (current + step + controls.length) % controls.length : -1;
1213
+ if (index < 0 || !controls[index])
429
1214
  return;
430
1215
  event.preventDefault();
431
- buttons[index].tabIndex = 0;
432
- buttons.forEach((button, buttonIndex) => { if (buttonIndex !== index)
433
- button.tabIndex = -1; });
434
- buttons[index].focus();
1216
+ controls[index].tabIndex = 0;
1217
+ controls.forEach((control, controlIndex) => { if (controlIndex !== index)
1218
+ control.tabIndex = -1; });
1219
+ controls[index].focus();
435
1220
  });
436
1221
  this.editor = document.createElement("div");
437
1222
  this.editor.setAttribute("role", "textbox");
@@ -440,7 +1225,7 @@ export function defineRichTextEditor() {
440
1225
  const invalid = this.getAttribute("aria-invalid");
441
1226
  if (invalid !== null)
442
1227
  this.editor.setAttribute("aria-invalid", invalid);
443
- this.editor.contentEditable = String(!this.hasAttribute("disabled") && !this.hasAttribute("read-only"));
1228
+ this.editor.contentEditable = this.hasAttribute("disabled") || this.hasAttribute("read-only") ? "false" : this.contentMode === "plain" ? "plaintext-only" : "true";
444
1229
  this.editor.setAttribute("aria-readonly", String(this.hasAttribute("read-only")));
445
1230
  this.editor.setAttribute("aria-disabled", String(this.hasAttribute("disabled")));
446
1231
  this.editor.setAttribute("aria-required", String(this.hasAttribute("required")));
@@ -455,36 +1240,61 @@ export function defineRichTextEditor() {
455
1240
  event.preventDefault();
456
1241
  this.command("redo");
457
1242
  } });
458
- this.editor.addEventListener("input", () => { this.sanitizeEditor(); this.syncForm(); this.dispatchEvent(new CustomEvent("spui-input", { detail: { value: this.value }, bubbles: true, composed: true })); });
1243
+ this.editor.addEventListener("beforeinput", event => {
1244
+ if (!this.probingSelection)
1245
+ return;
1246
+ const targetRange = event.getTargetRanges?.()[0];
1247
+ if (targetRange && this.editor.contains(targetRange.startContainer) && this.editor.contains(targetRange.endContainer)) {
1248
+ const range = this.editor.ownerDocument.createRange();
1249
+ range.setStart(targetRange.startContainer, targetRange.startOffset);
1250
+ range.setEnd(targetRange.endContainer, targetRange.endOffset);
1251
+ this.probedSelection = range;
1252
+ }
1253
+ event.preventDefault();
1254
+ event.stopImmediatePropagation();
1255
+ }, true);
1256
+ this.editor.addEventListener("input", () => this.commitEditorValue());
1257
+ this.editor.addEventListener("pointerdown", event => {
1258
+ this.preserveToolbarSelection = false;
1259
+ });
459
1260
  this.editor.addEventListener("mouseup", this.onSelectionChange);
460
1261
  this.editor.addEventListener("keyup", this.onSelectionChange);
461
1262
  this.editor.addEventListener("click", this.onEditorClick);
462
- shadow.addEventListener("selectionchange", this.onSelectionChange, { signal: this.events?.signal });
463
1263
  this.editor.addEventListener("paste", event => {
464
1264
  event.preventDefault();
465
1265
  const clipboard = event.clipboardData;
1266
+ if (this.contentMode === "plain" || this.contentMode === "restricted") {
1267
+ this.editor.ownerDocument.execCommand("insertText", false, clipboard?.getData("text/plain") ?? plainText(clipboard?.getData("text/html") ?? ""));
1268
+ this.commitEditorValue();
1269
+ return;
1270
+ }
466
1271
  const imageFile = [...(clipboard?.files ?? [])].find(file => file.type.startsWith("image/"));
467
1272
  if (imageFile) {
468
1273
  void this.insertUploadedImage(imageFile);
469
1274
  return;
470
1275
  }
471
- const html = cleanHtml(clipboard?.getData("text/html") ?? "");
1276
+ const html = cleanHtml(clipboard?.getData("text/html") ?? clipboard?.getData("text/plain") ?? "", this.contentMode, this.allowHyperlinks);
472
1277
  const text = clipboard?.getData("text/plain") ?? "";
473
1278
  if (html)
474
1279
  this.editor.ownerDocument.execCommand("insertHTML", false, html);
475
1280
  else
476
1281
  this.editor.ownerDocument.execCommand("insertText", false, text);
477
- this.sanitizeEditor();
478
- this.syncForm();
479
- this.dispatchEvent(new CustomEvent("spui-input", { detail: { value: this.value }, bubbles: true, composed: true }));
1282
+ this.commitEditorValue();
480
1283
  });
481
- this.editor.addEventListener("blur", () => { this.sanitizeEditor(); this.syncForm(); this.dispatchEvent(new CustomEvent("spui-change", { detail: { value: this.value }, bubbles: true, composed: true })); });
1284
+ this.editor.addEventListener("blur", () => this.commitEditorValue("spui-change"));
482
1285
  const style = document.createElement("style");
483
- style.textContent = `${base}:host{display:grid;gap:6px}label{font-weight:600;color:var(--spui-ui-muted,var(--spui-semantic-body-text,inherit))}[role=toolbar]{display:flex;flex-wrap:wrap;gap:4px;padding:4px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,#c8c8c8));border-radius:var(--spui-control-radius,6px);background:var(--spui-ui-surface,#fff)}button{border:0;border-radius:4px;background:transparent;color:var(--spui-ui-text,inherit);padding:6px 8px;font:inherit;cursor:pointer}button:hover:not(:disabled){background:var(--spui-ui-surface-hover,#eee)}button:disabled{opacity:.55;cursor:not-allowed}button:focus-visible,[role=textbox]:focus-visible{outline:2px solid var(--spui-focus-ring,#005a9e);outline-offset:2px}[role=textbox]{min-height:120px;box-sizing:border-box;padding:10px 12px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,#c8c8c8));border-radius:var(--spui-control-radius,6px);background:var(--spui-control-background,var(--spui-ui-surface,#fff));color:var(--spui-ui-text,inherit);font:inherit;line-height:1.5;overflow:auto;caret-color:var(--spui-focus-ring,#005a9e)}[role=textbox]:empty:before{content:attr(data-placeholder);color:var(--spui-ui-muted,#666);pointer-events:none}table{border-collapse:collapse;max-width:100%;margin-block:8px}caption{text-align:start;font-weight:600}td,th{min-width:56px;padding:6px 9px;border:1px solid var(--spui-ui-stroke,#c8c8c8);text-align:start}th{background:var(--spui-ui-surface-hover,#f5f5f5);font-weight:600}a{color:var(--spui-semantic-link,#0f6cbd)}@media(forced-colors:active){button{border:1px solid ButtonText}td,th{border-color:CanvasText}}`;
484
- shadow.replaceChildren(style, label, toolbar, this.editor);
1286
+ style.textContent = `${base}:host{display:grid;min-width:0;max-width:100%;container-type:inline-size;gap:6px}label{font-weight:600;color:var(--spui-ui-muted,var(--spui-semantic-body-text,inherit))}[role=toolbar]{display:flex;flex-wrap:wrap;align-items:center;gap:4px;padding:6px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,#c8c8c8));border-radius:var(--spui-control-radius,6px);background:var(--spui-ui-surface,#fff)}[role=toolbar][hidden],button[hidden]{display:none}.toolbar-group{display:flex;flex:0 1 auto;flex-wrap:wrap;align-items:center;gap:2px;min-width:0;padding-inline-start:6px;border-inline-start:1px solid var(--spui-ui-stroke,#c8c8c8)}.toolbar-group[hidden]{display:none}button{box-sizing:border-box;display:inline-grid;place-items:center;flex:none;width:36px;min-width:36px;min-height:36px;padding:0;border:0;border-radius:var(--spui-control-radius,4px);background:transparent;color:var(--spui-ui-text,inherit);font:inherit;cursor:pointer}.toolbar-format{box-sizing:border-box;max-width:172px;min-width:116px;min-height:36px;padding:0 8px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,#c8c8c8));border-radius:var(--spui-control-radius,4px);background:var(--spui-control-background,var(--spui-ui-surface,#fff));color:var(--spui-ui-text,inherit);font:inherit}.toolbar-format:hover{background:var(--spui-ui-surface-hover,#eee)}button svg{display:block;width:18px;height:18px}button:hover:not(:disabled){background:var(--spui-ui-surface-hover,#eee)}button[aria-pressed=true]{background:color-mix(in srgb,var(--spui-ui-accent,#0078d4) 14%,var(--spui-ui-surface,#fff));color:var(--spui-ui-text,var(--spui-semantic-body-text,#323130))}button:disabled{opacity:.42;cursor:not-allowed}button:focus-visible,select:focus-visible,[role=textbox]:focus-visible,input:focus-visible{outline:2px solid var(--spui-focus-ring,var(--spui-ui-accent,#005a9e));outline-offset:2px}[role=textbox]{min-width:0;min-height:150px;box-sizing:border-box;padding:10px 12px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,#c8c8c8));border-radius:var(--spui-control-radius,6px);background:var(--spui-control-background,var(--spui-ui-surface,#fff));color:var(--spui-ui-text,inherit);font:inherit;line-height:1.5;overflow:auto;caret-color:var(--spui-focus-ring,var(--spui-ui-accent,#005a9e))}[role=textbox]:empty:before{content:attr(data-placeholder);color:var(--spui-ui-muted,#666);pointer-events:none}table{border-collapse:collapse;max-width:100%;margin-block:8px}caption{text-align:start;font-weight:600}td,th{min-width:56px;padding:6px 9px;border:1px solid var(--spui-ui-stroke,#c8c8c8);text-align:start}th{background:var(--spui-ui-surface-hover,#f5f5f5);font-weight:600}a{color:var(--spui-semantic-link,#0f6cbd)}@media(pointer:coarse){[role=toolbar] button{width:44px;min-width:44px;min-height:44px}[role=toolbar] select{min-height:44px}}@container (max-width: 64rem){[role=toolbar]{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:8px}.toolbar-format{flex:1 0 100%;width:100%;max-width:none;min-width:0;min-height:40px}.toolbar-group{display:flex;flex:0 1 auto;flex-wrap:wrap;align-items:center;gap:4px;min-width:0;padding:0;border:0}.toolbar-group--fonts{display:grid;flex:1 0 100%;grid-template-columns:repeat(2,minmax(0,1fr))}.toolbar-font-select{width:100%;max-width:none;min-width:0;min-height:44px}.toolbar-group button{width:44px;min-width:44px;min-height:44px}.toolbar-group--table{flex:1 0 100%}[role=textbox]{min-height:12rem;overflow-wrap:anywhere}table{display:block;overflow-x:auto}}@media(forced-colors:active){select{border:1px solid ButtonText;background:Field;color:FieldText}button{border:1px solid ButtonText}button[aria-pressed=true]{color:Highlight;border-color:Highlight}td,th{border-color:CanvasText}}`;
1287
+ style.textContent += `.link-editor{display:flex;flex-wrap:wrap;align-items:end;gap:8px;padding:8px;border:1px solid var(--spui-ui-stroke,#c8c8c8);border-radius:var(--spui-control-radius,6px);background:var(--spui-ui-surface-hover,var(--spui-ui-surface,#fff))}.link-editor[hidden]{display:none}.link-field{display:grid;flex:1 1 150px;gap:4px;min-width:0;color:var(--spui-ui-text,inherit);font-size:13px}.link-field input{box-sizing:border-box;width:100%;min-width:0;min-height:32px;padding:5px 8px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,#8a8886));border-radius:var(--spui-control-radius,4px);background:var(--spui-control-background,var(--spui-ui-surface,#fff));color:var(--spui-ui-text,inherit)}.link-actions{display:flex;flex-wrap:wrap;gap:4px}.link-actions button{border:1px solid var(--spui-ui-stroke,#c8c8c8);border-radius:var(--spui-control-radius,4px);background:var(--spui-ui-surface,#fff)}.link-actions [data-link-action=apply]{border-color:transparent;background:var(--spui-semantic-primary-button-background,var(--spui-ui-accent,#0078d4));color:var(--spui-ui-accent-text,#fff)}.link-error{flex:1 0 100%;color:var(--spui-ui-danger,var(--spui-semantic-error-text,#c50f1f));font-size:13px}`;
1288
+ style.textContent += `[role=textbox],[role=textbox] *{-webkit-user-select:text!important;user-select:text!important;-webkit-touch-callout:default;touch-action:auto}`;
1289
+ style.textContent += `.toolbar-group--fonts{gap:4px}.toolbar-font-select{width:100%;min-width:0}.toolbar-group--color{gap:6px;padding-inline:8px}.toolbar-color{position:relative;min-width:0}.toolbar-color>summary{box-sizing:border-box;display:flex;align-items:center;gap:5px;min-height:36px;padding:4px 7px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,#c8c8c8));border-radius:var(--spui-control-radius,5px);background:var(--spui-control-background,var(--spui-ui-surface,#fff));color:var(--spui-ui-text,inherit);font-size:11px;font-weight:600;cursor:pointer;list-style:none}.toolbar-color>summary::-webkit-details-marker{display:none}.toolbar-color__glyph{font-size:17px;line-height:1}.toolbar-color[data-command=foreColor] .toolbar-color__glyph{border-bottom:3px solid var(--spui-picked-color)}.toolbar-color[data-command=hiliteColor] .toolbar-color__glyph{color:var(--spui-picked-color);text-shadow:0 0 0 var(--spui-picked-color)}.toolbar-color__current{width:16px;height:16px;border:1px solid color-mix(in srgb,var(--spui-ui-text,#323130) 22%,transparent);border-radius:4px;background:var(--spui-picked-color);flex:none}.toolbar-color__name{white-space:nowrap}.toolbar-color__palette{position:absolute;z-index:10;inset-block-start:calc(100% + 4px);inset-inline-start:0;display:grid;grid-template-columns:repeat(6,28px);gap:4px;min-width:196px;padding:8px;border:1px solid var(--spui-ui-stroke,#c8c8c8);border-radius:8px;background:var(--spui-ui-surface,#fff);box-shadow:0 8px 24px #0003}.toolbar-color__swatch{width:28px;min-width:28px;height:28px;min-height:28px;padding:0;border:1px solid color-mix(in srgb,var(--spui-ui-text,#323130) 22%,transparent);border-radius:50%;background:var(--spui-picked-color);cursor:pointer}.toolbar-color__swatch:hover{outline:2px solid var(--spui-ui-accent,#0078d4);outline-offset:1px}.toolbar-color__custom{grid-column:1/-1;display:flex;align-items:center;gap:8px;padding-block-start:6px;border-block-start:1px solid var(--spui-ui-stroke,#ddd);font-size:12px;font-weight:500}.toolbar-color__custom input{box-sizing:border-box;width:42px;height:34px;min-height:34px;padding:2px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,#c8c8c8));border-radius:5px;background:var(--spui-control-background,var(--spui-ui-surface,#fff));cursor:pointer}.toolbar-color input:disabled{opacity:.42;cursor:not-allowed}@container (max-width:64rem){.toolbar-group--fonts{grid-template-columns:repeat(2,minmax(0,1fr))}.toolbar-font-select{width:100%;min-width:0;max-width:none;min-height:44px}.toolbar-group--color{display:flex;flex-wrap:wrap;justify-content:start;padding:0}.toolbar-color>summary{min-height:44px;padding-inline:9px}.toolbar-color__palette{grid-template-columns:repeat(6,32px);min-width:220px}.toolbar-color__swatch{width:32px;min-width:32px;height:32px;min-height:32px}}`;
1290
+ style.textContent += `.link-new-tab{display:inline-flex;flex:1 0 100%;align-items:center;gap:8px;color:var(--spui-ui-text,inherit);font-size:13px}.link-new-tab input{width:16px;height:16px;min-height:16px;margin:0;accent-color:var(--spui-ui-accent,#0078d4)}`;
1291
+ style.textContent += `.link-picker-status{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.toolbar-color>summary[aria-disabled=true]{opacity:.42;cursor:not-allowed}`;
1292
+ style.textContent += `.toolbar-color>summary:focus-visible,.toolbar-color__swatch:focus-visible{outline:2px solid var(--spui-focus-ring,var(--spui-ui-accent,#005a9e));outline-offset:2px}`;
1293
+ shadow.replaceChildren(style, label, toolbar, linkForm, this.editor);
485
1294
  syncHostAccessibleName(this, this.editor);
486
1295
  this.syncDisabled();
487
1296
  this.refreshStrings();
1297
+ this.syncContentMode();
488
1298
  this.onSelectionChange();
489
1299
  }
490
1300
  refreshStrings() {
@@ -494,13 +1304,62 @@ export function defineRichTextEditor() {
494
1304
  this.imageInput?.setAttribute("aria-label", this.getAttribute("image-input-label") ?? "Choose image");
495
1305
  const label = this.getAttribute("label") ?? "Rich text";
496
1306
  toolbar.setAttribute("aria-label", this.getAttribute("toolbar-label") ?? `${label} formatting`);
497
- const attrs = ["bold-label", "italic-label", "underline-label", "bullet-list-label", "number-list-label", "undo-label", "redo-label", "insert-table-label", "insert-image-label", "insert-row-above-label", "insert-row-below-label", "insert-column-before-label", "insert-column-after-label", "delete-row-label", "delete-column-label", "delete-table-label", "link-label"];
1307
+ const attrs = ["bold-label", "italic-label", "underline-label", "strike-label", "bullet-list-label", "number-list-label", "clear-formatting-label", "undo-label", "redo-label", "insert-table-label", "insert-image-label", "insert-row-above-label", "insert-row-below-label", "insert-column-before-label", "insert-column-after-label", "delete-row-label", "delete-column-label", "delete-table-label", "link-label"];
498
1308
  toolbar.querySelectorAll("button[data-command]").forEach((button, index) => {
499
1309
  const attribute = button.dataset.labelAttribute ?? attrs[index];
500
1310
  const text = this.getAttribute(attribute) ?? button.dataset.fallback ?? "";
501
- button.textContent = button.dataset.command === "insertUnorderedList" && !this.hasAttribute(attribute) ? "• List" : button.dataset.command === "insertOrderedList" && !this.hasAttribute(attribute) ? "1. List" : text;
502
1311
  button.setAttribute("aria-label", text);
1312
+ button.title = text;
503
1313
  });
1314
+ const format = toolbar.querySelector('select[data-command="formatBlock"]');
1315
+ if (format) {
1316
+ format.setAttribute("aria-label", this.getAttribute("format-block-label") ?? "Text style");
1317
+ const labels = { P: ["paragraph-label", "Paragraph"], H1: ["heading-1-label", "Heading 1"], H2: ["heading-2-label", "Heading 2"], H3: ["heading-3-label", "Heading 3"], BLOCKQUOTE: ["blockquote-label", "Quote"], PRE: ["preformatted-label", "Preformatted"] };
1318
+ format.querySelectorAll("option").forEach(option => { const [attribute, fallback] = labels[option.value]; option.textContent = this.getAttribute(attribute) ?? fallback; });
1319
+ }
1320
+ toolbar.querySelectorAll(".toolbar-font-select").forEach(select => select.setAttribute("aria-label", this.getAttribute(select.dataset.labelAttribute ?? "") ?? (select.dataset.command === "fontFamily" ? "Font family" : "Font size")));
1321
+ toolbar.querySelectorAll(".toolbar-color[data-label-attribute]").forEach(picker => {
1322
+ const text = this.getAttribute(picker.dataset.labelAttribute ?? "") ?? (picker.dataset.command === "foreColor" ? "Text color" : "Highlight color");
1323
+ const summary = picker.querySelector("summary");
1324
+ if (summary) {
1325
+ summary.setAttribute("aria-label", text);
1326
+ summary.title = text;
1327
+ }
1328
+ picker.querySelector(".toolbar-color__palette")?.setAttribute("aria-label", text);
1329
+ picker.querySelector('input[type="color"]')?.setAttribute("aria-label", `${text}: ${this.getAttribute("custom-color-label") ?? "Custom color"}`);
1330
+ const themeAccent = picker.querySelector('.toolbar-color__swatch[data-color="theme-accent"]');
1331
+ if (themeAccent) {
1332
+ const accentLabel = this.getAttribute("theme-accent-label") ?? "SharePoint theme accent";
1333
+ themeAccent.setAttribute("aria-label", accentLabel);
1334
+ themeAccent.title = accentLabel;
1335
+ }
1336
+ const swatchLabel = this.getAttribute("color-swatch-label") ?? "Select {color}";
1337
+ picker.querySelectorAll('.toolbar-color__swatch:not([data-color="theme-accent"])').forEach(swatch => { const label = swatchLabel.replace(/\{color\}/g, swatch.dataset.color ?? ""); swatch.setAttribute("aria-label", label); swatch.title = label; });
1338
+ const customLabel = picker.querySelector(".toolbar-color__custom");
1339
+ if (customLabel?.firstChild?.nodeType === Node.TEXT_NODE)
1340
+ customLabel.firstChild.textContent = this.getAttribute("custom-color-label") ?? "Custom color";
1341
+ const name = picker.querySelector(".toolbar-color__name");
1342
+ if (name)
1343
+ name.textContent = text;
1344
+ });
1345
+ if (this.linkForm) {
1346
+ this.linkForm.setAttribute("aria-label", this.getAttribute("link-dialog-label") ?? "Edit link");
1347
+ const fieldFallbacks = { "link-prompt": "Link URL", "link-text-label": "Display text", "link-title-label": "Title" };
1348
+ this.linkForm.querySelectorAll(".link-field[data-label-attribute]").forEach(field => {
1349
+ const attribute = field.dataset.labelAttribute;
1350
+ if (field.firstChild)
1351
+ field.firstChild.textContent = this.getAttribute(attribute) ?? fieldFallbacks[attribute] ?? "";
1352
+ });
1353
+ const actionFallbacks = { "link-apply-label": "Apply link", "link-remove-label": "Remove link", "link-cancel-label": "Cancel", "link-picker-label": "Browse SharePoint" };
1354
+ this.linkForm.querySelectorAll("button[data-label-attribute]").forEach(button => {
1355
+ const attribute = button.dataset.labelAttribute;
1356
+ button.textContent = this.getAttribute(attribute) ?? actionFallbacks[attribute] ?? "";
1357
+ });
1358
+ const newTabText = this.linkForm.querySelector(".link-new-tab span");
1359
+ if (newTabText)
1360
+ newTabText.textContent = this.getAttribute("link-new-tab-label") ?? "Open in new tab";
1361
+ }
1362
+ this.syncLinkPickerButton();
504
1363
  this.syncTableActions();
505
1364
  }
506
1365
  }