@redvars/peacock 3.6.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/code-highlighter.js +1 -1
  2. package/dist/code-highlighter.js.map +1 -1
  3. package/dist/custom-elements-jsdocs.json +928 -96
  4. package/dist/custom-elements.json +1294 -379
  5. package/dist/{flow-designer-dZnLJOQT.js → flow-designer-DvTUrDp5.js} +3 -3
  6. package/dist/{flow-designer-dZnLJOQT.js.map → flow-designer-DvTUrDp5.js.map} +1 -1
  7. package/dist/{flow-designer-node-XMe-jlKg.js → flow-designer-node-BWrPuxAR.js} +2 -2
  8. package/dist/flow-designer-node-BWrPuxAR.js.map +1 -0
  9. package/dist/flow-designer-node.js +1 -1
  10. package/dist/flow-designer.js +2 -2
  11. package/dist/html-editor.js +27245 -87
  12. package/dist/html-editor.js.map +1 -1
  13. package/dist/index.js +3 -3
  14. package/dist/modal.js +1 -7
  15. package/dist/modal.js.map +1 -1
  16. package/dist/{navigation-rail-DyO0oAZU.js → navigation-rail-DTTkqohi.js} +763 -214
  17. package/dist/navigation-rail-DTTkqohi.js.map +1 -0
  18. package/dist/peacock-loader.js +12 -3
  19. package/dist/peacock-loader.js.map +1 -1
  20. package/dist/src/html-editor/html-editor.d.ts +44 -11
  21. package/dist/src/index.d.ts +2 -0
  22. package/dist/src/list/index.d.ts +2 -0
  23. package/dist/src/list/list-item.d.ts +35 -0
  24. package/dist/src/list/list.d.ts +28 -0
  25. package/dist/src/modal/modal.d.ts +1 -7
  26. package/dist/src/navigation-rail/navigation-rail.d.ts +3 -7
  27. package/dist/src/number-field/number-field.d.ts +2 -2
  28. package/dist/src/svg/index.d.ts +1 -0
  29. package/dist/src/svg/svg.d.ts +38 -0
  30. package/dist/src/toolbar/toolbar.d.ts +3 -3
  31. package/dist/toolbar.js +3 -3
  32. package/dist/toolbar.js.map +1 -1
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +7 -1
  35. package/readme.md +3 -3
  36. package/src/code-highlighter/code-highlighter.ts +1 -1
  37. package/src/flow-designer/flow-designer-node.ts +1 -1
  38. package/src/html-editor/html-editor.scss +44 -2
  39. package/src/html-editor/html-editor.ts +309 -94
  40. package/src/index.ts +2 -1
  41. package/src/list/index.ts +2 -0
  42. package/src/list/list-item.scss +111 -0
  43. package/src/list/list-item.ts +175 -0
  44. package/src/list/list.scss +24 -0
  45. package/src/list/list.ts +51 -0
  46. package/src/modal/modal.ts +1 -7
  47. package/src/navigation-rail/navigation-rail-item.scss +7 -38
  48. package/src/navigation-rail/navigation-rail-item.ts +1 -2
  49. package/src/navigation-rail/navigation-rail.scss +17 -21
  50. package/src/navigation-rail/navigation-rail.ts +6 -9
  51. package/src/number-field/number-field.ts +2 -2
  52. package/src/peacock-loader.ts +12 -0
  53. package/src/svg/index.ts +1 -0
  54. package/src/svg/svg.scss +91 -0
  55. package/src/svg/svg.ts +160 -0
  56. package/src/toolbar/toolbar.ts +3 -3
  57. package/dist/flow-designer-node-XMe-jlKg.js.map +0 -1
  58. package/dist/navigation-rail-DyO0oAZU.js.map +0 -1
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@redvars/peacock",
3
3
  "description": "The foundation for beautiful user interfaces",
4
4
  "license": "Apache-2.0",
