- Implemented DeesInputFileupload component with file upload functionality, including drag-and-drop support, file previews, and clear all option. - Developed DeesInputRichtext component featuring a rich text editor with a formatting toolbar, link management, and word count display. - Created demo for DeesInputRichtext showcasing various use cases including basic editing, placeholder text, different heights, and disabled state. - Added styles for both components to ensure a consistent and user-friendly interface. - Introduced types for toolbar buttons in the rich text editor for better type safety and maintainability.
		
			
				
	
	
		
			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();
 | |
|     }
 | |
|   }
 | |
| } |