import * as colors from './00colors.js'; import { DeesInputBase } from './dees-input-base.js'; import { demoFunc } from './dees-input-richtext.demo.js'; import './dees-icon.js'; import { customElement, type TemplateResult, property, html, css, cssManager, state, query, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; 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; } } interface IToolbarButton { name: string; icon?: string; action?: () => void; isActive?: () => boolean; title: string; isDivider?: boolean; } @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() private showLinkInput: boolean = false; @state() private wordCount: number = 0; @query('.editor-content') private editorElement: HTMLElement; @query('.link-input input') private linkInputElement: HTMLInputElement; private editor: Editor; public static styles = [ ...DeesInputBase.baseStyles, cssManager.defaultStyles, css` :host { display: block; position: relative; font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .input-wrapper { position: relative; } .label { display: block; margin-bottom: 8px; font-size: 14px; font-weight: 500; color: ${cssManager.bdTheme('#374151', '#e4e4e7')}; } .editor-container { display: flex; flex-direction: column; min-height: ${cssManager.bdTheme('200px', '200px')}; border: 1px solid ${cssManager.bdTheme('#e1e5e9', '#2c2c2c')}; border-radius: 8px; background: ${cssManager.bdTheme('#ffffff', '#141414')}; overflow: hidden; transition: all 0.2s ease; } .editor-container:hover { border-color: ${cssManager.bdTheme('#d1d5db', '#404040')}; } .editor-container.focused { border-color: ${cssManager.bdTheme('#0050b9', '#0069f2')}; box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 80, 185, 0.1)', 'rgba(0, 105, 242, 0.1)')}; } .editor-toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px 12px; background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')}; border-bottom: 1px solid ${cssManager.bdTheme('#e1e5e9', '#2c2c2c')}; align-items: center; position: relative; } .toolbar-button { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: none; border-radius: 4px; background: transparent; cursor: pointer; font-size: 14px; font-weight: 500; color: ${cssManager.bdTheme('#374151', '#9ca3af')}; transition: all 0.2s; user-select: none; } .toolbar-button dees-icon { width: 16px; height: 16px; } .toolbar-button:hover { background: ${cssManager.bdTheme('#e5e7eb', '#2c2c2c')}; color: ${cssManager.bdTheme('#1f2937', '#e4e4e7')}; } .toolbar-button.active { background: ${cssManager.bdTheme('#0050b9', '#0069f2')}; color: white; } .toolbar-button:disabled { opacity: 0.5; cursor: not-allowed; } .toolbar-divider { width: 1px; height: 24px; background: ${cssManager.bdTheme('#d1d5db', '#404040')}; margin: 0 4px; } .editor-content { flex: 1; padding: 16px; overflow-y: auto; min-height: var(--min-height, 200px); } .editor-content .ProseMirror { outline: none; line-height: 1.6; color: ${cssManager.bdTheme('#374151', '#e4e4e7')}; min-height: 100%; } .editor-content .ProseMirror p { margin: 0.5em 0; } .editor-content .ProseMirror p:first-child { margin-top: 0; } .editor-content .ProseMirror p:last-child { margin-bottom: 0; } .editor-content .ProseMirror h1 { font-size: 2em; font-weight: bold; margin: 1em 0 0.5em 0; line-height: 1.2; } .editor-content .ProseMirror h2 { font-size: 1.5em; font-weight: bold; margin: 1em 0 0.5em 0; line-height: 1.3; } .editor-content .ProseMirror h3 { font-size: 1.25em; font-weight: bold; margin: 1em 0 0.5em 0; line-height: 1.4; } .editor-content .ProseMirror ul, .editor-content .ProseMirror ol { padding-left: 1.5em; margin: 0.5em 0; } .editor-content .ProseMirror li { margin: 0.25em 0; } .editor-content .ProseMirror blockquote { border-left: 4px solid ${cssManager.bdTheme('#d1d5db', '#404040')}; margin: 1em 0; padding-left: 1em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; font-style: italic; } .editor-content .ProseMirror code { background: ${cssManager.bdTheme('#f3f4f6', '#2c2c2c')}; border-radius: 4px; padding: 0.2em 0.4em; font-family: 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: 0.9em; color: ${cssManager.bdTheme('#e11d48', '#f87171')}; } .editor-content .ProseMirror pre { background: ${cssManager.bdTheme('#1f2937', '#0a0a0a')}; color: ${cssManager.bdTheme('#f9fafb', '#e4e4e7')}; border-radius: 6px; padding: 1em; margin: 1em 0; overflow-x: auto; } .editor-content .ProseMirror pre code { background: none; color: inherit; padding: 0; border-radius: 0; } .editor-content .ProseMirror a { color: ${cssManager.bdTheme('#0050b9', '#0069f2')}; text-decoration: underline; cursor: pointer; } .editor-content .ProseMirror a:hover { color: ${cssManager.bdTheme('#0069f2', '#0084ff')}; } .editor-footer { padding: 8px 12px; background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')}; border-top: 1px solid ${cssManager.bdTheme('#e1e5e9', '#2c2c2c')}; font-size: 12px; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; display: flex; justify-content: space-between; align-items: center; } .word-count { font-weight: 500; } .link-input { display: none; position: absolute; top: 100%; left: 0; right: 0; background: ${cssManager.bdTheme('#ffffff', '#1a1a1a')}; border: 1px solid ${cssManager.bdTheme('#d1d5db', '#404040')}; border-radius: 6px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); padding: 12px; z-index: 1000; } .link-input.show { display: block; } .link-input input { width: 100%; padding: 8px 12px; border: 1px solid ${cssManager.bdTheme('#d1d5db', '#404040')}; border-radius: 4px; outline: none; font-size: 14px; background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')}; color: ${cssManager.bdTheme('#374151', '#e4e4e7')}; } .link-input input:focus { border-color: ${cssManager.bdTheme('#0050b9', '#0069f2')}; box-shadow: 0 0 0 2px ${cssManager.bdTheme('rgba(0, 80, 185, 0.1)', 'rgba(0, 105, 242, 0.1)')}; } .link-input-buttons { display: flex; gap: 8px; margin-top: 8px; } .link-input-buttons button { padding: 6px 12px; border: 1px solid ${cssManager.bdTheme('#d1d5db', '#404040')}; border-radius: 4px; background: ${cssManager.bdTheme('#ffffff', '#1a1a1a')}; cursor: pointer; font-size: 12px; color: ${cssManager.bdTheme('#374151', '#e4e4e7')}; transition: all 0.2s; } .link-input-buttons button:hover { background: ${cssManager.bdTheme('#f3f4f6', '#2c2c2c')}; } .link-input-buttons button.primary { background: ${cssManager.bdTheme('#0050b9', '#0069f2')}; color: white; border-color: ${cssManager.bdTheme('#0050b9', '#0069f2')}; } .link-input-buttons button.primary:hover { background: ${cssManager.bdTheme('#0069f2', '#0084ff')}; } .description { margin-top: 8px; font-size: 12px; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; line-height: 1.4; } :host([disabled]) .editor-container { opacity: 0.6; cursor: not-allowed; } :host([disabled]) .toolbar-button, :host([disabled]) .editor-content { pointer-events: none; } `, ]; public render(): TemplateResult { return html`
${this.label ? html`` : ''}
${this.renderToolbar()}
${this.showWordCount ? html` ` : ''}
${this.description ? html`
${this.description}
` : ''}
`; } private 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(); } }); } } private 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(); } private removeLink(): void { if (!this.editor) return; this.editor.chain().focus().unsetLink().run(); this.hideLinkInput(); } private hideLinkInput(): void { this.showLinkInput = false; this.editor?.commands.focus(); } private 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(); } } }