@pure-ds/core 0.3.0
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/CSS-INTELLISENSE-LIMITATION.md +98 -0
- package/CSS-INTELLISENSE-QUICK-REF.md +238 -0
- package/INTELLISENSE.md +384 -0
- package/LICENSE +15 -0
- package/custom-elements-manifest.config.js +30 -0
- package/custom-elements.json +2003 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/packages/pds-configurator/src/figma-export.d.ts +13 -0
- package/dist/types/packages/pds-configurator/src/figma-export.d.ts.map +1 -0
- package/dist/types/packages/pds-configurator/src/pds-config-form.d.ts +2 -0
- package/dist/types/packages/pds-configurator/src/pds-config-form.d.ts.map +1 -0
- package/dist/types/packages/pds-configurator/src/pds-configurator.d.ts +2 -0
- package/dist/types/packages/pds-configurator/src/pds-configurator.d.ts.map +1 -0
- package/dist/types/packages/pds-configurator/src/pds-demo.d.ts +2 -0
- package/dist/types/packages/pds-configurator/src/pds-demo.d.ts.map +1 -0
- package/dist/types/pds.config.d.ts +13 -0
- package/dist/types/pds.config.d.ts.map +1 -0
- package/dist/types/pds.d.ts +408 -0
- package/dist/types/public/assets/js/app.d.ts +2 -0
- package/dist/types/public/assets/js/app.d.ts.map +1 -0
- package/dist/types/public/assets/js/pds.d.ts +23 -0
- package/dist/types/public/assets/js/pds.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-calendar.d.ts +23 -0
- package/dist/types/public/assets/pds/components/pds-calendar.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-drawer.d.ts +2 -0
- package/dist/types/public/assets/pds/components/pds-drawer.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-icon.d.ts +53 -0
- package/dist/types/public/assets/pds/components/pds-icon.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-jsonform.d.ts +104 -0
- package/dist/types/public/assets/pds/components/pds-jsonform.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-richtext.d.ts +121 -0
- package/dist/types/public/assets/pds/components/pds-richtext.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-scrollrow.d.ts +61 -0
- package/dist/types/public/assets/pds/components/pds-scrollrow.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-splitpanel.d.ts +1 -0
- package/dist/types/public/assets/pds/components/pds-splitpanel.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-tabstrip.d.ts +39 -0
- package/dist/types/public/assets/pds/components/pds-tabstrip.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-toaster.d.ts +111 -0
- package/dist/types/public/assets/pds/components/pds-toaster.d.ts.map +1 -0
- package/dist/types/public/assets/pds/components/pds-upload.d.ts +83 -0
- package/dist/types/public/assets/pds/components/pds-upload.d.ts.map +1 -0
- package/dist/types/src/js/app.d.ts +2 -0
- package/dist/types/src/js/app.d.ts.map +1 -0
- package/dist/types/src/js/common/ask.d.ts +22 -0
- package/dist/types/src/js/common/ask.d.ts.map +1 -0
- package/dist/types/src/js/common/common.d.ts +3 -0
- package/dist/types/src/js/common/common.d.ts.map +1 -0
- package/dist/types/src/js/common/font-loader.d.ts +24 -0
- package/dist/types/src/js/common/font-loader.d.ts.map +1 -0
- package/dist/types/src/js/common/msg.d.ts +3 -0
- package/dist/types/src/js/common/msg.d.ts.map +1 -0
- package/dist/types/src/js/lit.d.ts +25 -0
- package/dist/types/src/js/lit.d.ts.map +1 -0
- package/dist/types/src/js/pds-configurator/figma-export.d.ts +13 -0
- package/dist/types/src/js/pds-configurator/figma-export.d.ts.map +1 -0
- package/dist/types/src/js/pds-configurator/pds-config-form.d.ts +2 -0
- package/dist/types/src/js/pds-configurator/pds-config-form.d.ts.map +1 -0
- package/dist/types/src/js/pds-configurator/pds-configurator.d.ts +2 -0
- package/dist/types/src/js/pds-configurator/pds-configurator.d.ts.map +1 -0
- package/dist/types/src/js/pds-configurator/pds-demo.d.ts +2 -0
- package/dist/types/src/js/pds-configurator/pds-demo.d.ts.map +1 -0
- package/dist/types/src/js/pds-core/pds-config.d.ts +758 -0
- package/dist/types/src/js/pds-core/pds-config.d.ts.map +1 -0
- package/dist/types/src/js/pds-core/pds-enhancer-metadata.d.ts +6 -0
- package/dist/types/src/js/pds-core/pds-enhancer-metadata.d.ts.map +1 -0
- package/dist/types/src/js/pds-core/pds-enhancers.d.ts +14 -0
- package/dist/types/src/js/pds-core/pds-enhancers.d.ts.map +1 -0
- package/dist/types/src/js/pds-core/pds-enums.d.ts +87 -0
- package/dist/types/src/js/pds-core/pds-enums.d.ts.map +1 -0
- package/dist/types/src/js/pds-core/pds-generator.d.ts +741 -0
- package/dist/types/src/js/pds-core/pds-generator.d.ts.map +1 -0
- package/dist/types/src/js/pds-core/pds-ontology.d.ts +48 -0
- package/dist/types/src/js/pds-core/pds-ontology.d.ts.map +1 -0
- package/dist/types/src/js/pds-core/pds-paths.d.ts +37 -0
- package/dist/types/src/js/pds-core/pds-paths.d.ts.map +1 -0
- package/dist/types/src/js/pds-core/pds-query.d.ts +102 -0
- package/dist/types/src/js/pds-core/pds-query.d.ts.map +1 -0
- package/dist/types/src/js/pds-core/pds-registry.d.ts +40 -0
- package/dist/types/src/js/pds-core/pds-registry.d.ts.map +1 -0
- package/dist/types/src/js/pds.d.ts +109 -0
- package/dist/types/src/js/pds.d.ts.map +1 -0
- package/dist/types/src/pds-core/pds-api.d.ts +31 -0
- package/dist/types/src/pds-core/pds-api.d.ts.map +1 -0
- package/package.json +104 -0
- package/packages/pds-cli/README.md +15 -0
- package/packages/pds-cli/bin/generate-css-data.js +565 -0
- package/packages/pds-cli/bin/generate-manifest.js +352 -0
- package/packages/pds-cli/bin/pds-build-icons.js +152 -0
- package/packages/pds-cli/bin/pds-dx.js +114 -0
- package/packages/pds-cli/bin/pds-static.js +556 -0
- package/packages/pds-cli/bin/pds.js +127 -0
- package/packages/pds-cli/bin/postinstall.js +380 -0
- package/packages/pds-cli/bin/sync-assets.js +252 -0
- package/packages/pds-cli/lib/asset-roots.js +47 -0
- package/packages/pds-cli/lib/fs-writer.js +75 -0
- package/pds.css-data.json +5 -0
- package/pds.html-data.json +5 -0
- package/public/assets/js/app.js +5719 -0
- package/public/assets/js/lit.js +131 -0
- package/public/assets/js/pds.js +3423 -0
- package/public/assets/pds/components/pds-calendar.js +837 -0
- package/public/assets/pds/components/pds-drawer.js +857 -0
- package/public/assets/pds/components/pds-icon.js +338 -0
- package/public/assets/pds/components/pds-jsonform.js +1775 -0
- package/public/assets/pds/components/pds-richtext.js +1035 -0
- package/public/assets/pds/components/pds-scrollrow.js +331 -0
- package/public/assets/pds/components/pds-splitpanel.js +401 -0
- package/public/assets/pds/components/pds-tabstrip.js +251 -0
- package/public/assets/pds/components/pds-toaster.js +446 -0
- package/public/assets/pds/components/pds-upload.js +657 -0
- package/public/assets/pds/custom-elements.json +2003 -0
- package/public/assets/pds/icons/pds-icons.svg +498 -0
- package/public/assets/pds/pds-css-complete.json +1861 -0
- package/public/assets/pds/pds.css-data.json +2152 -0
- package/public/assets/pds/vscode-custom-data.json +824 -0
- package/readme.md +1870 -0
- package/src/js/pds-core/pds-config.js +1162 -0
- package/src/js/pds-core/pds-enhancer-metadata.js +75 -0
- package/src/js/pds-core/pds-enhancers.js +357 -0
- package/src/js/pds-core/pds-enums.js +86 -0
- package/src/js/pds-core/pds-generator.js +5317 -0
- package/src/js/pds-core/pds-ontology.js +256 -0
- package/src/js/pds-core/pds-paths.js +109 -0
- package/src/js/pds-core/pds-query.js +571 -0
- package/src/js/pds-core/pds-registry.js +129 -0
- package/src/js/pds-core/pds.d.ts +129 -0
- package/src/js/pds.d.ts +408 -0
- package/src/js/pds.js +1579 -0
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack-style rich text field with a semantic output pipeline.
|
|
3
|
+
*
|
|
4
|
+
* @element pds-richtext
|
|
5
|
+
* @formAssociated
|
|
6
|
+
*
|
|
7
|
+
* @attr {string} name - Form field name included with submitted form data
|
|
8
|
+
* @attr {string} placeholder - Placeholder copy displayed when the editor is empty
|
|
9
|
+
* @attr {boolean} disabled - Disables editing, selection, and toolbar interactions
|
|
10
|
+
* @attr {boolean} required - Marks the field as required for native form validation
|
|
11
|
+
* @attr {boolean} submit-on-enter - When present, pressing Enter submits the host form (Shift+Enter inserts a newline)
|
|
12
|
+
* @attr {boolean} spellcheck - Enables native spellcheck inside the editor (default: true)
|
|
13
|
+
* @attr {boolean} toolbar - Toggles the formatting toolbar UI (default: true)
|
|
14
|
+
* @attr {"html"|"markdown"} format - Output format for `value`; Markdown uses Showdown for sanitised HTML
|
|
15
|
+
* @attr {string} value - Initial editor value; kept in sync with the `value` property
|
|
16
|
+
*
|
|
17
|
+
* @property {string} name - Reflective form control name
|
|
18
|
+
* @property {string} placeholder - Placeholder text for the editor surface
|
|
19
|
+
* @property {boolean} disabled - Reflects the disabled state on the host element
|
|
20
|
+
* @property {boolean} required - Mirrors native required semantics
|
|
21
|
+
* @property {boolean} submitOnEnter - Controls form submission when the user presses Enter
|
|
22
|
+
* @property {boolean} spellcheck - Enables or disables native spell checking
|
|
23
|
+
* @property {boolean} toolbar - Shows or hides the inline formatting toolbar
|
|
24
|
+
* @property {"html"|"markdown"} format - Determines whether the element emits HTML or Markdown
|
|
25
|
+
* @property {string} value - Serialised editor contents as HTML (default) or Markdown
|
|
26
|
+
*
|
|
27
|
+
* @fires input - Fired whenever the editor value syncs from user input
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* <form onsubmit="event.preventDefault(); console.log(new FormData(this).get('message'))">
|
|
31
|
+
* <pds-richtext name="message" placeholder="Message Steve" submit-on-enter></pds-richtext>
|
|
32
|
+
* <button type="submit">Send</button>
|
|
33
|
+
* </form>
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// Refactored: remove Lit dependency, implement as plain HTMLElement with Shadow DOM
|
|
37
|
+
// - Adopts PDS layers (primitives, components) for styling tokens
|
|
38
|
+
// - Replaces hardcoded colors with semantic CSS variables
|
|
39
|
+
// - Preserves form-associated behavior and public API
|
|
40
|
+
// - Toolbar & editor logic retained; uses minimal template string assembly
|
|
41
|
+
4;
|
|
42
|
+
export class RichText extends HTMLElement {
|
|
43
|
+
#internals;
|
|
44
|
+
#editorDiv;
|
|
45
|
+
#converter;
|
|
46
|
+
#loadingShowdown = false;
|
|
47
|
+
#loadedShowdown = false;
|
|
48
|
+
#isSyncingFromEditor = false;
|
|
49
|
+
#isReflectingValue = false;
|
|
50
|
+
#displayValue = "";
|
|
51
|
+
#showdownPromise = null;
|
|
52
|
+
#warnedMarkdownFallback = false;
|
|
53
|
+
|
|
54
|
+
static formAssociated = true;
|
|
55
|
+
|
|
56
|
+
static get observedAttributes() {
|
|
57
|
+
return [
|
|
58
|
+
"name",
|
|
59
|
+
"placeholder",
|
|
60
|
+
"disabled",
|
|
61
|
+
"required",
|
|
62
|
+
"submit-on-enter",
|
|
63
|
+
"spellcheck",
|
|
64
|
+
"toolbar",
|
|
65
|
+
"value",
|
|
66
|
+
"format",
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
constructor() {
|
|
71
|
+
super();
|
|
72
|
+
this.attachShadow({ mode: "open" });
|
|
73
|
+
this.#internals = this.attachInternals();
|
|
74
|
+
this._submitOnEnter = false;
|
|
75
|
+
this._toolbar = true;
|
|
76
|
+
this._spellcheck = true;
|
|
77
|
+
this._value = ""; // form submission value (HTML or Markdown)
|
|
78
|
+
this._placeholder = "";
|
|
79
|
+
this._disabled = false;
|
|
80
|
+
this._required = false;
|
|
81
|
+
this._format = "html";
|
|
82
|
+
this.#displayValue = "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Property accessors (reflective where needed)
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Current form field name used when serialising via `FormData`.
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
get name() {
|
|
92
|
+
return this.getAttribute("name") || "";
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Update the form field name.
|
|
96
|
+
* @param {string|null} v
|
|
97
|
+
*/
|
|
98
|
+
set name(v) {
|
|
99
|
+
if (v == null) this.removeAttribute("name");
|
|
100
|
+
else this.setAttribute("name", v);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Placeholder text shown while the editor is empty.
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
get placeholder() {
|
|
107
|
+
return this._placeholder;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Set the placeholder text.
|
|
111
|
+
* @param {string|null} v
|
|
112
|
+
*/
|
|
113
|
+
set placeholder(v) {
|
|
114
|
+
this._placeholder = v ?? "";
|
|
115
|
+
if (this.#editorDiv)
|
|
116
|
+
this.#editorDiv.setAttribute("data-ph", this._placeholder);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Indicates whether user input is enabled.
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
get disabled() {
|
|
123
|
+
return this._disabled;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Enable or disable user input.
|
|
127
|
+
* @param {boolean} v
|
|
128
|
+
*/
|
|
129
|
+
set disabled(v) {
|
|
130
|
+
const b = !!v;
|
|
131
|
+
this._disabled = b;
|
|
132
|
+
this.toggleAttribute("disabled", b);
|
|
133
|
+
if (this.#editorDiv)
|
|
134
|
+
this.#editorDiv.setAttribute("contenteditable", String(!b));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Whether the control participates in required form validation.
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
get required() {
|
|
141
|
+
return this._required;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Toggle required validation.
|
|
145
|
+
* @param {boolean} v
|
|
146
|
+
*/
|
|
147
|
+
set required(v) {
|
|
148
|
+
const b = !!v;
|
|
149
|
+
this._required = b;
|
|
150
|
+
this.toggleAttribute("required", b);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Submit-on-enter behaviour flag.
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
get submitOnEnter() {
|
|
157
|
+
return this._submitOnEnter;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Enable or disable submit-on-enter behaviour.
|
|
161
|
+
* @param {boolean} v
|
|
162
|
+
*/
|
|
163
|
+
set submitOnEnter(v) {
|
|
164
|
+
const b = !!v;
|
|
165
|
+
this._submitOnEnter = b;
|
|
166
|
+
this.toggleAttribute("submit-on-enter", b);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Indicates if native spell checking is active.
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
get spellcheck() {
|
|
173
|
+
return this._spellcheck;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Toggle native spell checking support.
|
|
177
|
+
* @param {boolean} v
|
|
178
|
+
*/
|
|
179
|
+
set spellcheck(v) {
|
|
180
|
+
const b = !!v;
|
|
181
|
+
this._spellcheck = b;
|
|
182
|
+
this.toggleAttribute("spellcheck", b);
|
|
183
|
+
if (this.#editorDiv) this.#editorDiv.setAttribute("spellcheck", String(b));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Whether the formatting toolbar is rendered.
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
get toolbar() {
|
|
190
|
+
return this._toolbar;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Show or hide the formatting toolbar.
|
|
194
|
+
* @param {boolean} v
|
|
195
|
+
*/
|
|
196
|
+
set toolbar(v) {
|
|
197
|
+
const b = !!v;
|
|
198
|
+
this._toolbar = b;
|
|
199
|
+
this.toggleAttribute("toolbar", b);
|
|
200
|
+
this.#render();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Current output format for the value (HTML or Markdown).
|
|
204
|
+
* @returns {"html"|"markdown"}
|
|
205
|
+
*/
|
|
206
|
+
get format() {
|
|
207
|
+
return this._format;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Change the output format for future values.
|
|
211
|
+
* @param {string|null} v
|
|
212
|
+
*/
|
|
213
|
+
set format(v) {
|
|
214
|
+
const next =
|
|
215
|
+
(v ?? "").toString().toLowerCase() === "markdown" ? "markdown" : "html";
|
|
216
|
+
if (next === "html") {
|
|
217
|
+
if (this.hasAttribute("format")) this.removeAttribute("format");
|
|
218
|
+
} else if (this.getAttribute("format") !== "markdown") {
|
|
219
|
+
this.setAttribute("format", "markdown");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Serialised editor contents respecting the configured `format`.
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
get value() {
|
|
227
|
+
return this._value;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Update the editor value programmatically.
|
|
231
|
+
* @param {string|null} v
|
|
232
|
+
*/
|
|
233
|
+
set value(v) {
|
|
234
|
+
const next = v ?? "";
|
|
235
|
+
if (next === this._value) {
|
|
236
|
+
this.#reflectValueAttribute(this._value);
|
|
237
|
+
if (!this.#isSyncingFromEditor) {
|
|
238
|
+
this.#updateEditorFromValue({ reflect: false, updateForm: false });
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this._value = next;
|
|
243
|
+
if (this.#isSyncingFromEditor) {
|
|
244
|
+
this.#reflectValueAttribute(this._value);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
this.#updateEditorFromValue();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Reflect attribute mutations into the corresponding property state.
|
|
252
|
+
* @param {string} name
|
|
253
|
+
* @param {string|null} oldV
|
|
254
|
+
* @param {string|null} newV
|
|
255
|
+
*/
|
|
256
|
+
attributeChangedCallback(name, oldV, newV) {
|
|
257
|
+
if (oldV === newV) return;
|
|
258
|
+
switch (name) {
|
|
259
|
+
case "placeholder":
|
|
260
|
+
this.placeholder = newV || "";
|
|
261
|
+
break;
|
|
262
|
+
case "disabled":
|
|
263
|
+
this.disabled = this.hasAttribute("disabled");
|
|
264
|
+
break;
|
|
265
|
+
case "required":
|
|
266
|
+
this.required = this.hasAttribute("required");
|
|
267
|
+
break;
|
|
268
|
+
case "submit-on-enter":
|
|
269
|
+
this.submitOnEnter = this.hasAttribute("submit-on-enter");
|
|
270
|
+
break;
|
|
271
|
+
case "spellcheck":
|
|
272
|
+
this.spellcheck = this.hasAttribute("spellcheck");
|
|
273
|
+
break;
|
|
274
|
+
case "toolbar":
|
|
275
|
+
this.toolbar = this.hasAttribute("toolbar");
|
|
276
|
+
break;
|
|
277
|
+
case "value":
|
|
278
|
+
if (this.#isReflectingValue) break;
|
|
279
|
+
this._value = newV || "";
|
|
280
|
+
if (!this.#isSyncingFromEditor) this.#updateEditorFromValue();
|
|
281
|
+
break;
|
|
282
|
+
case "format":
|
|
283
|
+
this.#applyFormat(newV);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#applyFormat(value) {
|
|
289
|
+
const next =
|
|
290
|
+
(value ?? "").toString().toLowerCase() === "markdown"
|
|
291
|
+
? "markdown"
|
|
292
|
+
: "html";
|
|
293
|
+
if (next === this._format) return;
|
|
294
|
+
this._format = next;
|
|
295
|
+
const refresh = () => this.#updateEditorFromValue({
|
|
296
|
+
reflect: true,
|
|
297
|
+
updateForm: true,
|
|
298
|
+
forceDisplayRefresh: true,
|
|
299
|
+
});
|
|
300
|
+
if (next === "markdown") {
|
|
301
|
+
this.#ensureShowdown().then(refresh);
|
|
302
|
+
} else {
|
|
303
|
+
refresh();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Reference to the associated HTMLFormElement, when applicable.
|
|
309
|
+
* @returns {HTMLFormElement|null}
|
|
310
|
+
*/
|
|
311
|
+
get form() {
|
|
312
|
+
return this.#internals.form;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Run native form validation against the control.
|
|
316
|
+
* @returns {boolean}
|
|
317
|
+
*/
|
|
318
|
+
checkValidity() {
|
|
319
|
+
return this.#internals.checkValidity();
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Report validity using the browser's built-in UI.
|
|
323
|
+
* @returns {boolean}
|
|
324
|
+
*/
|
|
325
|
+
reportValidity() {
|
|
326
|
+
return this.#internals.reportValidity();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Attach DOM, adopted styles, and hydrate the initial value on connect.
|
|
331
|
+
* @returns {Promise<void>}
|
|
332
|
+
*/
|
|
333
|
+
async connectedCallback() {
|
|
334
|
+
this.#render();
|
|
335
|
+
await this.#adoptStyles();
|
|
336
|
+
if (this._format === "markdown") await this.#ensureShowdown();
|
|
337
|
+
else this.#ensureShowdown();
|
|
338
|
+
this.#updateEditorFromValue({ reflect: true, updateForm: true, forceDisplayRefresh: true });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async #adoptStyles() {
|
|
342
|
+
// Component stylesheet (tokens + semantic vars)
|
|
343
|
+
const componentStyles = PDS.createStylesheet(/*css*/ `@layer richtext {
|
|
344
|
+
:host { display:block; color: var(--rt-fg, var(--color-text-primary)); font: var(--font-body-sm, 14px/1.35 system-ui,-apple-system,Segoe UI,Roboto,sans-serif); }
|
|
345
|
+
:host([disabled]) { opacity: .6; pointer-events: none; }
|
|
346
|
+
.box { border: 1px solid var(--rt-border, var(--color-border, currentColor)); border-radius: var(--radius-md,8px); background: var(--rt-bg, var(--color-input-bg)); }
|
|
347
|
+
.box:focus-within {
|
|
348
|
+
border-color: var(--rt-border-focus, var(--color-primary-500, var(--color-primary))); box-shadow: 0 0 0 3px var(--rt-focus-ring, var(--color-focus-ring, color-mix(in oklab, var(--color-primary) 20%, CanvasText 80%)));
|
|
349
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary-500) 30%, transparent);
|
|
350
|
+
|
|
351
|
+
.ed {
|
|
352
|
+
background-color: var(--color-surface-base);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.toolbar {background-color: var(--surface-subtle-bg); display:flex; gap: var(--spacing-2,10px); align-items:center; border-bottom: 1px solid var(--rt-border, var(--color-border-muted)); border-radius: var(--radius-md,8px) var(--radius-md,8px) 0 0; }
|
|
357
|
+
.tbtn { transition: none; display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius: var(--radius-sm,6px); cursor:pointer; user-select:none; color: inherit; background: transparent; border:none; }
|
|
358
|
+
.tbtn:hover { background: var(--color-surface-hover, color-mix(in oklab, CanvasText 12%, transparent)); }
|
|
359
|
+
.edwrap { position:relative; }
|
|
360
|
+
.ed { display: block; min-height:90px; max-height: 400px; overflow:auto; padding: var(--spacing-1, 0) var(--spacing-2, 0); outline:none; word-break:break-word; border-radius: 0 0 var(--radius-md,8px) var(--radius-md,8px); background: var(--rt-editor-bg, var(--color-input-bg)); }
|
|
361
|
+
.ed[contenteditable="true"]:empty::before { content: attr(data-ph); color: var(--rt-muted, var(--color-text-muted)); pointer-events:none; }
|
|
362
|
+
.send { margin-left:auto; display:inline-flex; gap: var(--spacing-2,8px); align-items:center; }
|
|
363
|
+
button.icon { background:transparent; border:0; color:inherit; cursor:pointer; padding:6px; border-radius: var(--radius-sm,6px); }
|
|
364
|
+
button.icon:hover { background: var(--rt-hover-bg, color-mix(in oklab, CanvasText 12%, transparent)); }
|
|
365
|
+
}`);
|
|
366
|
+
try {
|
|
367
|
+
await PDS.adoptLayers(
|
|
368
|
+
this.shadowRoot,
|
|
369
|
+
["primitives", "components"],
|
|
370
|
+
[componentStyles]
|
|
371
|
+
);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.warn("richtext: adoptLayers failed", e);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async #ensureShowdown() {
|
|
378
|
+
if (this.#loadedShowdown) return true;
|
|
379
|
+
if (this.#showdownPromise) return this.#showdownPromise;
|
|
380
|
+
|
|
381
|
+
this.#loadingShowdown = true;
|
|
382
|
+
this.#showdownPromise = (async () => {
|
|
383
|
+
try {
|
|
384
|
+
let showdownExports = await this.#importShowdownFromMap();
|
|
385
|
+
|
|
386
|
+
if (!showdownExports) {
|
|
387
|
+
await this.#loadShowdownFromCdn();
|
|
388
|
+
showdownExports = window.showdown;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const showdownApi = this.#resolveShowdownExports(showdownExports);
|
|
392
|
+
if (!showdownApi) {
|
|
393
|
+
throw new Error("Showdown exports missing Converter");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
this.#applyShowdownConverter(showdownApi);
|
|
397
|
+
this.#loadedShowdown = true;
|
|
398
|
+
this.#warnedMarkdownFallback = false;
|
|
399
|
+
} catch (e) {
|
|
400
|
+
console.warn(
|
|
401
|
+
"Showdown failed to load; cleaning will still happen via local sanitizer.",
|
|
402
|
+
e
|
|
403
|
+
);
|
|
404
|
+
this.#loadedShowdown = false;
|
|
405
|
+
} finally {
|
|
406
|
+
this.#loadingShowdown = false;
|
|
407
|
+
if (!this.#loadedShowdown) this.#showdownPromise = null;
|
|
408
|
+
}
|
|
409
|
+
return this.#loadedShowdown;
|
|
410
|
+
})();
|
|
411
|
+
|
|
412
|
+
return this.#showdownPromise;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async #importShowdownFromMap() {
|
|
416
|
+
try {
|
|
417
|
+
const mod = await import("#showdown");
|
|
418
|
+
return mod;
|
|
419
|
+
} catch (err) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async #loadShowdownFromCdn() {
|
|
425
|
+
if ("showdown" in window) return;
|
|
426
|
+
try {
|
|
427
|
+
await this.#loadScript(
|
|
428
|
+
"https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js"
|
|
429
|
+
);
|
|
430
|
+
} catch {
|
|
431
|
+
await this.#loadScript(
|
|
432
|
+
"https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js"
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#resolveShowdownExports(exports) {
|
|
438
|
+
const candidates = [
|
|
439
|
+
exports,
|
|
440
|
+
exports && exports.default,
|
|
441
|
+
exports && exports.showdown,
|
|
442
|
+
exports && exports.default && exports.default.showdown,
|
|
443
|
+
];
|
|
444
|
+
for (const candidate of candidates) {
|
|
445
|
+
if (candidate && typeof candidate.Converter === "function") {
|
|
446
|
+
return candidate;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (exports && typeof exports.Converter === "function") return exports;
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
#applyShowdownConverter(api) {
|
|
454
|
+
if (!api || typeof api.Converter !== "function") {
|
|
455
|
+
throw new Error("Invalid Showdown API");
|
|
456
|
+
}
|
|
457
|
+
if (!window.showdown) {
|
|
458
|
+
window.showdown = api;
|
|
459
|
+
}
|
|
460
|
+
// @ts-ignore
|
|
461
|
+
this.#converter = new api.Converter({
|
|
462
|
+
simplifiedAutoLink: true,
|
|
463
|
+
openLinksInNewWindow: true,
|
|
464
|
+
strikethrough: true,
|
|
465
|
+
emoji: false,
|
|
466
|
+
ghMentions: false,
|
|
467
|
+
tables: false,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Load a classic script tag; resolves on load, rejects on error
|
|
472
|
+
#loadScript(src) {
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
const s = document.createElement("script");
|
|
475
|
+
s.src = src;
|
|
476
|
+
s.async = true;
|
|
477
|
+
s.onload = () => resolve(true);
|
|
478
|
+
s.onerror = () => reject(new Error(`Failed to load script ${src}`));
|
|
479
|
+
document.head.appendChild(s);
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#render() {
|
|
484
|
+
if (!this.shadowRoot) return;
|
|
485
|
+
const _labels = ["B", "I", "</>", "•", "1."]; // "🔗", "S"
|
|
486
|
+
const toolbarHtml = this._toolbar
|
|
487
|
+
? `<div class="toolbar">${_labels
|
|
488
|
+
.map(
|
|
489
|
+
(l) =>
|
|
490
|
+
`<button class="tbtn btn-btn-sm" data-tool="${l}">${
|
|
491
|
+
l === "</>" ? "</>" : l
|
|
492
|
+
}</button>`
|
|
493
|
+
)
|
|
494
|
+
.join("")}</div>`
|
|
495
|
+
: "";
|
|
496
|
+
this.shadowRoot.innerHTML = `
|
|
497
|
+
<div class="box">
|
|
498
|
+
${toolbarHtml}
|
|
499
|
+
<div class="edwrap">
|
|
500
|
+
<div class="ed" role="textbox" aria-multiline="true" data-ph="${
|
|
501
|
+
this._placeholder
|
|
502
|
+
}" contenteditable="${!this._disabled}" spellcheck="${
|
|
503
|
+
this._spellcheck
|
|
504
|
+
}">${this.#displayValue || ""}</div>
|
|
505
|
+
</div>
|
|
506
|
+
</div>`;
|
|
507
|
+
this.#editorDiv = this.shadowRoot.querySelector(".ed");
|
|
508
|
+
// Wire editor events
|
|
509
|
+
this.#editorDiv.addEventListener("paste", this.#onPaste);
|
|
510
|
+
this.#editorDiv.addEventListener("keydown", this.#onKeyDown);
|
|
511
|
+
this.#editorDiv.addEventListener("input", this.#onInput);
|
|
512
|
+
// Toolbar buttons
|
|
513
|
+
this.shadowRoot.querySelectorAll(".tbtn").forEach((btn) => {
|
|
514
|
+
btn.addEventListener("click", () => {
|
|
515
|
+
this.#focus();
|
|
516
|
+
const label = btn.getAttribute("data-tool");
|
|
517
|
+
this.#executeTool(label);
|
|
518
|
+
this.#syncValue();
|
|
519
|
+
});
|
|
520
|
+
btn.addEventListener("mousedown", (e) => e.preventDefault());
|
|
521
|
+
});
|
|
522
|
+
if (this.#editorDiv && this.#editorDiv.innerHTML !== this.#displayValue) {
|
|
523
|
+
this.#editorDiv.innerHTML = this.#displayValue;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
#executeTool(label) {
|
|
528
|
+
switch (label) {
|
|
529
|
+
case "B":
|
|
530
|
+
document.execCommand("bold");
|
|
531
|
+
break;
|
|
532
|
+
case "I":
|
|
533
|
+
document.execCommand("italic");
|
|
534
|
+
break;
|
|
535
|
+
case "S":
|
|
536
|
+
document.execCommand("strikeThrough");
|
|
537
|
+
break;
|
|
538
|
+
case "🔗":
|
|
539
|
+
this.#makeLink();
|
|
540
|
+
break;
|
|
541
|
+
case "</>":
|
|
542
|
+
this.#toggleCode();
|
|
543
|
+
break;
|
|
544
|
+
case "⤴":
|
|
545
|
+
this.#insertBlock("blockquote");
|
|
546
|
+
break;
|
|
547
|
+
case "•":
|
|
548
|
+
this.#insertBlock("ul");
|
|
549
|
+
break;
|
|
550
|
+
case "1.":
|
|
551
|
+
this.#insertBlock("ol");
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
#focus() {
|
|
557
|
+
this.#editorDiv?.focus();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Input -> update cleaned value for form submission
|
|
561
|
+
#onInput = () => {
|
|
562
|
+
this.#syncValue();
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// Paste as plain text
|
|
566
|
+
#onPaste = (e) => {
|
|
567
|
+
e.preventDefault();
|
|
568
|
+
const text = (e.clipboardData || window.clipboardData).getData(
|
|
569
|
+
"text/plain"
|
|
570
|
+
);
|
|
571
|
+
document.execCommand("insertText", false, text);
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
#onKeyDown = async (e) => {
|
|
575
|
+
if (e.key === "Enter" && !e.isComposing) {
|
|
576
|
+
if (e.shiftKey || !this._submitOnEnter) return; // newline allowed
|
|
577
|
+
e.preventDefault();
|
|
578
|
+
await this.#syncValue();
|
|
579
|
+
this.#requestSubmit();
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// ===== Formatting helpers =====
|
|
584
|
+
#makeLink() {
|
|
585
|
+
const url = window.prompt("Link URL", "https://");
|
|
586
|
+
if (!url) return;
|
|
587
|
+
document.execCommand("createLink", false, url);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
#toggleCode() {
|
|
591
|
+
// Wrap selection with <code> or unwrap if already in code
|
|
592
|
+
const sel = window.getSelection();
|
|
593
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
594
|
+
const range = sel.getRangeAt(0);
|
|
595
|
+
// Simple toggle: if ancestor <code>, unwrap; else wrap
|
|
596
|
+
const codeAncestor = this.#closestAncestor(
|
|
597
|
+
range.commonAncestorContainer,
|
|
598
|
+
"CODE"
|
|
599
|
+
);
|
|
600
|
+
if (codeAncestor) {
|
|
601
|
+
const parent = codeAncestor.parentNode;
|
|
602
|
+
while (codeAncestor.firstChild)
|
|
603
|
+
parent.insertBefore(codeAncestor.firstChild, codeAncestor);
|
|
604
|
+
parent.removeChild(codeAncestor);
|
|
605
|
+
} else {
|
|
606
|
+
const wrapper = document.createElement("code");
|
|
607
|
+
range.surroundContents(wrapper);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
#insertBlock(type) {
|
|
612
|
+
if (type === "blockquote") {
|
|
613
|
+
document.execCommand("formatBlock", false, "blockquote");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (type === "ul") {
|
|
617
|
+
document.execCommand("insertUnorderedList");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (type === "ol") {
|
|
621
|
+
document.execCommand("insertOrderedList");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
#closestAncestor(node, tag) {
|
|
627
|
+
while (node) {
|
|
628
|
+
if (node.nodeType === 1 && node.tagName === tag) return node;
|
|
629
|
+
node = node.parentNode;
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async #syncValue() {
|
|
635
|
+
if (!this.#editorDiv) return;
|
|
636
|
+
if (this._format === "markdown") await this.#ensureShowdown();
|
|
637
|
+
const { html, markdown } = this.#prepareContentFromEditor(
|
|
638
|
+
this.#editorDiv.innerHTML || "",
|
|
639
|
+
this._format === "markdown"
|
|
640
|
+
);
|
|
641
|
+
const nextValue = this._format === "markdown" ? markdown : html;
|
|
642
|
+
this.#isSyncingFromEditor = true;
|
|
643
|
+
this.value = nextValue;
|
|
644
|
+
this.#isSyncingFromEditor = false;
|
|
645
|
+
this.#displayValue = html;
|
|
646
|
+
if (this.#editorDiv.innerHTML !== html) {
|
|
647
|
+
this.#editorDiv.innerHTML = html;
|
|
648
|
+
}
|
|
649
|
+
this.#internals.setFormValue(nextValue);
|
|
650
|
+
this.#updateValidityState(html, nextValue);
|
|
651
|
+
this.dispatchEvent(
|
|
652
|
+
new InputEvent("input", { bubbles: true, composed: true })
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
#canonicalize(html) {
|
|
657
|
+
// normalize newlines and div/paragraphs
|
|
658
|
+
const tmp = document.createElement("div");
|
|
659
|
+
tmp.innerHTML = html;
|
|
660
|
+
// remove disallowed attributes
|
|
661
|
+
tmp.querySelectorAll("*").forEach((el) => {
|
|
662
|
+
[...el.attributes].forEach((a) => {
|
|
663
|
+
if (!/^href$/i.test(a.name)) el.removeAttribute(a.name);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
return tmp.innerHTML;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
#sanitizeHtml(html) {
|
|
670
|
+
const canonical = this.#canonicalize(html || "");
|
|
671
|
+
if (!canonical) return "";
|
|
672
|
+
const scratch = document.createElement("div");
|
|
673
|
+
scratch.innerHTML = canonical;
|
|
674
|
+
if (!(scratch.textContent || "").trim()) return "";
|
|
675
|
+
this.#stripDisallowedElements(scratch);
|
|
676
|
+
this.#normalizeBlockContainers(scratch);
|
|
677
|
+
this.#removeEmptyParagraphs(scratch);
|
|
678
|
+
this.#normalizeAnchors(scratch);
|
|
679
|
+
return scratch.innerHTML;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
#stripDisallowedElements(root) {
|
|
683
|
+
const allowed = new Set([
|
|
684
|
+
"A",
|
|
685
|
+
"B",
|
|
686
|
+
"BLOCKQUOTE",
|
|
687
|
+
"BR",
|
|
688
|
+
"CODE",
|
|
689
|
+
"DIV",
|
|
690
|
+
"EM",
|
|
691
|
+
"H1",
|
|
692
|
+
"H2",
|
|
693
|
+
"H3",
|
|
694
|
+
"H4",
|
|
695
|
+
"H5",
|
|
696
|
+
"H6",
|
|
697
|
+
"I",
|
|
698
|
+
"LI",
|
|
699
|
+
"OL",
|
|
700
|
+
"P",
|
|
701
|
+
"PRE",
|
|
702
|
+
"S",
|
|
703
|
+
"STRONG",
|
|
704
|
+
"U",
|
|
705
|
+
"UL",
|
|
706
|
+
]);
|
|
707
|
+
root.querySelectorAll("*").forEach((el) => {
|
|
708
|
+
const tag = el.tagName.toUpperCase();
|
|
709
|
+
if (!allowed.has(tag)) {
|
|
710
|
+
const frag = document.createDocumentFragment();
|
|
711
|
+
while (el.firstChild) frag.appendChild(el.firstChild);
|
|
712
|
+
el.replaceWith(frag);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (tag === "A") {
|
|
716
|
+
[...el.attributes].forEach((attr) => {
|
|
717
|
+
if (!/^(href|target|rel)$/i.test(attr.name)) {
|
|
718
|
+
el.removeAttribute(attr.name);
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
} else {
|
|
722
|
+
[...el.attributes].forEach((attr) => {
|
|
723
|
+
el.removeAttribute(attr.name);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
#normalizeBlockContainers(root) {
|
|
730
|
+
const blockChildren = new Set([
|
|
731
|
+
"BLOCKQUOTE",
|
|
732
|
+
"DIV",
|
|
733
|
+
"H1",
|
|
734
|
+
"H2",
|
|
735
|
+
"H3",
|
|
736
|
+
"H4",
|
|
737
|
+
"H5",
|
|
738
|
+
"H6",
|
|
739
|
+
"OL",
|
|
740
|
+
"PRE",
|
|
741
|
+
"UL",
|
|
742
|
+
]);
|
|
743
|
+
root.querySelectorAll("p").forEach((p) => {
|
|
744
|
+
const hasBlockChild = [...p.children].some((child) =>
|
|
745
|
+
blockChildren.has(child.tagName.toUpperCase())
|
|
746
|
+
);
|
|
747
|
+
if (!hasBlockChild) return;
|
|
748
|
+
const parent = p.parentNode;
|
|
749
|
+
if (!parent) return;
|
|
750
|
+
while (p.firstChild) parent.insertBefore(p.firstChild, p);
|
|
751
|
+
parent.removeChild(p);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
#removeEmptyParagraphs(root) {
|
|
756
|
+
root.querySelectorAll("p").forEach((p) => {
|
|
757
|
+
const hasContent = (p.textContent || "").replace(/\u00A0/g, " ").trim().length > 0;
|
|
758
|
+
const hasNonBreakChild = [...p.children].some((child) => child.tagName && child.tagName.toUpperCase() !== "BR");
|
|
759
|
+
if (!hasContent && !hasNonBreakChild) {
|
|
760
|
+
p.remove();
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
#normalizeAnchors(root) {
|
|
766
|
+
root.querySelectorAll("a").forEach((a) => {
|
|
767
|
+
const href = a.getAttribute("href") || "";
|
|
768
|
+
if (!href || /^javascript:/i.test(href)) {
|
|
769
|
+
a.removeAttribute("href");
|
|
770
|
+
a.removeAttribute("target");
|
|
771
|
+
a.removeAttribute("rel");
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (!a.hasAttribute("target")) {
|
|
775
|
+
a.setAttribute("target", "_blank");
|
|
776
|
+
}
|
|
777
|
+
const existingRel = (a.getAttribute("rel") || "")
|
|
778
|
+
.split(/\s+/)
|
|
779
|
+
.filter(Boolean);
|
|
780
|
+
if (!existingRel.includes("noopener")) existingRel.push("noopener");
|
|
781
|
+
if (!existingRel.includes("noreferrer")) existingRel.push("noreferrer");
|
|
782
|
+
a.setAttribute("rel", existingRel.join(" "));
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
#cleanHtml(html, options = {}) {
|
|
787
|
+
const { roundtrip = true } = options;
|
|
788
|
+
const sanitized = this.#sanitizeHtml(html);
|
|
789
|
+
if (!sanitized) return "";
|
|
790
|
+
if (!roundtrip) return sanitized;
|
|
791
|
+
const md = this.#htmlToMinimalMarkdown(sanitized);
|
|
792
|
+
if (!md.trim()) return "";
|
|
793
|
+
return this.#loadedShowdown && this.#converter
|
|
794
|
+
? this.#converter.makeHtml(md)
|
|
795
|
+
: this.#markdownToBareHtml(md);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
#prepareContentFromEditor(html, requireConverter = false) {
|
|
799
|
+
const cleanedHtml = this.#cleanHtml(html || "", { roundtrip: false });
|
|
800
|
+
if (!cleanedHtml) return { html: "", markdown: "" };
|
|
801
|
+
if (this.#converter && typeof this.#converter.makeMarkdown === "function") {
|
|
802
|
+
return { html: cleanedHtml, markdown: this.#converter.makeMarkdown(cleanedHtml) };
|
|
803
|
+
}
|
|
804
|
+
if (requireConverter && !this.#warnedMarkdownFallback) {
|
|
805
|
+
console.warn(
|
|
806
|
+
"pds-richtext: Showdown converter unavailable; falling back to internal markdown cleaner."
|
|
807
|
+
);
|
|
808
|
+
this.#warnedMarkdownFallback = true;
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
html: cleanedHtml,
|
|
812
|
+
markdown: this.#htmlToMinimalMarkdown(cleanedHtml),
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
#updateValidityState(displayHtml, formValue) {
|
|
817
|
+
if (!this.required) {
|
|
818
|
+
this.#internals.setValidity({}, "", this.#editorDiv);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const hasDisplayContent = this.#hasContent(displayHtml);
|
|
822
|
+
const hasValueContent = typeof formValue === "string" && formValue.trim().length > 0;
|
|
823
|
+
if (hasDisplayContent || hasValueContent) {
|
|
824
|
+
this.#internals.setValidity({}, "", this.#editorDiv);
|
|
825
|
+
} else {
|
|
826
|
+
this.#internals.setValidity(
|
|
827
|
+
{ customError: true },
|
|
828
|
+
"Please enter a message.",
|
|
829
|
+
this.#editorDiv
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
#hasContent(html) {
|
|
835
|
+
if (!html) return false;
|
|
836
|
+
const tmp = document.createElement("div");
|
|
837
|
+
tmp.innerHTML = html;
|
|
838
|
+
return !!(tmp.textContent || "").trim();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
#updateEditorFromValue(options = {}) {
|
|
842
|
+
if (
|
|
843
|
+
this._format === "markdown" &&
|
|
844
|
+
(!this.#converter || !this.#loadedShowdown)
|
|
845
|
+
) {
|
|
846
|
+
this.#ensureShowdown().then(() => this.#applyValueToEditor(options));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
this.#applyValueToEditor(options);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
#applyValueToEditor(options = {}) {
|
|
853
|
+
const { reflect = true, updateForm = true, forceDisplayRefresh = false } = options;
|
|
854
|
+
const format = this._format;
|
|
855
|
+
let cleanedHtml;
|
|
856
|
+
if (format === "html") {
|
|
857
|
+
cleanedHtml = this.#cleanHtml(this._value, { roundtrip: false });
|
|
858
|
+
if (cleanedHtml !== this._value) {
|
|
859
|
+
this._value = cleanedHtml;
|
|
860
|
+
}
|
|
861
|
+
} else {
|
|
862
|
+
const renderedHtml = this.#markdownToDisplayHtml(this._value);
|
|
863
|
+
cleanedHtml = this.#cleanHtml(renderedHtml, { roundtrip: false });
|
|
864
|
+
if (this.#converter && typeof this.#converter.makeMarkdown === "function") {
|
|
865
|
+
const normalizedMarkdown = cleanedHtml
|
|
866
|
+
? this.#converter.makeMarkdown(cleanedHtml)
|
|
867
|
+
: "";
|
|
868
|
+
if (normalizedMarkdown !== this._value) {
|
|
869
|
+
this._value = normalizedMarkdown;
|
|
870
|
+
}
|
|
871
|
+
} else {
|
|
872
|
+
if (!this.#warnedMarkdownFallback) {
|
|
873
|
+
console.warn(
|
|
874
|
+
"pds-richtext: Showdown converter unavailable while normalizing markdown value; using internal sanitizer instead."
|
|
875
|
+
);
|
|
876
|
+
this.#warnedMarkdownFallback = true;
|
|
877
|
+
}
|
|
878
|
+
this._value = cleanedHtml
|
|
879
|
+
? this.#htmlToMinimalMarkdown(cleanedHtml)
|
|
880
|
+
: "";
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
this.#displayValue = cleanedHtml;
|
|
885
|
+
|
|
886
|
+
if (reflect) this.#reflectValueAttribute(this._value);
|
|
887
|
+
|
|
888
|
+
if (
|
|
889
|
+
this.#editorDiv &&
|
|
890
|
+
(forceDisplayRefresh || this.#editorDiv.innerHTML !== cleanedHtml)
|
|
891
|
+
) {
|
|
892
|
+
this.#editorDiv.innerHTML = cleanedHtml;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (updateForm) {
|
|
896
|
+
this.#internals.setFormValue(this._value);
|
|
897
|
+
this.#updateValidityState(cleanedHtml, this._value);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
#markdownToDisplayHtml(md) {
|
|
902
|
+
const value = (md ?? "").toString();
|
|
903
|
+
if (!value.trim()) return "";
|
|
904
|
+
if (this.#converter && typeof this.#converter.makeHtml === "function") {
|
|
905
|
+
return this.#converter.makeHtml(value);
|
|
906
|
+
}
|
|
907
|
+
return this.#markdownToBareHtml(value);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
#reflectValueAttribute(value) {
|
|
911
|
+
this.#isReflectingValue = true;
|
|
912
|
+
if (value) {
|
|
913
|
+
if (this.getAttribute("value") !== value) {
|
|
914
|
+
this.setAttribute("value", value);
|
|
915
|
+
}
|
|
916
|
+
} else if (this.hasAttribute("value")) {
|
|
917
|
+
this.removeAttribute("value");
|
|
918
|
+
}
|
|
919
|
+
this.#isReflectingValue = false;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Very small HTML -> Markdown covering the toolbar features and basic blocks
|
|
923
|
+
#htmlToMinimalMarkdown(html) {
|
|
924
|
+
const root = document.createElement("div");
|
|
925
|
+
root.innerHTML = html;
|
|
926
|
+
const walk = (n) => {
|
|
927
|
+
if (n.nodeType === 3) return n.nodeValue.replace(/\u00A0/g, " "); // nbsp -> space
|
|
928
|
+
if (n.nodeType !== 1) return "";
|
|
929
|
+
const tag = n.tagName;
|
|
930
|
+
const ch = [...n.childNodes].map(walk).join("");
|
|
931
|
+
switch (tag) {
|
|
932
|
+
case "B":
|
|
933
|
+
case "STRONG":
|
|
934
|
+
return ch ? `**${ch}**` : "";
|
|
935
|
+
case "I":
|
|
936
|
+
case "EM":
|
|
937
|
+
return ch ? `_${ch}_` : "";
|
|
938
|
+
case "S":
|
|
939
|
+
case "STRIKE":
|
|
940
|
+
return ch ? `~~${ch}~~` : "";
|
|
941
|
+
case "CODE":
|
|
942
|
+
return ch ? `\`${ch}\`` : "";
|
|
943
|
+
case "A": {
|
|
944
|
+
const href = n.getAttribute("href") || "";
|
|
945
|
+
const text = ch || href;
|
|
946
|
+
return href ? `[${text}](${href})` : text;
|
|
947
|
+
}
|
|
948
|
+
case "BR":
|
|
949
|
+
return " \n";
|
|
950
|
+
case "DIV":
|
|
951
|
+
case "P":
|
|
952
|
+
return ch ? `${ch}\n\n` : "";
|
|
953
|
+
case "UL":
|
|
954
|
+
return (
|
|
955
|
+
[...n.children].map((li) => `- ${walk(li)}`).join("\n") + "\n\n"
|
|
956
|
+
);
|
|
957
|
+
case "OL": {
|
|
958
|
+
return (
|
|
959
|
+
[...n.children].map((li, i) => `${i + 1}. ${walk(li)}`).join("\n") +
|
|
960
|
+
"\n\n"
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
case "LI":
|
|
964
|
+
return ch.replace(/\n+/g, " ");
|
|
965
|
+
case "BLOCKQUOTE":
|
|
966
|
+
return (
|
|
967
|
+
ch
|
|
968
|
+
.split(/\n/)
|
|
969
|
+
.map((l) => (l ? `> ${l}` : ">"))
|
|
970
|
+
.join("\n") + "\n\n"
|
|
971
|
+
);
|
|
972
|
+
default:
|
|
973
|
+
return ch; // drop other tags
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
let md = [...root.childNodes].map(walk).join("");
|
|
977
|
+
// collapse excessive blank lines
|
|
978
|
+
md = md.replace(/\n{3,}/g, "\n\n").trim();
|
|
979
|
+
return md;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Fallback Markdown -> bare HTML if Showdown not available
|
|
983
|
+
#markdownToBareHtml(md) {
|
|
984
|
+
// extremely small subset renderer
|
|
985
|
+
let h = md
|
|
986
|
+
.replace(/&/g, "&")
|
|
987
|
+
.replace(/</g, "<")
|
|
988
|
+
.replace(/>/g, ">");
|
|
989
|
+
h = h
|
|
990
|
+
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
|
991
|
+
.replace(/_(.*?)_/g, "<em>$1</em>")
|
|
992
|
+
.replace(/~~(.*?)~~/g, "<s>$1</s>")
|
|
993
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
994
|
+
.replace(
|
|
995
|
+
/\[(.*?)\]\((https?:[^\s)]+)\)/g,
|
|
996
|
+
'<a href="$2" target="_blank" rel="noopener">$1</a>'
|
|
997
|
+
);
|
|
998
|
+
// Lists & blockquotes (simple, single‑level)
|
|
999
|
+
h = h.replace(/(^|\n)> (.*)/g, "$1<blockquote>$2</blockquote>");
|
|
1000
|
+
// ordered list
|
|
1001
|
+
h = h.replace(
|
|
1002
|
+
/(?:^|\n)(\d+)\. (.*)(?=\n|$)/g,
|
|
1003
|
+
(m, n, txt) => `\n<ol><li>${txt}</li></ol>`
|
|
1004
|
+
);
|
|
1005
|
+
// unordered list
|
|
1006
|
+
h = h.replace(/(?:^|\n)- (.*)(?=\n|$)/g, "\n<ul><li>$1</li></ul>");
|
|
1007
|
+
// paragraphs
|
|
1008
|
+
h = h
|
|
1009
|
+
.split(/\n{2,}/)
|
|
1010
|
+
.map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`)
|
|
1011
|
+
.join("");
|
|
1012
|
+
return h;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Submit helpers
|
|
1016
|
+
#requestSubmit() {
|
|
1017
|
+
const ev = new CustomEvent("submit-request", {
|
|
1018
|
+
bubbles: true,
|
|
1019
|
+
composed: true,
|
|
1020
|
+
cancelable: true,
|
|
1021
|
+
});
|
|
1022
|
+
if (!this.dispatchEvent(ev)) return;
|
|
1023
|
+
const form = this.form || this.closest("form");
|
|
1024
|
+
if (form) {
|
|
1025
|
+
if (!this.value && this.required) {
|
|
1026
|
+
this.reportValidity();
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (typeof form.requestSubmit === "function") form.requestSubmit();
|
|
1030
|
+
else form.dispatchEvent(new Event("submit", { cancelable: true }));
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
customElements.define("pds-richtext", RichText);
|