@redvars/peacock 3.8.5 → 3.8.6

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 (44) hide show
  1. package/dist/button.js +4 -0
  2. package/dist/button.js.map +1 -1
  3. package/dist/custom-elements-jsdocs.json +185 -15
  4. package/dist/custom-elements.json +886 -177
  5. package/dist/floating-controller-CZYwnRVu.js +185 -0
  6. package/dist/floating-controller-CZYwnRVu.js.map +1 -0
  7. package/dist/{floating-controller-CnUZnOhK.js → floating-ui.dom-oQieRCpS.js} +2 -183
  8. package/dist/floating-ui.dom-oQieRCpS.js.map +1 -0
  9. package/dist/html-editor.js +3 -21091
  10. package/dist/html-editor.js.map +1 -1
  11. package/dist/index-DcbiqHpf.js +21091 -0
  12. package/dist/index-DcbiqHpf.js.map +1 -0
  13. package/dist/index.js +5 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/loader.js +7 -2
  16. package/dist/loader.js.map +1 -1
  17. package/dist/markdown-editor.js +4439 -0
  18. package/dist/markdown-editor.js.map +1 -0
  19. package/dist/menu.js +2 -1
  20. package/dist/menu.js.map +1 -1
  21. package/dist/popover.js +2 -1
  22. package/dist/popover.js.map +1 -1
  23. package/dist/{select-D85kpjUt.js → select-CnnPbvpQ.js} +68 -71
  24. package/dist/select-CnnPbvpQ.js.map +1 -0
  25. package/dist/src/index.d.ts +1 -0
  26. package/dist/src/input/input.d.ts +5 -5
  27. package/dist/src/markdown-editor/index.d.ts +2 -0
  28. package/dist/src/markdown-editor/markdown-editor.d.ts +116 -0
  29. package/dist/sub-menu.js +2 -1
  30. package/dist/sub-menu.js.map +1 -1
  31. package/dist/tooltip.js +2 -1
  32. package/dist/tooltip.js.map +1 -1
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +5 -1
  35. package/readme.md +2 -2
  36. package/src/button/button/button-sizes.scss +4 -0
  37. package/src/index.ts +1 -0
  38. package/src/input/input.ts +85 -82
  39. package/src/loader.ts +4 -0
  40. package/src/markdown-editor/index.ts +3 -0
  41. package/src/markdown-editor/markdown-editor.scss +378 -0
  42. package/src/markdown-editor/markdown-editor.ts +994 -0
  43. package/dist/floating-controller-CnUZnOhK.js.map +0 -1
  44. package/dist/select-D85kpjUt.js.map +0 -1
