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('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; } .editor-container { display: flex; flex-direction: column; min-height: ${cssManager.bdTheme('200px', '200px')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 6px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; overflow: hidden; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } .editor-container:hover { border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; } .editor-container.focused { border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')}; } .editor-toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px 12px; background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')}; border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; 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('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; transition: all 0.15s ease; user-select: none; } .toolbar-button dees-icon { width: 16px; height: 16px; } .toolbar-button:hover { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; } .toolbar-button.active { background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; } .toolbar-button:disabled { opacity: 0.5; cursor: not-allowed; } .toolbar-divider { width: 1px; height: 24px; background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; 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('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; 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('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; margin: 1em 0; padding-left: 1em; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; font-style: italic; } .editor-content .ProseMirror code { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; border-radius: 3px; padding: 0.2em 0.4em; font-family: 'Intel One Mono', 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: 0.9em; color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; } .editor-content .ProseMirror pre { background: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')}; 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('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; text-decoration: underline; cursor: pointer; } .editor-content .ProseMirror a:hover { color: ${cssManager.bdTheme('hsl(222.2 47.4% 41.2%)', 'hsl(217.2 91.2% 69.8%)')}; } .editor-footer { padding: 8px 12px; background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')}; border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; font-size: 12px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; 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('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; 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('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 6px; outline: none; font-size: 14px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } .link-input input:focus { border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')}; } .link-input-buttons { display: flex; gap: 8px; margin-top: 8px; } .link-input-buttons button { padding: 6px 12px; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 4px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; cursor: pointer; font-size: 12px; color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; transition: all 0.15s ease; font-weight: 500; } .link-input-buttons button:hover { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; } .link-input-buttons button.primary { background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; } .link-input-buttons button.primary:hover { background: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; border-color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; } .description { margin-top: 8px; font-size: 12px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; 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(); } } }