384 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			384 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | 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<string> { | ||
|  |   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`<div class="toolbar-divider"></div>`; | ||
|  |         } | ||
|  |         return html`
 | ||
|  |           <button | ||
|  |             class="toolbar-button ${button.isActive?.() ? 'active' : ''}" | ||
|  |             @click=${button.action} | ||
|  |             title=${button.title} | ||
|  |             ?disabled=${this.disabled || !this.editor} | ||
|  |           > | ||
|  |             <dees-icon .icon=${button.icon}></dees-icon> | ||
|  |           </button> | ||
|  |         `;
 | ||
|  |       })} | ||
|  |     `;
 | ||
|  |   } | ||
|  | 
 | ||
|  |   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 ? `<p>${this.placeholder}</p>` : ''), | ||
|  |       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<void> { | ||
|  |     await super.disconnectedCallback(); | ||
|  |     if (this.editor) { | ||
|  |       this.editor.destroy(); | ||
|  |     } | ||
|  |   } | ||
|  | } |