@@ -0,0 +1,994 @@
1
+ import { html, nothing } from 'lit';
2
+ import { property, query, state } from 'lit/decorators.js';
3
+ import { classMap } from 'lit/directives/class-map.js';
4
+ import { Editor, Extension, Range } from '@tiptap/core';
5
+ import { Plugin, NodeSelection } from '@tiptap/pm/state';
6
+ import { DOMSerializer, Fragment, Slice } from '@tiptap/pm/model';
7
+ import StarterKit from '@tiptap/starter-kit';
8
+ import Underline from '@tiptap/extension-underline';
9
+ import Placeholder from '@tiptap/extension-placeholder';
10
+ import Suggestion from '@tiptap/suggestion';
11
+ import { computePosition, flip, offset, shift } from '@floating-ui/dom';
12
+ import { marked } from 'marked';
13
+ import TurndownService from 'turndown';
14
+
15
+ import IndividualComponent from '@/IndividualComponent.js';
16
+ import BaseInput from '../input/BaseInput.js';
17
+ import { redispatchEvent } from '../__internal/utils/dispatch-event-utils.js';
18
+
19
+ import styles from './markdown-editor.scss';
20
+
21
+ interface SlashItem {
22
+ title: string;
23
+ hint: string;
24
+ icon: string;
25
+ command: (args: { editor: Editor; range: Range }) => void;
26
+ }
27
+
28
+ const SLASH_ITEMS: SlashItem[] = [
29
+ {
30
+ title: 'Heading 1',
31
+ hint: '#',
32
+ icon: 'title',
33
+ command: ({ editor, range }) =>
34
+ editor
35
+ .chain()
36
+ .focus()
37
+ .deleteRange(range)
38
+ .setNode('heading', { level: 1 })
39
+ .run(),
40
+ },
41
+ {
42
+ title: 'Heading 2',
43
+ hint: '##',
44
+ icon: 'title',
45
+ command: ({ editor, range }) =>
46
+ editor
47
+ .chain()
48
+ .focus()
49
+ .deleteRange(range)
50
+ .setNode('heading', { level: 2 })
51
+ .run(),
52
+ },
53
+ {
54
+ title: 'Heading 3',
55
+ hint: '###',
56
+ icon: 'title',
57
+ command: ({ editor, range }) =>
58
+ editor
59
+ .chain()
60
+ .focus()
61
+ .deleteRange(range)
62
+ .setNode('heading', { level: 3 })
63
+ .run(),
64
+ },
65
+ {
66
+ title: 'Bulleted list',
67
+ hint: '-',
68
+ icon: 'format_list_bulleted',
69
+ command: ({ editor, range }) =>
70
+ editor.chain().focus().deleteRange(range).toggleBulletList().run(),
71
+ },
72
+ {
73
+ title: 'Numbered list',
74
+ hint: '1.',
75
+ icon: 'format_list_numbered',
76
+ command: ({ editor, range }) =>
77
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
78
+ },
79
+ {
80
+ title: 'Quote',
81
+ hint: '>',
82
+ icon: 'format_quote',
83
+ command: ({ editor, range }) =>
84
+ editor.chain().focus().deleteRange(range).toggleBlockquote().run(),
85
+ },
86
+ {
87
+ title: 'Code block',
88
+ hint: '```',
89
+ icon: 'code',
90
+ command: ({ editor, range }) =>
91
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
92
+ },
93
+ {
94
+ title: 'Divider',
95
+ hint: '---',
96
+ icon: 'horizontal_rule',
97
+ command: ({ editor, range }) =>
98
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
99
+ },
100
+ ];
101
+
102
+ /**
103
+ * @label Markdown Editor
104
+ * @tag wc-markdown-editor
105
+ * @rawTag markdown-editor
106
+ *
107
+ * @summary A Tiptap-powered Notion-like Markdown editor with a bottom toggle
108
+ * between rendered and raw markdown views.
109
+ * @overview
110
+ * <p>The Markdown Editor provides a block-based, Notion-like writing surface
111
+ * built on Tiptap. Type <code>/</code> to open a slash menu for inserting
112
+ * headings, lists, quotes, code blocks, and dividers. Select text to reveal
113
+ * a floating bubble toolbar with inline formatting actions.</p>
114
+ *
115
+ * <p>The canonical <code>value</code> is Markdown. Use the bottom segmented
116
+ * switch to toggle between the rendered editor and the raw markdown source.
117
+ * The component dispatches a <code>change</code> event whenever content
118
+ * changes.</p>
119
+ *
120
+ * @cssprop --markdown-editor-min-height - Minimum height of the editable area. Defaults to 12rem.
121
+ * @cssprop --markdown-editor-bubble-background - Background color of the selection bubble menu.
122
+ * @cssprop --markdown-editor-slash-background - Background color of the slash command menu.
123
+ *
124
+ * @fires {Event} change - Fired whenever the markdown content changes.
125
+ *
126
+ * @example
127
+ * ```html
128
+ * <wc-markdown-editor
129
+ * label="Notes"
130
+ * value="# Hello\n\nType a slash on a new line to insert blocks."
131
+ * ></wc-markdown-editor>
132
+ * ```
133
+ * @tags input editor
134
+ */
135
+ @IndividualComponent
136
+ export class MarkdownEditor extends BaseInput {
137
+ // ── Static ────────────────────────────────────────────────────────────────
138
+
139
+ static styles = [styles];
140
+
141
+ // ── Properties ────────────────────────────────────────────────────────────
142
+
143
+ /** Current Markdown value of the editor. */
144
+ @property({ type: String })
145
+ value = '';
146
+
147
+ /** Label displayed above the editor. */
148
+ @property({ type: String })
149
+ label = '';
150
+
151
+ /** Placeholder text shown when the editor is empty. */
152
+ @property({ type: String })
153
+ placeholder = "Type '/' for commands…";
154
+
155
+ /** Visual style of the wrapping field. */
156
+ @property({ type: String })
157
+ variant: 'filled' | 'outlined' | 'default' = 'default';
158
+
159
+ /** Helper text displayed below the editor. */
160
+ @property({ type: String, attribute: 'helper-text' })
161
+ helperText = '';
162
+
163
+ /** Whether to show an error state. */
164
+ @property({ type: Boolean })
165
+ error = false;
166
+
167
+ /** Error message text. */
168
+ @property({ type: String, attribute: 'error-text' })
169
+ errorText = '';
170
+
171
+ /** Debounce in milliseconds for dispatching `change`. */
172
+ @property({ type: Number })
173
+ debounce = 250;
174
+
175
+ // ── State ─────────────────────────────────────────────────────────────────
176
+
177
+ /** Whether the editor currently has focus. */
178
+ @state() private _focused = false;
179
+
180
+ /** Current view mode: rendered editor or raw markdown. */
181
+ @state() private _mode: 'editor' | 'markdown' = 'editor';
182
+
183
+ /** Whether the bubble toolbar is visible. */
184
+ @state() private _bubbleOpen = false;
185
+
186
+ /** Whether the slash command menu is visible. */
187
+ @state() private _slashOpen = false;
188
+
189
+ /** Filtered slash menu items. */
190
+ @state() private _slashItems: SlashItem[] = [];
191
+
192
+ /** Currently highlighted slash menu index. */
193
+ @state() private _slashIndex = 0;
194
+
195
+ /** Whether the block handle panel is visible. */
196
+ @state() private _blockHandleVisible = false;
197
+
198
+ // ── Queries ───────────────────────────────────────────────────────────────
199
+
200
+ @query('.tiptap-root')
201
+ private _editorEl!: HTMLDivElement;
202
+
203
+ @query('.bubble-menu')
204
+ private _bubbleEl?: HTMLDivElement;
205
+
206
+ @query('.slash-menu')
207
+ private _slashEl?: HTMLDivElement;
208
+
209
+ @query('.block-handle')
210
+ private _blockHandleEl?: HTMLDivElement;
211
+
212
+ // ── Private fields ────────────────────────────────────────────────────────
213
+
214
+ /** TipTap editor instance. */
215
+ private _editor?: Editor;
216
+
217
+ /** Debounce timer for the `change` event. */
218
+ private _changeTimeout?: number;
219
+
220
+ /** Most recent slash-suggestion bounding rect (for popup placement). */
221
+ private _slashClientRect: (() => DOMRect | null) | null = null;
222
+
223
+ /** Slash-suggestion command callback to invoke on selection. */
224
+ private _slashCommand?: (item: SlashItem) => void;
225
+
226
+ /** ProseMirror doc position (inside-block) of the block under the handle. */
227
+ private _blockHandleNodePos = -1;
228
+
229
+ /** Timer id for delayed hide of the block handle. */
230
+ private _blockHandleHideTimeout?: number;
231
+
232
+ /** Reusable Turndown serializer instance. */
233
+ private _turndown = new TurndownService({
234
+ headingStyle: 'atx',
235
+ codeBlockStyle: 'fenced',
236
+ bulletListMarker: '-',
237
+ emDelimiter: '_',
238
+ });
239
+
240
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
241
+
242
+ protected firstUpdated() {
243
+ this._initializeEditor();
244
+ }
245
+
246
+ disconnectedCallback() {
247
+ super.disconnectedCallback();
248
+ if (this._changeTimeout) {
249
+ window.clearTimeout(this._changeTimeout);
250
+ this._changeTimeout = undefined;
251
+ }
252
+ if (this._blockHandleHideTimeout) {
253
+ window.clearTimeout(this._blockHandleHideTimeout);
254
+ this._blockHandleHideTimeout = undefined;
255
+ }
256
+ this._destroyEditor();
257
+ }
258
+
259
+ protected updated(changed: Map<string, unknown>) {
260
+ if (changed.has('value') && this._editor) {
261
+ const currentMd = MarkdownEditor._normalizeMd(
262
+ this._htmlToMarkdown(this._editor.getHTML()),
263
+ );
264
+ const nextMd = MarkdownEditor._normalizeMd(this.value);
265
+ if (currentMd !== nextMd) {
266
+ this._editor.commands.setContent(
267
+ this._markdownToHtml(this.value),
268
+ false,
269
+ );
270
+ }
271
+ }
272
+
273
+ if ((changed.has('disabled') || changed.has('readonly')) && this._editor) {
274
+ this._editor.setEditable(!(this.disabled || this.readonly));
275
+ }
276
+
277
+ if (changed.has('placeholder') && this._editor) {
278
+ this._destroyEditor();
279
+ this._initializeEditor();
280
+ }
281
+ }
282
+
283
+ // ── Private methods: conversion ───────────────────────────────────────────
284
+
285
+ private static _normalizeMd(value: string) {
286
+ return (value ?? '').replace(/\r\n/g, '\n').replace(/\s+$/g, '');
287
+ }
288
+
289
+ private _markdownToHtml(md: string): string {
290
+ return marked.parse(md ?? '', {
291
+ gfm: true,
292
+ breaks: true,
293
+ async: false,
294
+ }) as string;
295
+ }
296
+
297
+ private _htmlToMarkdown(htmlStr: string): string {
298
+ return this._turndown.turndown(htmlStr ?? '');
299
+ }
300
+
301
+ // ── Private methods: TipTap setup ─────────────────────────────────────────
302
+
303
+ private _destroyEditor() {
304
+ if (!this._editor) return;
305
+ this._editorEl?.removeEventListener(
306
+ 'click',
307
+ this._focusEditorOnContainerClick,
308
+ );
309
+ this._editor.destroy();
310
+ this._editor = undefined;
311
+ }
312
+
313
+ private _initializeEditor() {
314
+ if (!this._editorEl || this._editor) return;
315
+
316
+ this._editor = new Editor({
317
+ element: this._editorEl,
318
+ extensions: [
319
+ StarterKit,
320
+ Underline,
321
+ Placeholder.configure({
322
+ placeholder: this.placeholder,
323
+ }),
324
+ this._buildSlashExtension(),
325
+ this._buildBlockHandleExtension(),
326
+ ],
327
+ content: this._markdownToHtml(this.value),
328
+ editable: !(this.disabled || this.readonly),
329
+ onFocus: () => {
330
+ this._focused = true;
331
+ },
332
+ onBlur: () => {
333
+ this._focused = false;
334
+ window.setTimeout(() => {
335
+ this._bubbleOpen = false;
336
+ }, 150);
337
+ },
338
+ onUpdate: () => {
339
+ if (!this._editor) return;
340
+
341
+ const nextMd = MarkdownEditor._normalizeMd(
342
+ this._htmlToMarkdown(this._editor.getHTML()),
343
+ );
344
+ if (nextMd !== this.value) {
345
+ this.value = nextMd;
346
+ }
347
+
348
+ this._dispatchDebouncedChange();
349
+ },
350
+ onSelectionUpdate: () => {
351
+ this._updateBubbleMenu();
352
+ },
353
+ });
354
+
355
+ this._editorEl.addEventListener('click', this._focusEditorOnContainerClick);
356
+ }
357
+
358
+ private _focusEditorOnContainerClick = (event: Event) => {
359
+ if (!this._editor) return;
360
+ if (event.target === this._editorEl) {
361
+ this._editor.commands.focus('end');
362
+ }
363
+ };
364
+
365
+ // ── Private methods: slash command ────────────────────────────────────────
366
+
367
+ private _buildSlashExtension() {
368
+ const host = this;
369
+
370
+ return Extension.create({
371
+ name: 'slashCommand',
372
+
373
+ addProseMirrorPlugins() {
374
+ return [
375
+ Suggestion<SlashItem>({
376
+ editor: this.editor,
377
+ char: '/',
378
+ startOfLine: false,
379
+ allowSpaces: false,
380
+ items: ({ query: slashQuery }) => {
381
+ const q = slashQuery.toLowerCase();
382
+ return SLASH_ITEMS.filter(item =>
383
+ item.title.toLowerCase().includes(q),
384
+ ).slice(0, 10);
385
+ },
386
+ command: ({ editor, range, props }) => {
387
+ props.command({ editor, range });
388
+ },
389
+ render: () => ({
390
+ onStart: props => {
391
+ host._slashItems = props.items;
392
+ host._slashIndex = 0;
393
+ host._slashClientRect =
394
+ props.clientRect as () => DOMRect | null;
395
+ host._slashCommand = item => {
396
+ props.command({
397
+ ...item,
398
+ command: item.command,
399
+ } as unknown as SlashItem);
400
+ };
401
+ host._slashOpen = props.items.length > 0;
402
+ host._positionSlashMenu();
403
+ },
404
+ onUpdate: props => {
405
+ host._slashItems = props.items;
406
+ host._slashIndex = 0;
407
+ host._slashClientRect =
408
+ props.clientRect as () => DOMRect | null;
409
+ host._slashOpen = props.items.length > 0;
410
+ host._positionSlashMenu();
411
+ },
412
+ onKeyDown: ({ event }) => {
413
+ if (!host._slashOpen) return false;
414
+
415
+ if (event.key === 'ArrowDown') {
416
+ host._slashIndex =
417
+ (host._slashIndex + 1) % host._slashItems.length;
418
+ return true;
419
+ }
420
+ if (event.key === 'ArrowUp') {
421
+ host._slashIndex =
422
+ (host._slashIndex - 1 + host._slashItems.length) %
423
+ host._slashItems.length;
424
+ return true;
425
+ }
426
+ if (event.key === 'Enter') {
427
+ const item = host._slashItems[host._slashIndex];
428
+ if (item && host._slashCommand) {
429
+ host._slashCommand(item);
430
+ }
431
+ return true;
432
+ }
433
+ if (event.key === 'Escape') {
434
+ host._slashOpen = false;
435
+ return true;
436
+ }
437
+ return false;
438
+ },
439
+ onExit: () => {
440
+ host._slashOpen = false;
441
+ host._slashClientRect = null;
442
+ host._slashCommand = undefined;
443
+ },
444
+ }),
445
+ }),
446
+ ];
447
+ },
448
+ });
449
+ }
450
+
451
+ private async _positionSlashMenu() {
452
+ await this.updateComplete;
453
+
454
+ if (!this._slashEl || !this._slashClientRect) return;
455
+ const rect = this._slashClientRect();
456
+ if (!rect) return;
457
+
458
+ const virtual = {
459
+ getBoundingClientRect: () => rect,
460
+ };
461
+
462
+ const { x, y } = await computePosition(virtual, this._slashEl, {
463
+ placement: 'bottom-start',
464
+ strategy: 'fixed',
465
+ middleware: [offset(6), flip(), shift({ padding: 8 })],
466
+ });
467
+
468
+ this._slashEl.style.left = `${x}px`;
469
+ this._slashEl.style.top = `${y}px`;
470
+ }
471
+
472
+ private _onSlashItemClick = (event: MouseEvent) => {
473
+ event.preventDefault();
474
+ const target = (event.currentTarget as HTMLElement) ?? null;
475
+ if (!target) return;
476
+ const index = Number(target.dataset.index ?? '-1');
477
+ const item = this._slashItems[index];
478
+ if (item && this._slashCommand) {
479
+ this._slashCommand(item);
480
+ }
481
+ };
482
+
483
+ // ── Private methods: block handle ─────────────────────────────────────────
484
+
485
+ private _buildBlockHandleExtension() {
486
+ const host = this;
487
+
488
+ return Extension.create({
489
+ name: 'blockHandle',
490
+
491
+ addProseMirrorPlugins() {
492
+ return [
493
+ new Plugin({
494
+ props: {
495
+ handleDOMEvents: {
496
+ mousemove: (view, event) => {
497
+ if (host.disabled || host.readonly || host._mode !== 'editor') {
498
+ host._blockHandleVisible = false;
499
+ return false;
500
+ }
501
+ const coords = view.posAtCoords({
502
+ left: event.clientX,
503
+ top: event.clientY,
504
+ });
505
+ if (!coords) {
506
+ host._blockHandleVisible = false;
507
+ return false;
508
+ }
509
+ try {
510
+ // Store the inside-block position (not before(1)) so that
511
+ // domAtPos() resolves to the block element, not its parent.
512
+ host._blockHandleNodePos = coords.pos;
513
+ window.clearTimeout(host._blockHandleHideTimeout);
514
+ host._blockHandleHideTimeout = undefined;
515
+ host._blockHandleVisible = true;
516
+ host._positionBlockHandle();
517
+ } catch {
518
+ host._blockHandleVisible = false;
519
+ }
520
+ return false;
521
+ },
522
+ mouseleave: (_view, event) => {
523
+ const rel = (event as MouseEvent).relatedTarget as Node | null;
524
+ if (
525
+ host._blockHandleEl?.contains(rel) ||
526
+ rel === host._blockHandleEl
527
+ )
528
+ return false;
529
+ // Delay so the mouse can travel to the handle without it vanishing.
530
+ window.clearTimeout(host._blockHandleHideTimeout);
531
+ host._blockHandleHideTimeout = window.setTimeout(() => {
532
+ host._blockHandleVisible = false;
533
+ }, 400);
534
+ return false;
535
+ },
536
+ },
537
+ },
538
+ }),
539
+ ];
540
+ },
541
+ });
542
+ }
543
+
544
+ private _positionBlockHandle() {
545
+ if (!this._editor || !this._blockHandleEl) return;
546
+ const { view } = this._editor;
547
+ const pos = this._blockHandleNodePos;
548
+ if (pos < 0) return;
549
+ try {
550
+ const domRef = view.domAtPos(pos);
551
+ let el: Element | null =
552
+ domRef.node.nodeType === Node.TEXT_NODE
553
+ ? (domRef.node as Text).parentElement
554
+ : (domRef.node as Element);
555
+ while (el && el !== view.dom) {
556
+ const d = getComputedStyle(el).display;
557
+ if (d === 'block' || d === 'list-item') break;
558
+ el = el.parentElement;
559
+ }
560
+ if (!el || el === view.dom) {
561
+ this._blockHandleVisible = false;
562
+ return;
563
+ }
564
+ const blockRect = el.getBoundingClientRect();
565
+ // Use .markdown-editor-content (grandparent of .ProseMirror) as the
566
+ // left anchor so the handle sits inside the left padding area and is
567
+ // never clipped by a sidebar or page margin.
568
+ const contentEl = view.dom.parentElement?.parentElement;
569
+ const contentLeft = contentEl
570
+ ? contentEl.getBoundingClientRect().left
571
+ : view.dom.getBoundingClientRect().left - 16;
572
+ const handleH = this._blockHandleEl.offsetHeight || 60;
573
+ const top = blockRect.top + blockRect.height / 2 - handleH / 2;
574
+ const left = contentLeft + 4;
575
+ this._blockHandleEl.style.top = `${top}px`;
576
+ this._blockHandleEl.style.left = `${left}px`;
577
+ } catch {
578
+ this._blockHandleVisible = false;
579
+ }
580
+ }
581
+
582
+ private _onBlockHandleMouseEnter = () => {
583
+ window.clearTimeout(this._blockHandleHideTimeout);
584
+ this._blockHandleHideTimeout = undefined;
585
+ };
586
+
587
+ private _onBlockHandleMouseLeave = (event: MouseEvent) => {
588
+ const rel = event.relatedTarget as Node | null;
589
+ if (this._editorEl?.contains(rel)) return;
590
+ window.clearTimeout(this._blockHandleHideTimeout);
591
+ this._blockHandleHideTimeout = window.setTimeout(() => {
592
+ this._blockHandleVisible = false;
593
+ }, 150);
594
+ };
595
+
596
+ private _onInsertBlockClick = () => {
597
+ if (!this._editor || this.disabled || this.readonly) return;
598
+ const { view } = this._editor;
599
+ const { state } = view;
600
+ try {
601
+ const $pos = state.doc.resolve(this._blockHandleNodePos);
602
+ const nodeEnd = $pos.after(1);
603
+ const safePos = Math.min(nodeEnd - 1, state.doc.content.size - 1);
604
+ const coords = view.coordsAtPos(safePos);
605
+ const rect = new DOMRect(coords.left, coords.bottom, 0, 0);
606
+
607
+ this._slashItems = [...SLASH_ITEMS];
608
+ this._slashIndex = 0;
609
+ this._slashClientRect = () => rect;
610
+ this._slashCommand = item => {
611
+ if (!this._editor) return;
612
+ const end = nodeEnd;
613
+ this._editor
614
+ .chain()
615
+ .focus()
616
+ .insertContentAt(end, { type: 'paragraph' })
617
+ .setTextSelection(end + 1)
618
+ .run();
619
+ const { from } = this._editor.state.selection;
620
+ item.command({ editor: this._editor, range: { from, to: from } });
621
+ this._slashOpen = false;
622
+ this._slashClientRect = null;
623
+ this._slashCommand = undefined;
624
+ };
625
+ this._slashOpen = true;
626
+ this._positionSlashMenu();
627
+ } catch { /* ignore */ }
628
+ };
629
+
630
+ private _onDragHandleMouseDown = (event: MouseEvent) => {
631
+ event.preventDefault();
632
+ if (!this._editor) return;
633
+ const { view } = this._editor;
634
+ const { state } = view;
635
+ try {
636
+ const $pos = state.doc.resolve(this._blockHandleNodePos);
637
+ const nodePos = $pos.depth > 0 ? $pos.before(1) : this._blockHandleNodePos;
638
+ const sel = NodeSelection.create(state.doc, nodePos);
639
+ view.dispatch(state.tr.setSelection(sel));
640
+ } catch { /* ignore */ }
641
+ };
642
+
643
+ private _onDragHandleDragStart = (event: DragEvent) => {
644
+ if (!this._editor || !event.dataTransfer) return;
645
+ const { view } = this._editor;
646
+ const { state } = view;
647
+ try {
648
+ const $pos = state.doc.resolve(this._blockHandleNodePos);
649
+ const nodePos = $pos.depth > 0 ? $pos.before(1) : this._blockHandleNodePos;
650
+ const node = state.doc.nodeAt(nodePos);
651
+ if (!node) return;
652
+ const slice = new Slice(Fragment.from(node), 0, 0);
653
+ const container = document.createElement('div');
654
+ container.appendChild(
655
+ DOMSerializer.fromSchema(state.schema).serializeFragment(slice.content),
656
+ );
657
+ event.dataTransfer.clearData();
658
+ event.dataTransfer.setData('text/html', container.innerHTML);
659
+ event.dataTransfer.setData('text/plain', container.textContent ?? '');
660
+ event.dataTransfer.effectAllowed = 'move';
661
+ view.dragging = { slice, move: true };
662
+ } catch { /* ignore */ }
663
+ };
664
+
665
+ private _onDragHandleDragEnd = () => {
666
+ if (this._editor) this._editor.view.dragging = null;
667
+ };
668
+
669
+ // ── Private methods: bubble menu ──────────────────────────────────────────
670
+
671
+ private async _updateBubbleMenu() {
672
+ if (!this._editor || this.disabled || this.readonly) {
673
+ this._bubbleOpen = false;
674
+ return;
675
+ }
676
+
677
+ if (this._mode !== 'editor') {
678
+ this._bubbleOpen = false;
679
+ return;
680
+ }
681
+
682
+ const { from, to, empty } = this._editor.state.selection;
683
+ if (empty || from === to) {
684
+ this._bubbleOpen = false;
685
+ return;
686
+ }
687
+
688
+ this._bubbleOpen = true;
689
+ await this.updateComplete;
690
+
691
+ if (!this._bubbleEl) return;
692
+
693
+ const { view } = this._editor;
694
+ const start = view.coordsAtPos(from);
695
+ const end = view.coordsAtPos(to);
696
+ const rect: DOMRect = {
697
+ x: Math.min(start.left, end.left),
698
+ y: start.top,
699
+ left: Math.min(start.left, end.left),
700
+ top: start.top,
701
+ right: Math.max(start.right, end.right),
702
+ bottom: end.bottom,
703
+ width: Math.max(start.right, end.right) - Math.min(start.left, end.left),
704
+ height: end.bottom - start.top,
705
+ toJSON: () => ({}),
706
+ };
707
+
708
+ const virtual = {
709
+ getBoundingClientRect: () => rect,
710
+ };
711
+
712
+ const { x, y } = await computePosition(virtual, this._bubbleEl, {
713
+ placement: 'top',
714
+ strategy: 'fixed',
715
+ middleware: [offset(8), flip(), shift({ padding: 8 })],
716
+ });
717
+
718
+ this._bubbleEl.style.left = `${x}px`;
719
+ this._bubbleEl.style.top = `${y}px`;
720
+ }
721
+
722
+ // ── Private methods: events / mode ────────────────────────────────────────
723
+
724
+ private _dispatchDebouncedChange() {
725
+ if (this._changeTimeout) {
726
+ window.clearTimeout(this._changeTimeout);
727
+ }
728
+
729
+ this._changeTimeout = window.setTimeout(() => {
730
+ redispatchEvent(
731
+ this,
732
+ new Event('change', { bubbles: true, composed: true }),
733
+ );
734
+ }, this.debounce);
735
+ }
736
+
737
+ private _execCommand(command: () => void) {
738
+ if (this.disabled || this.readonly || !this._editor) return;
739
+ command();
740
+ this._editor.commands.focus();
741
+ }
742
+
743
+ private _switchMode = (event: CustomEvent<{ value: string }>) => {
744
+ event.stopPropagation();
745
+ const nextMode = event.detail?.value === 'markdown' ? 'markdown' : 'editor';
746
+ if (nextMode === this._mode) return;
747
+
748
+ if (nextMode === 'markdown' && this._editor) {
749
+ const nextMd = MarkdownEditor._normalizeMd(
750
+ this._htmlToMarkdown(this._editor.getHTML()),
751
+ );
752
+ if (nextMd !== this.value) {
753
+ this.value = nextMd;
754
+ }
755
+ this._bubbleOpen = false;
756
+ }
757
+
758
+ this._mode = nextMode;
759
+ };
760
+
761
+ private _handleSourceChange = (event: Event) => {
762
+ event.stopPropagation();
763
+ const target = event.currentTarget as { value?: string };
764
+ const nextValue = target.value ?? '';
765
+ if (nextValue === this.value) return;
766
+
767
+ this.value = nextValue;
768
+ this._dispatchDebouncedChange();
769
+ };
770
+
771
+ // ── Render helpers ────────────────────────────────────────────────────────
772
+
773
+ private _renderToolbarButton(
774
+ icon: string,
775
+ title: string,
776
+ action: () => void,
777
+ active = false,
778
+ ) {
779
+ return html`
780
+ <button
781
+ class=${classMap({ 'toolbar-btn': true, active })}
782
+ title=${title}
783
+ aria-label=${title}
784
+ ?disabled=${this.disabled || this.readonly}
785
+ @mousedown=${(e: Event) => e.preventDefault()}
786
+ @click=${(e: Event) => {
787
+ e.preventDefault();
788
+ this._execCommand(action);
789
+ }}
790
+ >
791
+ <wc-icon name=${icon} size="sm"></wc-icon>
792
+ </button>
793
+ `;
794
+ }
795
+
796
+ private _renderBubbleMenu() {
797
+ if (!this._editor) return nothing;
798
+
799
+ return html`
800
+ <div
801
+ class=${classMap({ 'bubble-menu': true, open: this._bubbleOpen })}
802
+ role="toolbar"
803
+ aria-label="Inline formatting"
804
+ >
805
+ ${this._renderToolbarButton(
806
+ 'format_bold',
807
+ 'Bold',
808
+ () => this._editor?.chain().focus().toggleBold().run(),
809
+ this._editor.isActive('bold'),
810
+ )}
811
+ ${this._renderToolbarButton(
812
+ 'format_italic',
813
+ 'Italic',
814
+ () => this._editor?.chain().focus().toggleItalic().run(),
815
+ this._editor.isActive('italic'),
816
+ )}
817
+ ${this._renderToolbarButton(
818
+ 'format_underlined',
819
+ 'Underline',
820
+ () => this._editor?.chain().focus().toggleUnderline().run(),
821
+ this._editor.isActive('underline'),
822
+ )}
823
+ ${this._renderToolbarButton(
824
+ 'format_strikethrough',
825
+ 'Strikethrough',
826
+ () => this._editor?.chain().focus().toggleStrike().run(),
827
+ this._editor.isActive('strike'),
828
+ )}
829
+ ${this._renderToolbarButton(
830
+ 'code',
831
+ 'Inline code',
832
+ () => this._editor?.chain().focus().toggleCode().run(),
833
+ this._editor.isActive('code'),
834
+ )}
835
+ </div>
836
+ `;
837
+ }
838
+
839
+ private _renderSlashMenu() {
840
+ return html`
841
+ <div
842
+ class=${classMap({ 'slash-menu': true, open: this._slashOpen })}
843
+ role="listbox"
844
+ aria-label="Insert block"
845
+ >
846
+ ${this._slashItems.map(
847
+ (item, index) => html`
848
+ <button
849
+ type="button"
850
+ class=${classMap({
851
+ 'slash-item': true,
852
+ active: index === this._slashIndex,
853
+ })}
854
+ role="option"
855
+ aria-selected=${index === this._slashIndex}
856
+ data-index=${index}
857
+ @mousedown=${(e: Event) => e.preventDefault()}
858
+ @click=${this._onSlashItemClick}
859
+ >
860
+ <wc-icon name=${item.icon} size="sm"></wc-icon>
861
+ <span class="slash-item-label">${item.title}</span>
862
+ <span class="slash-item-hint">${item.hint}</span>
863
+ </button>
864
+ `,
865
+ )}
866
+ </div>
867
+ `;
868
+ }
869
+
870
+ private _renderBlockHandle() {
871
+ if (this._mode !== 'editor') return nothing;
872
+ return html`
873
+ <div
874
+ class=${classMap({
875
+ 'block-handle': true,
876
+ visible: this._blockHandleVisible,
877
+ })}
878
+ @mouseenter=${this._onBlockHandleMouseEnter}
879
+ @mouseleave=${this._onBlockHandleMouseLeave}
880
+ >
881
+ <button
882
+ class="block-handle-btn drag-btn"
883
+ title="Drag to reorder"
884
+ aria-label="Drag to reorder"
885
+ draggable="true"
886
+ ?disabled=${this.disabled || this.readonly}
887
+ @mousedown=${this._onDragHandleMouseDown}
888
+ @dragstart=${this._onDragHandleDragStart}
889
+ @dragend=${this._onDragHandleDragEnd}
890
+ >
891
+ <wc-icon name="drag_indicator" size="sm"></wc-icon>
892
+ </button>
893
+ <button
894
+ class="block-handle-btn insert-btn"
895
+ title="Insert block below"
896
+ aria-label="Insert block below"
897
+ ?disabled=${this.disabled || this.readonly}
898
+ @mousedown=${(e: Event) => e.preventDefault()}
899
+ @click=${this._onInsertBlockClick}
900
+ >
901
+ <wc-icon name="add" size="sm"></wc-icon>
902
+ </button>
903
+ </div>
904
+ `;
905
+ }
906
+
907
+ private _renderReadonlyTag() {
908
+ if (this.disabled) {
909
+ return html`<wc-tag class="read-only-tag" color="red">Disabled</wc-tag>`;
910
+ }
911
+ if (this.readonly) {
912
+ return html`<wc-tag class="read-only-tag" color="red">Read Only</wc-tag>`;
913
+ }
914
+ return nothing;
915
+ }
916
+
917
+ // ── Render ────────────────────────────────────────────────────────────────
918
+
919
+ render() {
920
+ const isEmpty = !this.value || this.value.trim() === '';
921
+
922
+ return html`
923
+ <wc-field
924
+ label=${this.label}
925
+ ?required=${this.required}
926
+ ?disabled=${this.disabled}
927
+ ?readonly=${this.readonly}
928
+ ?skeleton=${this.skeleton}
929
+ ?focused=${this._focused}
930
+ ?error=${this.error}
931
+ error-text=${this.errorText}
932
+ helper-text=${this.helperText}
933
+ variant=${this.variant}
934
+ ?populated=${!isEmpty}
935
+ .host=${this}
936
+ class=${classMap({
937
+ 'markdown-editor-field': true,
938
+ disabled: this.disabled,
939
+ readonly: this.readonly,
940
+ })}
941
+ >
942
+ <div
943
+ class=${classMap({
944
+ 'markdown-editor-content': true,
945
+ 'is-empty': isEmpty,
946
+ hidden: this._mode !== 'editor',
947
+ })}
948
+ data-placeholder=${this.placeholder}
949
+ >
950
+ <div class="tiptap-root"></div>
951
+ </div>
952
+
953
+ <div
954
+ class=${classMap({
955
+ 'markdown-source': true,
956
+ hidden: this._mode !== 'markdown',
957
+ })}
958
+ >
959
+ <wc-code-editor
960
+ language="markdown"
961
+ .value=${this.value}
962
+ ?readonly=${this.readonly}
963
+ ?disabled=${this.disabled}
964
+ @change=${this._handleSourceChange}
965
+ ></wc-code-editor>
966
+ </div>
967
+
968
+ <div class="mode-switcher">
969
+ <wc-segmented-button-group @change=${this._switchMode}>
970
+ <wc-segmented-button
971
+ value="editor"
972
+ ?selected=${this._mode === 'editor'}
973
+ ?disabled=${this.disabled}
974
+ >
975
+ Editor
976
+ </wc-segmented-button>
977
+ <wc-segmented-button
978
+ value="markdown"
979
+ ?selected=${this._mode === 'markdown'}
980
+ ?disabled=${this.disabled}
981
+ >
982
+ Markdown
983
+ </wc-segmented-button>
984
+ </wc-segmented-button-group>
985
+ </div>
986
+
987
+ ${this._renderReadonlyTag()}
988
+ </wc-field>
989
+
990
+ ${this._renderBubbleMenu()} ${this._renderSlashMenu()}
991
+ ${this._renderBlockHandle()}
992
+ `;
993
+ }
994
+ }