diff --git a/ts_web/elements/dees-input-wysiwyg.ts b/ts_web/elements/dees-input-wysiwyg.ts new file mode 100644 index 0000000..2e55399 --- /dev/null +++ b/ts_web/elements/dees-input-wysiwyg.ts @@ -0,0 +1,1113 @@ +import * as colors from './00colors.js'; +import { DeesInputBase } from './dees-input-base.js'; +import { demoFunc } from './dees-input-wysiwyg.demo.js'; + +import { + customElement, + type TemplateResult, + property, + html, + cssManager, + css, + state, +} from '@design.estate/dees-element'; +import * as domtools from '@design.estate/dees-domtools'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-input-wysiwyg': DeesInputWysiwyg; + } +} + +interface IBlock { + id: string; + type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider'; + content: string; + metadata?: any; +} + +@customElement('dees-input-wysiwyg') +export class DeesInputWysiwyg extends DeesInputBase { + public static demo = demoFunc; + + @property({ type: String }) + public value: string = ''; + + @property({ type: String }) + public outputFormat: 'html' | 'markdown' = 'html'; + + @state() + private blocks: IBlock[] = [ + { + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'paragraph', + content: '', + } + ]; + + @state() + private selectedBlockId: string | null = null; + + @state() + private showSlashMenu: boolean = false; + + @state() + private slashMenuPosition: { x: number; y: number } = { x: 0, y: 0 }; + + @state() + private slashMenuFilter: string = ''; + + @state() + private slashMenuSelectedIndex: number = 0; + + private editorContentRef: HTMLDivElement; + private isComposing: boolean = false; + + public static styles = [ + ...DeesInputBase.baseStyles, + cssManager.defaultStyles, + css` + :host { + display: block; + position: relative; + } + + .wysiwyg-container { + background: ${cssManager.bdTheme('#ffffff', '#1a1a1a')}; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; + border-radius: 8px; + min-height: 200px; + padding: 20px; + position: relative; + transition: all 0.2s ease; + color: ${cssManager.bdTheme('#000000', '#ffffff')}; + } + + .wysiwyg-container:hover { + border-color: ${cssManager.bdTheme('#d0d0d0', '#444')}; + } + + .wysiwyg-container:focus-within { + border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(77, 148, 255, 0.1)')}; + } + + .editor-content { + outline: none; + min-height: 160px; + } + + .block { + margin-bottom: 12px; + position: relative; + transition: all 0.15s ease; + min-height: 1.6em; + color: ${cssManager.bdTheme('#000000', '#e0e0e0')}; + } + + .block:last-child { + margin-bottom: 0; + } + + .block.selected { + background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.08)', 'rgba(77, 148, 255, 0.12)')}; + margin-left: -12px; + margin-right: -12px; + padding: 8px 12px; + border-radius: 6px; + } + + .block[contenteditable] { + outline: none; + } + + .block.paragraph { + font-size: 16px; + line-height: 1.6; + font-weight: 400; + } + + .block.paragraph:empty::before { + content: "Type '/' for commands..."; + color: ${cssManager.bdTheme('#999', '#666')}; + pointer-events: none; + font-size: 16px; + line-height: 1.6; + font-weight: 400; + } + + .block.heading-1 { + font-size: 32px; + font-weight: 700; + line-height: 1.2; + margin-bottom: 16px; + color: ${cssManager.bdTheme('#000000', '#ffffff')}; + } + + .block.heading-1:empty::before { + content: "Heading 1"; + color: ${cssManager.bdTheme('#999', '#666')}; + pointer-events: none; + font-size: 32px; + line-height: 1.2; + font-weight: 700; + } + + .block.heading-2 { + font-size: 24px; + font-weight: 600; + line-height: 1.3; + margin-bottom: 14px; + color: ${cssManager.bdTheme('#000000', '#ffffff')}; + } + + .block.heading-2:empty::before { + content: "Heading 2"; + color: ${cssManager.bdTheme('#999', '#666')}; + pointer-events: none; + font-size: 24px; + line-height: 1.3; + font-weight: 600; + } + + .block.heading-3 { + font-size: 20px; + font-weight: 600; + line-height: 1.4; + margin-bottom: 12px; + color: ${cssManager.bdTheme('#000000', '#ffffff')}; + } + + .block.heading-3:empty::before { + content: "Heading 3"; + color: ${cssManager.bdTheme('#999', '#666')}; + pointer-events: none; + font-size: 20px; + line-height: 1.4; + font-weight: 600; + } + + .block.quote { + border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + padding-left: 16px; + font-style: italic; + color: ${cssManager.bdTheme('#555', '#b0b0b0')}; + margin-left: 0; + margin-right: 0; + } + + .block.quote:empty::before { + content: "Quote"; + color: ${cssManager.bdTheme('#999', '#666')}; + pointer-events: none; + font-size: 16px; + line-height: 1.6; + font-weight: 400; + font-style: italic; + } + + .block.code { + background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')}; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')}; + border-radius: 6px; + padding: 12px 16px; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; + font-size: 14px; + white-space: pre-wrap; + color: ${cssManager.bdTheme('#d14', '#ff6b6b')}; + overflow-x: auto; + } + + .block.code:empty::before { + content: "// Code block"; + color: ${cssManager.bdTheme('#999', '#666')}; + pointer-events: none; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; + font-size: 14px; + line-height: 1.6; + font-weight: 400; + } + + .block.list { + padding-left: 24px; + } + + .block.list ul, + .block.list ol { + margin: 0; + padding: 0; + list-style-position: inside; + } + + .block.list ul { + list-style: disc; + } + + .block.list ol { + list-style: decimal; + } + + .block.list li { + margin-bottom: 4px; + line-height: 1.6; + } + + .block.divider { + text-align: center; + padding: 20px 0; + cursor: default; + pointer-events: none; + } + + .block.divider hr { + border: none; + border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; + margin: 0; + } + + .slash-menu { + position: absolute; + background: ${cssManager.bdTheme('#ffffff', '#262626')}; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')}; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + padding: 4px; + z-index: 1000; + min-width: 220px; + max-height: 300px; + overflow-y: auto; + } + + .slash-menu-item { + padding: 10px 12px; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 12px; + border-radius: 4px; + color: ${cssManager.bdTheme('#000000', '#e0e0e0')}; + font-size: 14px; + } + + .slash-menu-item:hover, + .slash-menu-item.selected { + background: ${cssManager.bdTheme('#f0f0f0', '#333333')}; + color: ${cssManager.bdTheme('#000000', '#ffffff')}; + } + + .slash-menu-item .icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: ${cssManager.bdTheme('#666', '#999')}; + font-weight: 600; + } + + .slash-menu-item:hover .icon, + .slash-menu-item.selected .icon { + color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + } + + .toolbar { + position: absolute; + top: -40px; + left: 0; + background: ${cssManager.bdTheme('#ffffff', '#262626')}; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')}; + border-radius: 6px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); + padding: 4px; + display: none; + gap: 4px; + z-index: 1000; + } + + .toolbar.visible { + display: flex; + } + + .toolbar-button { + width: 32px; + height: 32px; + border: none; + background: transparent; + cursor: pointer; + border-radius: 4px; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + color: ${cssManager.bdTheme('#000000', '#e0e0e0')}; + } + + .toolbar-button:hover { + background: ${cssManager.bdTheme('#f0f0f0', '#333333')}; + color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + } + `, + ]; + + async firstUpdated() { + this.updateValue(); + this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement; + } + + render(): TemplateResult { + return html` + +
+
+ ${this.blocks.map(block => this.renderBlock(block))} +
+ ${this.showSlashMenu ? this.renderSlashMenu() : ''} +
+ `; + } + + private renderBlock(block: IBlock): TemplateResult { + const isSelected = this.selectedBlockId === block.id; + + if (block.type === 'divider') { + return html` +
+
+
+ `; + } + + if (block.type === 'list') { + return html` +
+ `; + } + + return html` +
+ `; + } + + private renderListContent(content: string, metadata?: any): string { + const items = content.split('\n').filter(item => item.trim()); + if (items.length === 0) return ''; + const listTag = metadata?.listType === 'ordered' ? 'ol' : 'ul'; + return `<${listTag}>${items.map(item => `
  • ${this.escapeHtml(item)}
  • `).join('')}`; + } + + private getFilteredMenuItems() { + const allItems = [ + { type: 'paragraph', label: 'Paragraph', icon: '¶' }, + { type: 'heading-1', label: 'Heading 1', icon: 'H₁' }, + { type: 'heading-2', label: 'Heading 2', icon: 'H₂' }, + { type: 'heading-3', label: 'Heading 3', icon: 'H₃' }, + { type: 'quote', label: 'Quote', icon: '"' }, + { type: 'code', label: 'Code', icon: '<>' }, + { type: 'list', label: 'List', icon: '•' }, + { type: 'divider', label: 'Divider', icon: '—' }, + ]; + + return allItems.filter(item => + this.slashMenuFilter === '' || + item.label.toLowerCase().includes(this.slashMenuFilter.toLowerCase()) + ); + } + + private renderSlashMenu(): TemplateResult { + const menuItems = this.getFilteredMenuItems(); + + return html` +
    + ${menuItems.map((item, index) => html` +
    + ${item.icon} + ${item.label} +
    + `)} +
    + `; + } + + private handleBlockInput(e: InputEvent, block: IBlock) { + if (this.isComposing) return; + + const target = e.target as HTMLDivElement; + + if (block.type === 'list') { + // Extract text from list items + const listItems = target.querySelectorAll('li'); + block.content = Array.from(listItems).map(li => li.textContent || '').join('\n'); + // Preserve list type + const listElement = target.querySelector('ol, ul'); + if (listElement) { + block.metadata = { listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet' }; + } + } else { + block.content = target.textContent || ''; + } + + // Check for shortcuts at the beginning of a paragraph + if (block.type === 'paragraph') { + // Check for heading shortcuts + const headingPatterns = [ + { pattern: /^# $/, type: 'heading-1' }, + { pattern: /^## $/, type: 'heading-2' }, + { pattern: /^### $/, type: 'heading-3' } + ]; + + for (const { pattern, type } of headingPatterns) { + if (pattern.test(block.content)) { + e.preventDefault(); + block.type = type as IBlock['type']; + block.content = ''; + + // Force update the block + setTimeout(() => { + const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement; + if (blockElement) { + blockElement.textContent = ''; + blockElement.focus(); + } + }); + + this.updateValue(); + this.requestUpdate(); + return; + } + } + + // Check for list shortcuts + const listPatterns = [ + { pattern: /^(\*|-) $/, type: 'bullet' }, + { pattern: /^(\d+)\. $/, type: 'ordered' }, + { pattern: /^(\d+)\) $/, type: 'ordered' } + ]; + + for (const { pattern, type } of listPatterns) { + if (pattern.test(block.content)) { + e.preventDefault(); + block.type = 'list'; + block.content = ''; + block.metadata = { listType: type }; + + // Force update the block to be a list + setTimeout(() => { + const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement; + if (blockElement) { + const listTag = type === 'ordered' ? 'ol' : 'ul'; + blockElement.innerHTML = `<${listTag}>
  • `; + // Focus the first list item + const firstLi = blockElement.querySelector('li'); + if (firstLi) { + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(firstLi); + range.collapse(true); + sel!.removeAllRanges(); + sel!.addRange(range); + } + } + }); + + this.updateValue(); + this.requestUpdate(); + return; + } + } + + // Check for quote shortcut + if (block.content === '> ') { + e.preventDefault(); + block.type = 'quote'; + block.content = ''; + + setTimeout(() => { + const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement; + if (blockElement) { + blockElement.textContent = ''; + blockElement.focus(); + } + }); + + this.updateValue(); + this.requestUpdate(); + return; + } + + // Check for code block shortcut + if (block.content === '```') { + e.preventDefault(); + block.type = 'code'; + block.content = ''; + + setTimeout(() => { + const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement; + if (blockElement) { + blockElement.textContent = ''; + blockElement.focus(); + } + }); + + this.updateValue(); + this.requestUpdate(); + return; + } + + // Check for divider shortcut + if (block.content === '---' || block.content === '***' || block.content === '___') { + e.preventDefault(); + block.type = 'divider'; + block.content = ' '; + + // Create a new paragraph block after the divider + const blockIndex = this.blocks.findIndex(b => b.id === block.id); + const newBlock: IBlock = { + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'paragraph', + content: '', + }; + this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)]; + + setTimeout(() => { + const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement; + if (newBlockElement) { + newBlockElement.focus(); + } + }); + + this.updateValue(); + this.requestUpdate(); + return; + } + } + + // Check for slash commands (should be last to allow other shortcuts to take precedence) + if (block.content.startsWith('/') && block.type === 'paragraph') { + this.slashMenuFilter = block.content.slice(1); + this.showSlashMenu = true; + this.slashMenuSelectedIndex = 0; + const rect = target.getBoundingClientRect(); + const containerRect = this.shadowRoot!.querySelector('.wysiwyg-container')!.getBoundingClientRect(); + this.slashMenuPosition = { + x: rect.left - containerRect.left, + y: rect.bottom - containerRect.top + 4 + }; + } else { + this.closeSlashMenu(); + } + + this.updateValue(); + } + + private handleBlockKeyDown(e: KeyboardEvent, block: IBlock) { + if (this.showSlashMenu && ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) { + this.handleSlashMenuKeyboard(e); + return; + } + + // Handle Tab key for indentation + if (e.key === 'Tab') { + if (block.type === 'code') { + // Allow tab in code blocks + e.preventDefault(); + document.execCommand('insertText', false, ' '); + return; + } else if (block.type === 'list') { + // Future: implement list indentation + e.preventDefault(); + return; + } + } + + if (e.key === 'Enter' && !e.shiftKey) { + if (block.type === 'list') { + // Handle Enter in lists differently + const target = e.target as HTMLDivElement; + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const currentLi = range.startContainer.parentElement?.closest('li'); + + if (currentLi && currentLi.textContent === '') { + // Empty list item - exit list mode + e.preventDefault(); + const blockIndex = this.blocks.findIndex(b => b.id === block.id); + const newBlock: IBlock = { + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'paragraph', + content: '', + }; + + this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)]; + + setTimeout(() => { + const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement; + if (newBlockElement) { + newBlockElement.focus(); + } + }); + } + // Otherwise, let the browser handle creating new list items + } + return; + } + + e.preventDefault(); + + const blockIndex = this.blocks.findIndex(b => b.id === block.id); + const newBlock: IBlock = { + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'paragraph', + content: '', + }; + + this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)]; + + setTimeout(() => { + const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement; + if (newBlockElement) { + newBlockElement.focus(); + } + }); + } else if (e.key === 'Backspace' && block.content === '' && this.blocks.length > 1) { + e.preventDefault(); + const blockIndex = this.blocks.findIndex(b => b.id === block.id); + if (blockIndex > 0) { + const prevBlock = this.blocks[blockIndex - 1]; + this.blocks = this.blocks.filter(b => b.id !== block.id); + + setTimeout(() => { + const prevBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${prevBlock.id}"]`) as HTMLDivElement; + if (prevBlockElement && prevBlock.type !== 'divider') { + prevBlockElement.focus(); + this.setCursorToEnd(prevBlockElement); + } + }); + } + } + } + + private handleSlashMenuKeyboard(e: KeyboardEvent) { + const menuItems = this.getFilteredMenuItems(); + + switch(e.key) { + case 'ArrowDown': + e.preventDefault(); + this.slashMenuSelectedIndex = (this.slashMenuSelectedIndex + 1) % menuItems.length; + break; + case 'ArrowUp': + e.preventDefault(); + this.slashMenuSelectedIndex = this.slashMenuSelectedIndex === 0 + ? menuItems.length - 1 + : this.slashMenuSelectedIndex - 1; + break; + case 'Enter': + e.preventDefault(); + if (menuItems[this.slashMenuSelectedIndex]) { + this.insertBlock(menuItems[this.slashMenuSelectedIndex].type as IBlock['type']); + } + break; + case 'Escape': + e.preventDefault(); + this.closeSlashMenu(); + break; + } + } + + private closeSlashMenu() { + this.showSlashMenu = false; + this.slashMenuFilter = ''; + this.slashMenuSelectedIndex = 0; + } + + private setCursorToEnd(element: HTMLElement) { + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(element); + range.collapse(false); + sel!.removeAllRanges(); + sel!.addRange(range); + } + + private handleBlockFocus(block: IBlock) { + if (block.type !== 'divider') { + this.selectedBlockId = block.id; + } + } + + private handleBlockBlur(block: IBlock) { + setTimeout(() => { + if (this.selectedBlockId === block.id) { + this.selectedBlockId = null; + } + this.closeSlashMenu(); + }, 200); + } + + private handleEditorClick(e: MouseEvent) { + const target = e.target as HTMLElement; + if (target.classList.contains('editor-content')) { + const lastBlock = this.blocks[this.blocks.length - 1]; + const lastBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${lastBlock.id}"]`) as HTMLDivElement; + if (lastBlockElement && lastBlock.type !== 'divider') { + lastBlockElement.focus(); + this.setCursorToEnd(lastBlockElement); + } + } + } + + private insertBlock(type: IBlock['type']) { + const currentBlockIndex = this.blocks.findIndex(b => b.id === this.selectedBlockId); + const currentBlock = this.blocks[currentBlockIndex]; + + if (currentBlock && currentBlock.content.startsWith('/')) { + currentBlock.type = type; + currentBlock.content = ''; + + if (type === 'divider') { + currentBlock.content = ' '; + const newBlock: IBlock = { + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'paragraph', + content: '', + }; + this.blocks = [...this.blocks.slice(0, currentBlockIndex + 1), newBlock, ...this.blocks.slice(currentBlockIndex + 1)]; + + setTimeout(() => { + const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement; + if (newBlockElement) { + newBlockElement.focus(); + } + }); + } else { + // Force update the contenteditable element + setTimeout(() => { + const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`) as HTMLDivElement; + if (blockElement) { + blockElement.textContent = ''; + blockElement.focus(); + } + }); + } + } + + this.closeSlashMenu(); + this.updateValue(); + } + + private updateValue() { + if (this.outputFormat === 'html') { + this.value = this.getHtmlOutput(); + } else { + this.value = this.getMarkdownOutput(); + } + this.changeSubject.next(this.value); + } + + private getHtmlOutput(): string { + return this.blocks.map(block => { + switch (block.type) { + case 'paragraph': + return block.content ? `

    ${this.escapeHtml(block.content)}

    ` : ''; + case 'heading-1': + return `

    ${this.escapeHtml(block.content)}

    `; + case 'heading-2': + return `

    ${this.escapeHtml(block.content)}

    `; + case 'heading-3': + return `

    ${this.escapeHtml(block.content)}

    `; + case 'quote': + return `
    ${this.escapeHtml(block.content)}
    `; + case 'code': + return `
    ${this.escapeHtml(block.content)}
    `; + case 'list': + const items = block.content.split('\n').filter(item => item.trim()); + if (items.length > 0) { + const listTag = block.metadata?.listType === 'ordered' ? 'ol' : 'ul'; + return `<${listTag}>${items.map(item => `
  • ${this.escapeHtml(item)}
  • `).join('')}`; + } + return ''; + case 'divider': + return '
    '; + default: + return `

    ${this.escapeHtml(block.content)}

    `; + } + }).filter(html => html !== '').join('\n'); + } + + private getMarkdownOutput(): string { + return this.blocks.map(block => { + switch (block.type) { + case 'paragraph': + return block.content; + case 'heading-1': + return `# ${block.content}`; + case 'heading-2': + return `## ${block.content}`; + case 'heading-3': + return `### ${block.content}`; + case 'quote': + return `> ${block.content}`; + case 'code': + return `\`\`\`\n${block.content}\n\`\`\``; + case 'list': + const items = block.content.split('\n').filter(item => item.trim()); + if (block.metadata?.listType === 'ordered') { + return items.map((item, index) => `${index + 1}. ${item}`).join('\n'); + } else { + return items.map(item => `- ${item}`).join('\n'); + } + case 'divider': + return '---'; + default: + return block.content; + } + }).filter(md => md !== '').join('\n\n'); + } + + private escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + public getValue(): string { + return this.value; + } + + public setValue(value: string): void { + this.value = value; + + if (this.outputFormat === 'html') { + this.blocks = this.parseHtmlToBlocks(value); + } else { + this.blocks = this.parseMarkdownToBlocks(value); + } + + if (this.blocks.length === 0) { + this.blocks = [{ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'paragraph', + content: '', + }]; + } + + this.changeSubject.next(this.value); + this.requestUpdate(); + } + + private parseHtmlToBlocks(html: string): IBlock[] { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const blocks: IBlock[] = []; + + const processNode = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'paragraph', + content: node.textContent.trim(), + }); + } else if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + const tagName = element.tagName.toLowerCase(); + + switch (tagName) { + case 'p': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'paragraph', + content: element.textContent || '', + }); + break; + case 'h1': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'heading-1', + content: element.textContent || '', + }); + break; + case 'h2': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'heading-2', + content: element.textContent || '', + }); + break; + case 'h3': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'heading-3', + content: element.textContent || '', + }); + break; + case 'blockquote': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'quote', + content: element.textContent || '', + }); + break; + case 'pre': + case 'code': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'code', + content: element.textContent || '', + }); + break; + case 'ul': + case 'ol': + const listItems = Array.from(element.querySelectorAll('li')); + const content = listItems.map(li => li.textContent || '').join('\n'); + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'list', + content: content, + metadata: { listType: tagName === 'ol' ? 'ordered' : 'bullet' } + }); + break; + case 'hr': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'divider', + content: ' ', + }); + break; + default: + // Process children for other elements + element.childNodes.forEach(child => processNode(child)); + } + } + }; + + doc.body.childNodes.forEach(node => processNode(node)); + return blocks; + } + + private parseMarkdownToBlocks(markdown: string): IBlock[] { + const lines = markdown.split('\n'); + const blocks: IBlock[] = []; + let currentListItems: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('# ')) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'heading-1', + content: line.substring(2), + }); + } else if (line.startsWith('## ')) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'heading-2', + content: line.substring(3), + }); + } else if (line.startsWith('### ')) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'heading-3', + content: line.substring(4), + }); + } else if (line.startsWith('> ')) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'quote', + content: line.substring(2), + }); + } else if (line.startsWith('```')) { + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'code', + content: codeLines.join('\n'), + }); + } else if (line.match(/^(\*|-) /)) { + currentListItems.push(line.substring(2)); + // Check if next line is not a list item + if (i === lines.length - 1 || (!lines[i + 1].match(/^(\*|-) /))) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'list', + content: currentListItems.join('\n'), + metadata: { listType: 'bullet' } + }); + currentListItems = []; + } + } else if (line.match(/^\d+\. /)) { + currentListItems.push(line.replace(/^\d+\. /, '')); + // Check if next line is not a numbered list item + if (i === lines.length - 1 || (!lines[i + 1].match(/^\d+\. /))) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'list', + content: currentListItems.join('\n'), + metadata: { listType: 'ordered' } + }); + currentListItems = []; + } + } else if (line === '---' || line === '***' || line === '___') { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'divider', + content: ' ', + }); + } else if (line.trim()) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'paragraph', + content: line, + }); + } + } + + return blocks; + } +} \ No newline at end of file diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 7a37d75..141bcb8 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -31,6 +31,7 @@ export * from './dees-input-fileupload.js'; export * from './dees-input-iban.js'; export * from './dees-input-typelist.js'; export * from './dees-input-phone.js'; +export * from './dees-input-wysiwyg.js'; export * from './dees-progressbar.js'; export * from './dees-input-quantityselector.js'; export * from './dees-input-radio.js';