@spfx-fast/sharepoint-ui 0.4.1 → 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.
- package/AGENTS.md +23 -3
- package/QUICKSTART.md +133 -0
- package/README.md +21 -878
- package/components.json +178 -33
- package/dist/appearance.js +4 -4
- package/dist/elements/alert.js +26 -2
- package/dist/elements/avatar.js +1 -1
- package/dist/elements/button.js +1 -1
- package/dist/elements/carousel.js +5 -2
- package/dist/elements/color-picker.js +1 -1
- package/dist/elements/combobox.js +5 -5
- package/dist/elements/data-grid-model.d.ts +3 -0
- package/dist/elements/data-grid.d.ts +2 -1
- package/dist/elements/data-grid.js +19 -10
- package/dist/elements/date-picker.js +1 -1
- package/dist/elements/dialog.js +1 -1
- package/dist/elements/file-upload.js +1 -1
- package/dist/elements/floating-position.d.ts +10 -0
- package/dist/elements/floating-position.js +53 -0
- package/dist/elements/info.js +1 -1
- package/dist/elements/input.js +2 -2
- package/dist/elements/link.js +1 -1
- package/dist/elements/message-bar.js +33 -5
- package/dist/elements/nav.js +4 -1
- package/dist/elements/overflow.d.ts +1 -0
- package/dist/elements/overflow.js +2 -2
- package/dist/elements/popover.js +1 -1
- package/dist/elements/rating.js +3 -3
- package/dist/elements/rich-text-editor.d.ts +15 -1
- package/dist/elements/rich-text-editor.js +938 -79
- package/dist/elements/search.js +3 -10
- package/dist/elements/select.js +2 -2
- package/dist/elements/shared.d.ts +4 -1
- package/dist/elements/shared.js +14 -1
- package/dist/elements/sharepoint-field-basic-controls.d.ts +1 -0
- package/dist/elements/sharepoint-field-basic-controls.js +14 -0
- package/dist/elements/sharepoint-fields.d.ts +29 -0
- package/dist/elements/sharepoint-fields.js +460 -0
- package/dist/elements/spin-button.js +2 -2
- package/dist/elements/tag-picker.js +29 -11
- package/dist/elements/textarea.js +2 -2
- package/dist/elements/toaster.js +2 -1
- package/dist/elements/tooltip.js +18 -1
- package/dist/elements/tree.js +1 -1
- package/dist/elements.d.ts +3 -2
- package/dist/elements.js +4 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/react.d.ts +188 -19
- package/dist/react.js +321 -61
- package/dist/sharepoint-fields.d.ts +135 -0
- package/dist/sharepoint-fields.js +402 -0
- package/dist/theme.d.ts +9 -1
- package/dist/theme.js +78 -1
- package/package.json +11 -2
- package/skills/sharepoint-ui/SKILL.md +1 -1
- package/skills/sharepoint-ui/references/sharepoint-patterns.md +1 -1
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
import { base, syncHostAccessibleName } from "./shared.js";
|
|
2
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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) {
|
|
134
|
-
this.
|
|
135
|
-
this.
|
|
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 =
|
|
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(
|
|
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(
|
|
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?.() ??
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
this.
|
|
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.
|
|
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
|
-
|
|
335
|
-
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
this.
|
|
345
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
395
|
-
|
|
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 =
|
|
400
|
-
if (labelAttribute)
|
|
1046
|
+
button.dataset.fallback = title;
|
|
1047
|
+
if (labelAttribute)
|
|
401
1048
|
button.dataset.labelAttribute = labelAttribute;
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
button.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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" ?
|
|
428
|
-
if (index < 0 || !
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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 =
|
|
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("
|
|
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.
|
|
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", () =>
|
|
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:
|
|
484
|
-
|
|
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
|
}
|