From 662672602944c1531644cea9ba45480dfc05c52d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 23 Jun 2025 17:36:39 +0000 Subject: [PATCH] feat(editor): Add wysiwyg editor --- ts_web/elements/dees-input-wysiwyg.ts | 1136 +---------------- ts_web/elements/wysiwyg/dees-input-wysiwyg.ts | 564 ++++++++ ts_web/elements/wysiwyg/index.ts | 5 + ts_web/elements/wysiwyg/wysiwyg.blocks.ts | 98 ++ ts_web/elements/wysiwyg/wysiwyg.converters.ts | 244 ++++ ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts | 69 + ts_web/elements/wysiwyg/wysiwyg.styles.ts | 285 +++++ ts_web/elements/wysiwyg/wysiwyg.types.ts | 20 + 8 files changed, 1287 insertions(+), 1134 deletions(-) create mode 100644 ts_web/elements/wysiwyg/dees-input-wysiwyg.ts create mode 100644 ts_web/elements/wysiwyg/index.ts create mode 100644 ts_web/elements/wysiwyg/wysiwyg.blocks.ts create mode 100644 ts_web/elements/wysiwyg/wysiwyg.converters.ts create mode 100644 ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts create mode 100644 ts_web/elements/wysiwyg/wysiwyg.styles.ts create mode 100644 ts_web/elements/wysiwyg/wysiwyg.types.ts diff --git a/ts_web/elements/dees-input-wysiwyg.ts b/ts_web/elements/dees-input-wysiwyg.ts index 87ad86b..c999573 100644 --- a/ts_web/elements/dees-input-wysiwyg.ts +++ b/ts_web/elements/dees-input-wysiwyg.ts @@ -1,1134 +1,2 @@ -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 - this.requestUpdate(); - setTimeout(() => { - const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement; - if (blockElement) { - blockElement.focus(); - // Place cursor at the beginning for headings - const range = document.createRange(); - const sel = window.getSelection(); - range.selectNodeContents(blockElement); - range.collapse(true); - sel!.removeAllRanges(); - sel!.addRange(range); - } - }, 0); - - this.updateValue(); - return; - } - } - - // Check for list shortcuts - const listPatterns = [ - { pattern: /^[*-] $/, type: 'bullet' }, - { pattern: /^(\d+)\. $/, type: 'ordered' }, - { pattern: /^(\d+)\) $/, type: 'ordered' } - ]; - - // Debug: Log content when it starts with * or - - if (block.content.startsWith('*') || block.content.startsWith('-')) { - console.log('List shortcut check - content:', JSON.stringify(block.content), 'length:', block.content.length); - } - - 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 = ''; - - this.requestUpdate(); - setTimeout(() => { - const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement; - if (blockElement) { - blockElement.focus(); - const range = document.createRange(); - const sel = window.getSelection(); - range.selectNodeContents(blockElement); - range.collapse(true); - sel!.removeAllRanges(); - sel!.addRange(range); - } - }, 0); - - this.updateValue(); - return; - } - - // Check for code block shortcut - if (block.content === '```') { - e.preventDefault(); - block.type = 'code'; - block.content = ''; - - this.requestUpdate(); - setTimeout(() => { - const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement; - if (blockElement) { - blockElement.focus(); - const range = document.createRange(); - const sel = window.getSelection(); - range.selectNodeContents(blockElement); - range.collapse(true); - sel!.removeAllRanges(); - sel!.addRange(range); - } - }, 0); - - this.updateValue(); - 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 +// Re-export the modular component from the wysiwyg directory +export { DeesInputWysiwyg } from './wysiwyg/dees-input-wysiwyg.js'; \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts new file mode 100644 index 0000000..bf3c01c --- /dev/null +++ b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts @@ -0,0 +1,564 @@ +import { DeesInputBase } from '../dees-input-base.js'; +import { demoFunc } from '../dees-input-wysiwyg.demo.js'; + +import { + customElement, + type TemplateResult, + property, + html, + cssManager, + state, +} from '@design.estate/dees-element'; + +import { + type IBlock, + type OutputFormat, + wysiwygStyles, + WysiwygConverters, + WysiwygShortcuts, + WysiwygBlocks, + type ISlashMenuItem +} from './index.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-input-wysiwyg': DeesInputWysiwyg; + } +} + +@customElement('dees-input-wysiwyg') +export class DeesInputWysiwyg extends DeesInputBase { + public static demo = demoFunc; + + @property({ type: String }) + public value: string = ''; + + @property({ type: String }) + public outputFormat: OutputFormat = 'html'; + + @state() + private blocks: IBlock[] = [ + { + id: WysiwygShortcuts.generateBlockId(), + 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; + private saveTimeout: any = null; + + public static styles = [ + ...DeesInputBase.baseStyles, + cssManager.defaultStyles, + wysiwygStyles + ]; + + async firstUpdated() { + this.updateValue(); + this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement; + // Set initial content for blocks + this.setBlockContents(); + } + + updated(changedProperties: Map) { + // When blocks change (e.g., from setValue), update DOM content + if (changedProperties.has('blocks')) { + this.setBlockContents(); + } + } + + private setBlockContents() { + // Only set content for blocks that aren't being edited + this.blocks.forEach(block => { + const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement; + if (blockElement && document.activeElement !== blockElement && block.type !== 'divider') { + if (block.type === 'list') { + blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata); + } else { + blockElement.textContent = block.content; + } + } + }); + } + + 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; + + return WysiwygBlocks.renderBlock(block, isSelected, { + onInput: (e: InputEvent) => this.handleBlockInput(e, block), + onKeyDown: (e: KeyboardEvent) => this.handleBlockKeyDown(e, block), + onFocus: () => this.handleBlockFocus(block), + onBlur: () => this.handleBlockBlur(block), + onCompositionStart: () => this.isComposing = true, + onCompositionEnd: () => this.isComposing = false, + }); + } + + private getFilteredMenuItems(): ISlashMenuItem[] { + const allItems = WysiwygShortcuts.getSlashMenuItems(); + 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 block type change intents + const detectedType = this.detectBlockTypeIntent(block.content); + + // Only process if the detected type is different from current type + if (detectedType && detectedType.type !== block.type) { + e.preventDefault(); + + // Handle special cases + if (detectedType.type === 'list') { + block.type = 'list'; + block.content = ''; + block.metadata = { listType: detectedType.listType }; + + // Update list structure immediately + const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul'; + target.innerHTML = `<${listTag}>
  • `; + + // Force update and focus + this.updateValue(); + this.requestUpdate(); + + setTimeout(() => { + WysiwygBlocks.focusListItem(target); + }, 0); + + return; + } else if (detectedType.type === 'divider') { + 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: WysiwygShortcuts.generateBlockId(), + 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; + } else { + // For all other block types + block.type = detectedType.type; + block.content = ''; + + // Clear the DOM element immediately + target.textContent = ''; + + // Force update + this.updateValue(); + this.requestUpdate(); + + return; + } + } + + // Check for slash commands at the beginning of any block + if (block.content === '/' || (block.content.startsWith('/') && this.showSlashMenu)) { + // Only show menu on initial '/', or update filter if already showing + if (!this.showSlashMenu && block.content === '/') { + 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 + }; + } + this.slashMenuFilter = block.content.slice(1); + } else if (!block.content.startsWith('/')) { + this.closeSlashMenu(); + } + + // Don't update value on every input - let the browser handle typing normally + // But schedule a save after a delay + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + this.saveTimeout = setTimeout(() => { + this.updateValue(); + }, 1000); // Save after 1 second of inactivity + } + + 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: WysiwygShortcuts.generateBlockId(), + 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: WysiwygShortcuts.generateBlockId(), + type: 'paragraph', + content: '', + }; + + this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)]; + + this.updateValue(); + + 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); + + this.updateValue(); + + setTimeout(() => { + const prevBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${prevBlock.id}"]`) as HTMLDivElement; + if (prevBlockElement && prevBlock.type !== 'divider') { + prevBlockElement.focus(); + WysiwygBlocks.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() { + if (this.showSlashMenu && this.selectedBlockId) { + // Clear the slash command from the content if menu is closing without selection + const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId); + if (currentBlock && currentBlock.content.startsWith('/')) { + const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`) as HTMLDivElement; + if (blockElement) { + // Clear the slash command text + blockElement.textContent = ''; + currentBlock.content = ''; + // Ensure cursor stays in the block + blockElement.focus(); + } + } + } + + this.showSlashMenu = false; + this.slashMenuFilter = ''; + this.slashMenuSelectedIndex = 0; + } + + private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null { + // Check heading patterns + const headingResult = WysiwygShortcuts.checkHeadingShortcut(content); + if (headingResult) { + return headingResult; + } + + // Check list patterns + const listResult = WysiwygShortcuts.checkListShortcut(content); + if (listResult) { + return listResult; + } + + // Check quote pattern + if (WysiwygShortcuts.checkQuoteShortcut(content)) { + return { type: 'quote' }; + } + + // Check code pattern + if (WysiwygShortcuts.checkCodeShortcut(content)) { + return { type: 'code' }; + } + + // Check divider pattern + if (WysiwygShortcuts.checkDividerShortcut(content)) { + return { type: 'divider' }; + } + + // Don't automatically revert to paragraph - blocks should keep their type + // unless explicitly changed by the user + return null; + } + + private handleBlockFocus(block: IBlock) { + if (block.type !== 'divider') { + this.selectedBlockId = block.id; + } + } + + private handleBlockBlur(block: IBlock) { + // Update value on blur to ensure it's saved + this.updateValue(); + + setTimeout(() => { + if (this.selectedBlockId === block.id) { + this.selectedBlockId = null; + } + // Don't close slash menu on blur if clicking on menu item + const activeElement = document.activeElement; + const slashMenu = this.shadowRoot?.querySelector('.slash-menu'); + if (!slashMenu?.contains(activeElement as Node)) { + 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(); + WysiwygBlocks.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: WysiwygShortcuts.generateBlockId(), + 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 if (type === 'list') { + // Handle list type specially + currentBlock.metadata = { listType: 'bullet' }; // Default to bullet list + setTimeout(() => { + const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`) as HTMLDivElement; + if (blockElement) { + blockElement.innerHTML = '
    '; + WysiwygBlocks.focusListItem(blockElement); + } + }); + } 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 = WysiwygConverters.getHtmlOutput(this.blocks); + } else { + this.value = WysiwygConverters.getMarkdownOutput(this.blocks); + } + this.changeSubject.next(this.value); + } + + public getValue(): string { + return this.value; + } + + public setValue(value: string): void { + this.value = value; + + if (this.outputFormat === 'html') { + this.blocks = WysiwygConverters.parseHtmlToBlocks(value); + } else { + this.blocks = WysiwygConverters.parseMarkdownToBlocks(value); + } + + if (this.blocks.length === 0) { + this.blocks = [{ + id: WysiwygShortcuts.generateBlockId(), + type: 'paragraph', + content: '', + }]; + } + + this.changeSubject.next(this.value); + this.requestUpdate(); + } +} \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/index.ts b/ts_web/elements/wysiwyg/index.ts new file mode 100644 index 0000000..087411d --- /dev/null +++ b/ts_web/elements/wysiwyg/index.ts @@ -0,0 +1,5 @@ +export * from './wysiwyg.types.js'; +export * from './wysiwyg.styles.js'; +export * from './wysiwyg.converters.js'; +export * from './wysiwyg.shortcuts.js'; +export * from './wysiwyg.blocks.js'; \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.blocks.ts b/ts_web/elements/wysiwyg/wysiwyg.blocks.ts new file mode 100644 index 0000000..683626e --- /dev/null +++ b/ts_web/elements/wysiwyg/wysiwyg.blocks.ts @@ -0,0 +1,98 @@ +import { html, type TemplateResult } from '@design.estate/dees-element'; +import { type IBlock } from './wysiwyg.types.js'; +import { WysiwygConverters } from './wysiwyg.converters.js'; + +export class WysiwygBlocks { + static 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 => `
  • ${WysiwygConverters.escapeHtml(item)}
  • `).join('')}`; + } + + static renderBlock( + block: IBlock, + isSelected: boolean, + handlers: { + onInput: (e: InputEvent) => void; + onKeyDown: (e: KeyboardEvent) => void; + onFocus: () => void; + onBlur: () => void; + onCompositionStart: () => void; + onCompositionEnd: () => void; + } + ): TemplateResult { + if (block.type === 'divider') { + return html` +
    +
    +
    + `; + } + + if (block.type === 'list') { + return html` +
    + `; + } + + return html` +
    + `; + } + + static setCursorToEnd(element: HTMLElement): void { + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(element); + range.collapse(false); + sel!.removeAllRanges(); + sel!.addRange(range); + } + + static setCursorToStart(element: HTMLElement): void { + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(element); + range.collapse(true); + sel!.removeAllRanges(); + sel!.addRange(range); + } + + static focusListItem(listElement: HTMLElement): void { + const firstLi = listElement.querySelector('li'); + if (firstLi) { + firstLi.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(firstLi); + range.collapse(true); + sel!.removeAllRanges(); + sel!.addRange(range); + } + } +} \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.converters.ts b/ts_web/elements/wysiwyg/wysiwyg.converters.ts new file mode 100644 index 0000000..542ec39 --- /dev/null +++ b/ts_web/elements/wysiwyg/wysiwyg.converters.ts @@ -0,0 +1,244 @@ +import { type IBlock } from './wysiwyg.types.js'; + +export class WysiwygConverters { + static escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + static getHtmlOutput(blocks: IBlock[]): string { + return 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'); + } + + static getMarkdownOutput(blocks: IBlock[]): string { + return 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'); + } + + static 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).substring(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).substring(2, 9)}`, + type: 'paragraph', + content: element.textContent || '', + }); + break; + case 'h1': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + type: 'heading-1', + content: element.textContent || '', + }); + break; + case 'h2': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + type: 'heading-2', + content: element.textContent || '', + }); + break; + case 'h3': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + type: 'heading-3', + content: element.textContent || '', + }); + break; + case 'blockquote': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + type: 'quote', + content: element.textContent || '', + }); + break; + case 'pre': + case 'code': + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(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).substring(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).substring(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; + } + + static 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).substring(2, 9)}`, + type: 'heading-1', + content: line.substring(2), + }); + } else if (line.startsWith('## ')) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + type: 'heading-2', + content: line.substring(3), + }); + } else if (line.startsWith('### ')) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + type: 'heading-3', + content: line.substring(4), + }); + } else if (line.startsWith('> ')) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(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).substring(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).substring(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).substring(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).substring(2, 9)}`, + type: 'divider', + content: ' ', + }); + } else if (line.trim()) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + type: 'paragraph', + content: line, + }); + } + } + + return blocks; + } +} \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts b/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts new file mode 100644 index 0000000..93f076c --- /dev/null +++ b/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts @@ -0,0 +1,69 @@ +import { type IBlock, type IShortcutPattern, type ISlashMenuItem } from './wysiwyg.types.js'; + +export class WysiwygShortcuts { + static readonly HEADING_PATTERNS: IShortcutPattern[] = [ + { pattern: /^#[\s\u00A0]$/, type: 'heading-1' }, + { pattern: /^##[\s\u00A0]$/, type: 'heading-2' }, + { pattern: /^###[\s\u00A0]$/, type: 'heading-3' } + ]; + + static readonly LIST_PATTERNS: IShortcutPattern[] = [ + { pattern: /^[*-][\s\u00A0]$/, type: 'bullet' }, + { pattern: /^(\d+)\.[\s\u00A0]$/, type: 'ordered' }, + { pattern: /^(\d+)\)[\s\u00A0]$/, type: 'ordered' } + ]; + + static readonly QUOTE_PATTERN = /^>[\s\u00A0]$/; + static readonly CODE_PATTERN = /^```$/; + static readonly DIVIDER_PATTERNS = ['---', '***', '___']; + + static checkHeadingShortcut(content: string): { type: IBlock['type'] } | null { + for (const { pattern, type } of this.HEADING_PATTERNS) { + if (pattern.test(content)) { + return { type: type as IBlock['type'] }; + } + } + return null; + } + + static checkListShortcut(content: string): { type: 'list', listType: 'bullet' | 'ordered' } | null { + for (const { pattern, type } of this.LIST_PATTERNS) { + if (pattern.test(content)) { + return { type: 'list', listType: type as 'bullet' | 'ordered' }; + } + } + return null; + } + + static checkQuoteShortcut(content: string): boolean { + return this.QUOTE_PATTERN.test(content); + } + + static checkCodeShortcut(content: string): boolean { + return this.CODE_PATTERN.test(content); + } + + static checkDividerShortcut(content: string): boolean { + return this.DIVIDER_PATTERNS.includes(content); + } + + static getSlashMenuItems(): ISlashMenuItem[] { + return [ + { 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: '—' }, + ]; + } + + static generateBlockId(): string { + return `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + } +} + +// Re-export the type that is used in this module +export type { ISlashMenuItem } from './wysiwyg.types.js'; \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.styles.ts b/ts_web/elements/wysiwyg/wysiwyg.styles.ts new file mode 100644 index 0000000..024741e --- /dev/null +++ b/ts_web/elements/wysiwyg/wysiwyg.styles.ts @@ -0,0 +1,285 @@ +import { css, cssManager } from '@design.estate/dees-element'; + +export const wysiwygStyles = 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')}; + } +`; \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.types.ts b/ts_web/elements/wysiwyg/wysiwyg.types.ts new file mode 100644 index 0000000..daa8a42 --- /dev/null +++ b/ts_web/elements/wysiwyg/wysiwyg.types.ts @@ -0,0 +1,20 @@ +export interface IBlock { + id: string; + type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider'; + content: string; + metadata?: any; +} + +export interface ISlashMenuItem { + type: string; + label: string; + icon: string; + action?: () => void; +} + +export interface IShortcutPattern { + pattern: RegExp; + type: string; +} + +export type OutputFormat = 'html' | 'markdown'; \ No newline at end of file