import { DeesInputBase } from '../dees-input-base.js'; import { demoFunc } from './demo.js'; import { richtextStyles } from './styles.js'; import { renderRichtext } from './template.js'; import type { IToolbarButton } from './types.js'; import '../dees-icon.js'; import { customElement, type TemplateResult, property, html, state, query, } from '@design.estate/dees-element'; import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Underline from '@tiptap/extension-underline'; import TextAlign from '@tiptap/extension-text-align'; import Link from '@tiptap/extension-link'; import Typography from '@tiptap/extension-typography'; declare global { interface HTMLElementTagNameMap { 'dees-input-richtext': DeesInputRichtext; } } @customElement('dees-input-richtext') export class DeesInputRichtext extends DeesInputBase { public static demo = demoFunc; // INSTANCE @property({ type: String, reflect: true, }) public value: string = ''; @property({ type: String, }) public placeholder: string = ''; @property({ type: Boolean, }) public showWordCount: boolean = true; @property({ type: Number, }) public minHeight: number = 200; @state() public showLinkInput: boolean = false; @state() public wordCount: number = 0; @query('.editor-content') private editorElement: HTMLElement; @query('.link-input input') private linkInputElement: HTMLInputElement; public editor: Editor; public static styles = richtextStyles; public render(): TemplateResult { return renderRichtext(this); } public renderToolbar(): TemplateResult { const buttons: IToolbarButton[] = this.getToolbarButtons(); return html` ${buttons.map((button) => { if (button.isDivider) { return html`
`; } return html` `; })} `; } private getToolbarButtons(): IToolbarButton[] { if (!this.editor) return []; return [ { name: 'bold', icon: 'lucide:bold', title: 'Bold (Ctrl+B)', action: () => this.editor.chain().focus().toggleBold().run(), isActive: () => this.editor.isActive('bold'), }, { name: 'italic', icon: 'lucide:italic', title: 'Italic (Ctrl+I)', action: () => this.editor.chain().focus().toggleItalic().run(), isActive: () => this.editor.isActive('italic'), }, { name: 'underline', icon: 'lucide:underline', title: 'Underline (Ctrl+U)', action: () => this.editor.chain().focus().toggleUnderline().run(), isActive: () => this.editor.isActive('underline'), }, { name: 'strike', icon: 'lucide:strikethrough', title: 'Strikethrough', action: () => this.editor.chain().focus().toggleStrike().run(), isActive: () => this.editor.isActive('strike'), }, { name: 'divider1', title: '', isDivider: true }, { name: 'h1', icon: 'lucide:heading1', title: 'Heading 1', action: () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(), isActive: () => this.editor.isActive('heading', { level: 1 }), }, { name: 'h2', icon: 'lucide:heading2', title: 'Heading 2', action: () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(), isActive: () => this.editor.isActive('heading', { level: 2 }), }, { name: 'h3', icon: 'lucide:heading3', title: 'Heading 3', action: () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(), isActive: () => this.editor.isActive('heading', { level: 3 }), }, { name: 'divider2', title: '', isDivider: true }, { name: 'bulletList', icon: 'lucide:list', title: 'Bullet List', action: () => this.editor.chain().focus().toggleBulletList().run(), isActive: () => this.editor.isActive('bulletList'), }, { name: 'orderedList', icon: 'lucide:listOrdered', title: 'Numbered List', action: () => this.editor.chain().focus().toggleOrderedList().run(), isActive: () => this.editor.isActive('orderedList'), }, { name: 'blockquote', icon: 'lucide:quote', title: 'Quote', action: () => this.editor.chain().focus().toggleBlockquote().run(), isActive: () => this.editor.isActive('blockquote'), }, { name: 'code', icon: 'lucide:code', title: 'Code', action: () => this.editor.chain().focus().toggleCode().run(), isActive: () => this.editor.isActive('code'), }, { name: 'codeBlock', icon: 'lucide:fileCode', title: 'Code Block', action: () => this.editor.chain().focus().toggleCodeBlock().run(), isActive: () => this.editor.isActive('codeBlock'), }, { name: 'divider3', title: '', isDivider: true }, { name: 'link', icon: 'lucide:link', title: 'Add Link', action: () => this.toggleLink(), isActive: () => this.editor.isActive('link'), }, { name: 'alignLeft', icon: 'lucide:alignLeft', title: 'Align Left', action: () => this.editor.chain().focus().setTextAlign('left').run(), isActive: () => this.editor.isActive({ textAlign: 'left' }), }, { name: 'alignCenter', icon: 'lucide:alignCenter', title: 'Align Center', action: () => this.editor.chain().focus().setTextAlign('center').run(), isActive: () => this.editor.isActive({ textAlign: 'center' }), }, { name: 'alignRight', icon: 'lucide:alignRight', title: 'Align Right', action: () => this.editor.chain().focus().setTextAlign('right').run(), isActive: () => this.editor.isActive({ textAlign: 'right' }), }, { name: 'divider4', title: '', isDivider: true }, { name: 'undo', icon: 'lucide:undo', title: 'Undo (Ctrl+Z)', action: () => this.editor.chain().focus().undo().run(), }, { name: 'redo', icon: 'lucide:redo', title: 'Redo (Ctrl+Y)', action: () => this.editor.chain().focus().redo().run(), }, ]; } public async firstUpdated() { await this.updateComplete; this.initializeEditor(); } private initializeEditor(): void { if (this.disabled) return; this.editor = new Editor({ element: this.editorElement, extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3], }, }), Underline, TextAlign.configure({ types: ['heading', 'paragraph'], }), Link.configure({ openOnClick: false, HTMLAttributes: { class: 'editor-link', }, }), Typography, ], content: this.value || (this.placeholder ? `

${this.placeholder}

` : ''), onUpdate: ({ editor }) => { this.value = editor.getHTML(); this.updateWordCount(); this.dispatchEvent( new CustomEvent('input', { detail: { value: this.value }, bubbles: true, composed: true, }) ); this.dispatchEvent( new CustomEvent('change', { detail: { value: this.value }, bubbles: true, composed: true, }) ); }, onSelectionUpdate: () => { this.requestUpdate(); }, onFocus: () => { this.requestUpdate(); }, onBlur: () => { this.requestUpdate(); }, }); this.updateWordCount(); } private updateWordCount(): void { if (!this.editor) return; const text = this.editor.getText(); this.wordCount = text.trim() ? text.trim().split(/\s+/).length : 0; } private toggleLink(): void { if (!this.editor) return; if (this.editor.isActive('link')) { const href = this.editor.getAttributes('link').href; this.showLinkInput = true; requestAnimationFrame(() => { if (this.linkInputElement) { this.linkInputElement.value = href || ''; this.linkInputElement.focus(); this.linkInputElement.select(); } }); } else { this.showLinkInput = true; requestAnimationFrame(() => { if (this.linkInputElement) { this.linkInputElement.value = ''; this.linkInputElement.focus(); } }); } } public saveLink(): void { if (!this.editor || !this.linkInputElement) return; const url = this.linkInputElement.value; if (url) { this.editor.chain().focus().setLink({ href: url }).run(); } this.hideLinkInput(); } public removeLink(): void { if (!this.editor) return; this.editor.chain().focus().unsetLink().run(); this.hideLinkInput(); } public hideLinkInput(): void { this.showLinkInput = false; this.editor?.commands.focus(); } public handleLinkInputKeydown(e: KeyboardEvent): void { if (e.key === 'Enter') { e.preventDefault(); this.saveLink(); } else if (e.key === 'Escape') { e.preventDefault(); this.hideLinkInput(); } } public setValue(value: string): void { this.value = value; if (this.editor && value !== this.editor.getHTML()) { this.editor.commands.setContent(value); } } public getValue(): string { return this.value; } public clear(): void { this.setValue(''); } public focus(): void { this.editor?.commands.focus(); } public async disconnectedCallback(): Promise { await super.disconnectedCallback(); if (this.editor) { this.editor.destroy(); } } }