5
- "version": "3.6.0",
5
+ "version": "3.6.1",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "module": "dist/index.js",
@@ -41,6 +41,12 @@
41
41
  "test:watch": "tsc && concurrently -k -r \"tsc --watch --preserveWatchOutput\" \"wtr --watch\""
42
42
  },
43
43
  "dependencies": {
44
+ "@tiptap/core": "^2.11.3",
45
+ "@tiptap/extension-mention": "^2.11.3",
46
+ "@tiptap/extension-placeholder": "^2.11.3",
47
+ "@tiptap/extension-underline": "^2.11.3",
48
+ "@tiptap/pm": "^2.11.3",
49
+ "@tiptap/starter-kit": "^2.11.3",
44
50
  "@floating-ui/dom": "^1.7.5",
45
51
  "@types/prettier": "^3.0.0",
46
52
  "d3": "^7.9.0",
package/readme.md CHANGED
@@ -44,9 +44,9 @@ Visit [https://peacock.redvars.com](https://peacock.redvars.com) to view the doc
44
44
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
45
45
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
46
46
 
47
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@redvars/peacock@3.6.0/dist/assets/styles.css"></link>
47
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@redvars/peacock@3.6.1/dist/assets/styles.css"></link>
48
48
  <script type='module'
49
- src='https://cdn.jsdelivr.net/npm/@redvars/peacock@3.6.0/dist/peacock-loader.js'></script>
49
+ src='https://cdn.jsdelivr.net/npm/@redvars/peacock@3.6.1/dist/peacock-loader.js'></script>
50
50
  </head>
51
51
 
52
52
  <wc-button>Button</wc-button>
@@ -74,7 +74,7 @@ menus, checkboxes, and radio buttons.
74
74
  | Date time picker | datetime-picker | 🔴 |
75
75
  | [Field / Form control](https://peacock.redvars.com/components/field) | wc-field | 🟡 |
76
76
  | File picker | file-picker | 🔴 |
77
- | HTML editor | html-editor | 🔴 |
77
+ | [HTML editor](https://peacock.redvars.com/components/html-editor) | wc-html-editor | 🟡 |
78
78
  | [Input](https://peacock.redvars.com/components/input) | wc-input | 🟢 |
79
79
  | Input URL | input-url | 🔴 |
80
80
  | Month picker | month-picker | 🔴 |
@@ -171,7 +171,7 @@ export class CodeHighlighter extends LitElement {
171
171
  <div class="header-title">${this.language}</div>
172
172
  <div class="header-actions">
173
173
  <wc-icon-button
174
- color="dark"
174
+ color="surface"
175
175
  variant="text"
176
176
  size="xs"
177
177
  aria-label=${locale.copyToClipboard}
@@ -143,7 +143,7 @@ export class FlowDesignerNode extends LitElement {
143
143
 
144
144
  return html`
145
145
  <div class="node-header">
146
- <wc-icon name=${icon} class="node-icon"></wc-icon>
146
+ <wc-icon provider="carbon" name=${icon} class="node-icon"></wc-icon>
147
147
  <span class="node-title">${node.label}</span>
148
148
  </div>
149
149
  `;
@@ -13,10 +13,18 @@
13
13
  // Let the field expand to fit content vertically
14
14
  --field-height: auto;
15
15
  --field-padding-block: 0;
16
+ --code-editor-height: var(--html-editor-min-height, 8rem);
16
17
 
17
18
  width: 100%;
18
19
  }
19
20
 
21
+ .mode-switcher {
22
+ display: flex;
23
+ justify-content: flex-end;
24
+ padding: var(--spacing-075, 0.375rem) var(--spacing-100, 0.5rem)
25
+ var(--spacing-050, 0.25rem);
26
+ }
27
+
20
28
  // ── Toolbar ─────────────────────────────────────────────────────────────────
21
29
 
22
30
  .html-editor-toolbar {
@@ -76,6 +84,15 @@
76
84
  cursor: not-allowed;
77
85
  }
78
86
 
87
+ &.active {
88
+ background: color-mix(
89
+ in srgb,
90
+ var(--color-primary, #6750a4) 16%,
91
+ transparent
92
+ );
93
+ color: var(--color-primary, #6750a4);
94
+ }
95
+
79
96
  wc-icon {
80
97
  pointer-events: none;
81
98
  }
@@ -104,13 +121,27 @@
104
121
  word-break: break-word;
105
122
  overflow-wrap: break-word;
106
123
 
124
+ &.hidden {
125
+ display: none;
126
+ }
127
+
128
+ .tiptap-root {
129
+ min-height: calc(var(--html-editor-min-height, 8rem) - 2rem);
130
+ }
131
+
132
+ .ProseMirror {
133
+ outline: none;
134
+ min-height: calc(var(--html-editor-min-height, 8rem) - 2rem);
135
+ }
136
+
107
137
  // Placeholder
108
- &.is-empty::before {
138
+ .ProseMirror p.is-editor-empty:first-child::before {
109
139
  content: attr(data-placeholder);
110
140
  color: var(--color-on-surface-variant, #49454f);
111
141
  opacity: 0.6;
112
142
  pointer-events: none;
113
- position: absolute;
143
+ float: left;
144
+ height: 0;
114
145
  }
115
146
 
116
147
  // Sensible defaults for user-generated rich content
@@ -131,6 +162,12 @@
131
162
  }
132
163
  }
133
164
 
165
+ .html-source {
166
+ &.hidden {
167
+ display: none;
168
+ }
169
+ }
170
+
134
171
  // ── Read-only tag ───────────────────────────────────────────────────────────
135
172
 
136
173
  .read-only-tag {
@@ -144,3 +181,8 @@
144
181
  cursor: not-allowed;
145
182
  opacity: 0.6;
146
183
  }
184
+
185
+ :host([disabled]) .html-source,
186
+ :host([readonly]) .html-source {
187
+ opacity: 0.7;
188
+ }
@@ -1,6 +1,12 @@
1
1
  import { html, nothing } from 'lit';
2
2
  import { property, query, state } from 'lit/decorators.js';
3
3
  import { classMap } from 'lit/directives/class-map.js';
4
+ import { Editor, mergeAttributes } from '@tiptap/core';
5
+ import StarterKit from '@tiptap/starter-kit';
6
+ import Underline from '@tiptap/extension-underline';
7
+ import Placeholder from '@tiptap/extension-placeholder';
8
+ import Mention from '@tiptap/extension-mention';
9
+ import { html as beautifyHtml } from 'js-beautify';
4
10
 
5
11
  import IndividualComponent from '@/IndividualComponent.js';
6
12
  import BaseInput from '../input/BaseInput.js';
@@ -13,24 +19,32 @@ import styles from './html-editor.scss';
13
19
  * @tag wc-html-editor
14
20
  * @rawTag html-editor
15
21
  *
16
- * @summary A WYSIWYG HTML editor component with a Material 3 styled toolbar.
22
+ * @summary A Tiptap-powered HTML editor with visual and source editing modes.
17
23
  * @overview
18
- * <p>The HTML Editor provides a rich-text editing experience using the browser's built-in
19
- * <code>contenteditable</code> API. It wraps the editable area in a Material 3 styled
20
- * <code>wc-field</code> and exposes a toolbar with common formatting actions.</p>
24
+ * <p>The HTML Editor provides a rich-text editing experience built on Tiptap.
25
+ * It wraps the editable area in a Material 3 styled <code>wc-field</code>,
26
+ * exposes common formatting actions, and includes a segmented switch between
27
+ * <strong>Visual</strong> and <strong>HTML</strong> source modes.</p>
21
28
  *
22
29
  * <p>Get and set the HTML content via the <code>value</code> property. The component
23
- * dispatches a <code>change</code> event whenever the content is modified.</p>
30
+ * dispatches a <code>change</code> event whenever the content is modified.
31
+ * Mention suggestions are supported through the <code>mentions</code> property,
32
+ * with optional externally managed lookup via the <code>search</code> event.</p>
24
33
  *
25
34
  * @cssprop --html-editor-min-height - Minimum height of the editable area. Defaults to 8rem.
26
35
  * @cssprop --html-editor-toolbar-background - Background color of the toolbar.
27
36
  * @cssprop --html-editor-toolbar-border-color - Border color between toolbar and editing area.
28
37
  *
29
38
  * @fires {Event} change - Fired whenever the editable content changes.
39
+ * @fires {CustomEvent} search - Fired in managed mention mode with { query, callback } detail.
30
40
  *
31
41
  * @example
32
42
  * ```html
33
- * <wc-html-editor label="Description" value="<p>Hello <strong>world</strong></p>"></wc-html-editor>
43
+ * <wc-html-editor
44
+ * label="Description"
45
+ * value="<p>Hello <strong>world</strong></p>"
46
+ * .mentions="[{ label: 'Alex', value: 'alex' }]"
47
+ * ></wc-html-editor>
34
48
  * ```
35
49
  * @tags input editor
36
50
  */
@@ -38,6 +52,10 @@ import styles from './html-editor.scss';
38
52
  export class HtmlEditor extends BaseInput {
39
53
  static styles = [styles];
40
54
 
55
+ private _editor?: Editor;
56
+
57
+ private _changeTimeout?: number;
58
+
41
59
  /** Current HTML value of the editor. */
42
60
  @property({ type: String })
43
61
  value = '';
@@ -66,64 +84,230 @@ export class HtmlEditor extends BaseInput {
66
84
  @property({ type: String, attribute: 'error-text' })
67
85
  errorText = '';
68
86
 
87
+ /** Whether toolbar controls should be displayed in visual mode. */
88
+ @property({ type: Boolean, attribute: 'show-toolbar' })
89
+ showToolbar = true;
90
+
91
+ /** Mention suggestions used by the mention extension. */
92
+ @property({ type: Array })
93
+ mentions: Array<{ label: string; value: string }> = [];
94
+
95
+ /** Mention filtering mode. */
96
+ @property({ type: String, attribute: 'mentions-search' })
97
+ mentionsSearch: 'contains' | 'managed' = 'contains';
98
+
99
+ /** Character that triggers mention suggestions. */
100
+ @property({ type: String, attribute: 'suggestion-character' })
101
+ suggestionCharacter = '@';
102
+
103
+ /** Whether to include the suggestion character in rendered mention text. */
104
+ @property({ type: Boolean, attribute: 'show-suggestion-character' })
105
+ showSuggestionCharacter = true;
106
+
107
+ /** Debounce in milliseconds for dispatching `change`. */
108
+ @property({ type: Number })
109
+ debounce = 250;
110
+
69
111
  @state() private _focused = false;
70
112
 
71
- @query('.html-editor-content')
113
+ @state() private _mode: 'visual' | 'html' = 'visual';
114
+
115
+ @query('.tiptap-root')
72
116
  private _editorEl!: HTMLDivElement;
73
117
 
74
118
  // ─── Lifecycle ─────────────────────────────────────────────────────────────
75
119
 
76
120
  protected firstUpdated() {
77
- if (this.value && this._editorEl) {
78
- this._editorEl.innerHTML = this.value;
121
+ this._initializeEditor();
122
+ }
123
+
124
+ disconnectedCallback() {
125
+ super.disconnectedCallback();
126
+ if (this._changeTimeout) {
127
+ window.clearTimeout(this._changeTimeout);
128
+ this._changeTimeout = undefined;
79
129
  }
130
+ this._destroyEditor();
80
131
  }
81
132
 
82
133
  protected updated(changed: Map<string, unknown>) {
83
- if (changed.has('value') && this._editorEl) {
84
- if (this._editorEl.innerHTML !== this.value) {
85
- this._editorEl.innerHTML = this.value ?? '';
134
+ if (changed.has('value') && this._editor) {
135
+ const editorHtml = HtmlEditor._normalizeHtml(this._editor.getHTML());
136
+ const nextHtml = HtmlEditor._normalizeHtml(this.value);
137
+ if (editorHtml !== nextHtml) {
138
+ this._editor.commands.setContent(this.value ?? '', false);
86
139
  }
87
140
  }
88
- if (changed.has('disabled') || changed.has('readonly')) {
89
- if (this._editorEl) {
90
- this._editorEl.contentEditable =
91
- this.disabled || this.readonly ? 'false' : 'true';
141
+
142
+ if ((changed.has('disabled') || changed.has('readonly')) && this._editor) {
143
+ this._editor.setEditable(!(this.disabled || this.readonly));
144
+ }
145
+
146
+ if (changed.has('placeholder') && this._editor) {
147
+ this._destroyEditor();
148
+ this._initializeEditor();
149
+ }
150
+
151
+ if (changed.has('mentions') && this._editor) {
152
+ const oldMentions = changed.get('mentions') as Array<{
153
+ label: string;
154
+ value: string;
155
+ }>;
156
+ if (oldMentions !== this.mentions) {
157
+ this._destroyEditor();
158
+ this._initializeEditor();
92
159
  }
93
160
  }
94
161
  }
95
162
 
96
163
  // ─── Private helpers ───────────────────────────────────────────────────────
97
164
 
98
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
- private _execCmd(command: string, value?: string) {
100
- if (this.disabled || this.readonly) return;
101
- this._editorEl.focus();
102
- // execCommand is deprecated but remains broadly supported for rich-text editing
103
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
- (document as any).execCommand(command, false, value ?? null);
165
+ private static _normalizeHtml(value: string) {
166
+ return beautifyHtml(value ?? '', {
167
+ wrap_line_length: 120,
168
+ });
105
169
  }
106
170
 
107
- private _handleInput() {
108
- this.value = this._editorEl.innerHTML;
109
- redispatchEvent(
110
- this,
111
- new Event('change', { bubbles: true, composed: true }),
112
- );
171
+ private _destroyEditor() {
172
+ if (!this._editor) return;
173
+ this._editorEl?.removeEventListener('click', this._focusEditorOnContainerClick);
174
+ this._editor.destroy();
175
+ this._editor = undefined;
113
176
  }
114
177
 
115
- private _handleFocus() {
116
- this._focused = true;
178
+ private _initializeEditor() {
179
+ if (!this._editorEl || this._editor) return;
180
+
181
+ this._editor = new Editor({
182
+ element: this._editorEl,
183
+ extensions: [
184
+ StarterKit,
185
+ Underline,
186
+ Placeholder.configure({
187
+ placeholder: this.placeholder,
188
+ }),
189
+ Mention.configure({
190
+ HTMLAttributes: {
191
+ class: 'mention',
192
+ },
193
+ renderHTML: ({ options, node }) => {
194
+ const item = this._getMentionItem(node.attrs.id);
195
+ return [
196
+ 'a',
197
+ mergeAttributes({ contenteditable: false }, options.HTMLAttributes),
198
+ `${this.showSuggestionCharacter ? options.suggestion.char : ''}${
199
+ item ? item.label : node.attrs.id
200
+ }`,
201
+ ];
202
+ },
203
+ suggestion: {
204
+ allowSpaces: true,
205
+ char: this.suggestionCharacter,
206
+ items: async ({ query: mentionQuery }) => {
207
+ if (this.mentionsSearch === 'managed') {
208
+ return this._requestManagedMentions(mentionQuery);
209
+ }
210
+
211
+ return this.mentions
212
+ .filter(item =>
213
+ item.label.toLowerCase().startsWith(mentionQuery.toLowerCase()),
214
+ )
215
+ .map(item => item.value)
216
+ .slice(0, 5);
217
+ },
218
+ },
219
+ }),
220
+ ],
221
+ content: this.value,
222
+ editable: !(this.disabled || this.readonly),
223
+ onFocus: () => {
224
+ this._focused = true;
225
+ },
226
+ onBlur: () => {
227
+ this._focused = false;
228
+ },
229
+ onUpdate: () => {
230
+ if (!this._editor) return;
231
+
232
+ const nextHtml = HtmlEditor._normalizeHtml(this._editor.getHTML());
233
+ if (nextHtml !== this.value) {
234
+ this.value = nextHtml;
235
+ }
236
+
237
+ this._dispatchDebouncedChange();
238
+ },
239
+ });
240
+
241
+ this._editorEl.addEventListener('click', this._focusEditorOnContainerClick);
117
242
  }
118
243
 
119
- private _handleBlur() {
120
- this._focused = false;
244
+ private _focusEditorOnContainerClick = (event: Event) => {
245
+ if (!this._editor) return;
246
+ if (event.target === this._editorEl) {
247
+ this._editor.commands.focus('end');
248
+ }
249
+ };
250
+
251
+ private _dispatchDebouncedChange() {
252
+ if (this._changeTimeout) {
253
+ window.clearTimeout(this._changeTimeout);
254
+ }
255
+
256
+ this._changeTimeout = window.setTimeout(() => {
257
+ redispatchEvent(this, new Event('change', { bubbles: true, composed: true }));
258
+ }, this.debounce);
259
+ }
260
+
261
+ private _requestManagedMentions(mentionQuery: string): Promise<string[]> {
262
+ return new Promise(resolve => {
263
+ this.dispatchEvent(
264
+ new CustomEvent('search', {
265
+ detail: {
266
+ query: mentionQuery,
267
+ callback: (mentions: Array<{ label: string; value: string }>) => {
268
+ this.mentions = mentions;
269
+ resolve(this.mentions.map(item => item.value));
270
+ },
271
+ },
272
+ bubbles: true,
273
+ composed: true,
274
+ }),
275
+ );
276
+ });
277
+ }
278
+
279
+ private _getMentionItem(value: string) {
280
+ return this.mentions.find(item => item.value === value);
281
+ }
282
+
283
+ private _execCommand(command: () => void) {
284
+ if (this.disabled || this.readonly || !this._editor) return;
285
+ command();
286
+ this._editor.commands.focus();
287
+ }
288
+
289
+ private _switchMode(event: CustomEvent<{ value: string }>) {
290
+ event.stopPropagation();
291
+ const nextMode = event.detail?.value === 'html' ? 'html' : 'visual';
292
+
293
+ if (nextMode === this._mode) return;
294
+
295
+ if (nextMode === 'html' && this._editor) {
296
+ this.value = HtmlEditor._normalizeHtml(this._editor.getHTML());
297
+ }
298
+
299
+ this._mode = nextMode;
121
300
  }
122
301
 
123
- private _insertLink() {
124
- // eslint-disable-next-line no-alert
125
- const url = window.prompt('Enter URL:', 'https://');
126
- if (url) this._execCmd('createLink', url);
302
+ private _handleSourceChange(event: Event) {
303
+ event.stopPropagation();
304
+ const target = event.currentTarget as { value?: string };
305
+ const nextValue = target.value ?? '';
306
+
307
+ if (nextValue === this.value) return;
308
+
309
+ this.value = nextValue;
310
+ this._dispatchDebouncedChange();
127
311
  }
128
312
 
129
313
  // ─── Toolbar button ────────────────────────────────────────────────────────
@@ -131,19 +315,22 @@ export class HtmlEditor extends BaseInput {
131
315
  private _toolbarButton(
132
316
  icon: string,
133
317
  title: string,
134
- command: string,
135
- value?: string,
318
+ action: () => void,
319
+ active = false,
136
320
  ) {
137
321
  return html`
138
322
  <button
139
- class="toolbar-btn"
323
+ class=${classMap({
324
+ 'toolbar-btn': true,
325
+ active,
326
+ })}
140
327
  title=${title}
141
328
  aria-label=${title}
142
329
  ?disabled=${this.disabled || this.readonly}
143
330
  @mousedown=${(e: Event) => e.preventDefault()}
144
331
  @click=${(e: Event) => {
145
332
  e.preventDefault();
146
- this._execCmd(command, value);
333
+ this._execCommand(action);
147
334
  }}
148
335
  >
149
336
  <wc-icon name=${icon} size="sm"></wc-icon>
@@ -154,33 +341,52 @@ export class HtmlEditor extends BaseInput {
154
341
  // ─── Toolbar ───────────────────────────────────────────────────────────────
155
342
 
156
343
  private _renderToolbar() {
344
+ if (!this._editor || !this.showToolbar || this._mode !== 'visual') {
345
+ return nothing;
346
+ }
347
+
157
348
  return html`
158
349
  <div
159
350
  class="html-editor-toolbar"
160
351
  role="toolbar"
161
352
  aria-label="Formatting toolbar"
162
353
  >
163
- ${this._toolbarButton('format_bold', 'Bold', 'bold')}
164
- ${this._toolbarButton('format_italic', 'Italic', 'italic')}
165
- ${this._toolbarButton('format_underlined', 'Underline', 'underline')}
166
354
  ${this._toolbarButton(
167
- 'format_strikethrough',
168
- 'Strikethrough',
169
- 'strikeThrough',
355
+ 'undo',
356
+ 'Undo',
357
+ () => this._editor?.commands.undo(),
358
+ )}
359
+ ${this._toolbarButton(
360
+ 'redo',
361
+ 'Redo',
362
+ () => this._editor?.commands.redo(),
170
363
  )}
171
364
 
172
365
  <span class="toolbar-divider"></span>
173
366
 
174
- ${this._toolbarButton('format_align_left', 'Align left', 'justifyLeft')}
175
367
  ${this._toolbarButton(
176
- 'format_align_center',
177
- 'Align center',
178
- 'justifyCenter',
368
+ 'format_bold',
369
+ 'Bold',
370
+ () => this._editor?.chain().focus().toggleBold().run(),
371
+ this._editor.isActive('bold'),
179
372
  )}
180
373
  ${this._toolbarButton(
181
- 'format_align_right',
182
- 'Align right',
183
- 'justifyRight',
374
+ 'format_italic',
375
+ 'Italic',
376
+ () => this._editor?.chain().focus().toggleItalic().run(),
377
+ this._editor.isActive('italic'),
378
+ )}
379
+ ${this._toolbarButton(
380
+ 'format_underlined',
381
+ 'Underline',
382
+ () => this._editor?.chain().focus().toggleUnderline().run(),
383
+ this._editor.isActive('underline'),
384
+ )}
385
+ ${this._toolbarButton(
386
+ 'format_strikethrough',
387
+ 'Strikethrough',
388
+ () => this._editor?.chain().focus().toggleStrike().run(),
389
+ this._editor.isActive('strike'),
184
390
  )}
185
391
 
186
392
  <span class="toolbar-divider"></span>
@@ -188,44 +394,29 @@ export class HtmlEditor extends BaseInput {
188
394
  ${this._toolbarButton(
189
395
  'format_list_bulleted',
190
396
  'Unordered list',
191
- 'insertUnorderedList',
397
+ () => this._editor?.chain().focus().toggleBulletList().run(),
398
+ this._editor.isActive('bulletList'),
192
399
  )}
193
400
  ${this._toolbarButton(
194
401
  'format_list_numbered',
195
402
  'Ordered list',
196
- 'insertOrderedList',
403
+ () => this._editor?.chain().focus().toggleOrderedList().run(),
404
+ this._editor.isActive('orderedList'),
197
405
  )}
198
-
199
- <span class="toolbar-divider"></span>
200
-
201
- ${this._toolbarButton(
202
- 'format_indent_increase',
203
- 'Indent',
204
- 'indent',
205
- )}
206
- ${this._toolbarButton('format_indent_decrease', 'Outdent', 'outdent')}
207
-
208
- <span class="toolbar-divider"></span>
209
-
210
- <button
211
- class="toolbar-btn"
212
- title="Insert link"
213
- aria-label="Insert link"
214
- ?disabled=${this.disabled || this.readonly}
215
- @mousedown=${(e: Event) => e.preventDefault()}
216
- @click=${() => this._insertLink()}
217
- >
218
- <wc-icon name="link" size="sm"></wc-icon>
219
- </button>
220
-
221
- <span class="toolbar-divider"></span>
222
-
223
- ${this._toolbarButton('undo', 'Undo', 'undo')}
224
- ${this._toolbarButton('redo', 'Redo', 'redo')}
225
406
  </div>
226
407
  `;
227
408
  }
228
409
 
410
+ private _renderReadonlyTag() {
411
+ if (this.disabled) {
412
+ return html`<wc-tag class="read-only-tag" color="red">Disabled</wc-tag>`;
413
+ }
414
+ if (this.readonly) {
415
+ return html`<wc-tag class="read-only-tag" color="red">Read Only</wc-tag>`;
416
+ }
417
+ return nothing;
418
+ }
419
+
229
420
  // ─── Render ────────────────────────────────────────────────────────────────
230
421
 
231
422
  render() {
@@ -251,25 +442,49 @@ export class HtmlEditor extends BaseInput {
251
442
  readonly: this.readonly,
252
443
  })}
253
444
  >
445
+ <div class="mode-switcher">
446
+ <wc-segmented-button-group @change=${this._switchMode}>
447
+ <wc-segmented-button
448
+ value="visual"
449
+ ?selected=${this._mode === 'visual'}
450
+ ?disabled=${this.disabled}
451
+ >
452
+ Visual
453
+ </wc-segmented-button>
454
+ <wc-segmented-button
455
+ value="html"
456
+ ?selected=${this._mode === 'html'}
457
+ ?disabled=${this.disabled}
458
+ >
459
+ HTML
460
+ </wc-segmented-button>
461
+ </wc-segmented-button-group>
462
+ </div>
463
+
254
464
  ${this._renderToolbar()}
255
465
 
256
466
  <div
257
467
  class=${classMap({
258
468
  'html-editor-content': true,
259
469
  'is-empty': isEmpty,
470
+ hidden: this._mode !== 'visual',
260
471
  })}
261
- contenteditable=${this.disabled || this.readonly ? 'false' : 'true'}
262
472
  data-placeholder=${this.placeholder}
263
- @input=${this._handleInput}
264
- @focus=${this._handleFocus}
265
- @blur=${this._handleBlur}
266
- ></div>
267
-
268
- ${this.disabled
269
- ? html`<wc-tag class="read-only-tag" color="red">Disabled</wc-tag>`
270
- : this.readonly
271
- ? html`<wc-tag class="read-only-tag" color="red">Read Only</wc-tag>`
272
- : nothing}
473
+ >
474
+ <div class="tiptap-root"></div>
475
+ </div>
476
+
477
+ <div class=${classMap({ 'html-source': true, hidden: this._mode !== 'html' })}>
478
+ <wc-code-editor
479
+ language="html"
480
+ .value=${this.value}
481
+ ?readonly=${this.readonly}
482
+ ?disabled=${this.disabled}
483
+ @change=${this._handleSourceChange}
484
+ ></wc-code-editor>
485
+ </div>
486
+
487
+ ${this._renderReadonlyTag()}
273
488
  </wc-field>
274
489
  `;
275
490
  }