From 68b4e9ec8e90bce742a557aaf607cd3f030618ce Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 24 Jun 2025 20:32:03 +0000 Subject: [PATCH] feat(wysiwyg): Add more block types --- ts_web/elements/wysiwyg/dees-input-wysiwyg.ts | 27 +- ts_web/elements/wysiwyg/dees-wysiwyg-block.ts | 907 +++++++++++++++++- ts_web/elements/wysiwyg/wysiwyg.converters.ts | 47 + .../wysiwyg/wysiwyg.keyboardhandler.ts | 65 +- ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts | 4 + ts_web/elements/wysiwyg/wysiwyg.types.ts | 2 +- 6 files changed, 998 insertions(+), 54 deletions(-) diff --git a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts index 09e1a8b..c48bf38 100644 --- a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts +++ b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts @@ -131,7 +131,6 @@ export class DeesInputWysiwyg extends DeesInputBase { // Listen for custom selection events from blocks this.addEventListener('block-text-selected', (e: CustomEvent) => { - console.log('Received block-text-selected event:', e.detail); if (!this.slashMenu.visible && e.detail.hasSelection && e.detail.text.length > 0) { this.selectedText = e.detail.text; @@ -143,7 +142,6 @@ export class DeesInputWysiwyg extends DeesInputBase { y: Math.max(45, e.detail.rect.top - 45) }; - console.log('Showing formatting menu at:', coords); // Show the formatting menu at the calculated position this.formattingMenu.show( @@ -533,6 +531,20 @@ export class DeesInputWysiwyg extends DeesInputBase { // For image blocks, clear content and set empty metadata currentBlock.content = ''; currentBlock.metadata = { url: '', loading: false }; + } else if (type === 'youtube') { + // For YouTube blocks, clear content and set empty metadata + currentBlock.content = ''; + currentBlock.metadata = { videoId: '', url: '' }; + } else if (type === 'markdown') { + // For Markdown blocks, preserve content and default to edit mode + currentBlock.metadata = { showPreview: false }; + } else if (type === 'html') { + // For HTML blocks, preserve content and default to edit mode + currentBlock.metadata = { showPreview: false }; + } else if (type === 'attachment') { + // For attachment blocks, clear content and set empty files array + currentBlock.content = ''; + currentBlock.metadata = { files: [] }; } else { // For all other block types, ensure content is clean currentBlock.content = currentBlock.content || ''; @@ -556,10 +568,10 @@ export class DeesInputWysiwyg extends DeesInputBase { blockComponent.focusListItem(); } }); - } else if (type !== 'divider' && type !== 'image') { + } else if (type !== 'divider' && type !== 'image' && type !== 'youtube' && type !== 'markdown' && type !== 'html' && type !== 'attachment') { this.blockOperations.focusBlock(currentBlock.id, 'start'); - } else if (type === 'image') { - // Focus the image block (which will show the upload interface) + } else if (type === 'image' || type === 'youtube' || type === 'markdown' || type === 'html' || type === 'attachment') { + // Focus the non-editable block this.blockOperations.focusBlock(currentBlock.id); } } @@ -733,7 +745,6 @@ export class DeesInputWysiwyg extends DeesInputBase { private updateFormattingMenuPosition(): void { - console.log('updateFormattingMenuPosition called'); // Get all shadow roots const shadowRoots: ShadowRoot[] = []; @@ -749,7 +760,6 @@ export class DeesInputWysiwyg extends DeesInputBase { }); const coords = WysiwygFormatting.getSelectionCoordinates(...shadowRoots); - console.log('Selection coordinates:', coords); if (coords) { // Show the global formatting menu at absolute coordinates @@ -758,7 +768,6 @@ export class DeesInputWysiwyg extends DeesInputBase { async (command: string) => await this.applyFormat(command) ); } else { - console.log('No coordinates found'); } } @@ -924,7 +933,6 @@ export class DeesInputWysiwyg extends DeesInputBase { * Undo the last action */ private undo(): void { - console.log('Undo triggered'); const state = this.history.undo(); if (state) { this.restoreState(state); @@ -935,7 +943,6 @@ export class DeesInputWysiwyg extends DeesInputBase { * Redo the next action */ private redo(): void { - console.log('Redo triggered'); const state = this.history.redo(); if (state) { this.restoreState(state); diff --git a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts index 609338a..32a575c 100644 --- a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts +++ b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts @@ -368,12 +368,345 @@ export class DeesWysiwygBlock extends DeesElement { input[type="file"] { display: none; } + + /* YouTube block styles */ + .block.youtube { + padding: 0; + margin: 16px 0; + border-radius: 8px; + overflow: hidden; + position: relative; + cursor: pointer; + transition: all 0.15s ease; + } + + .block.youtube:focus { + outline: none; + } + + .block.youtube.selected { + box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')}; + } + + .youtube-container { + position: relative; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + height: 0; + overflow: hidden; + } + + .youtube-container iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } + + .youtube-placeholder { + background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')}; + border: 2px dashed ${cssManager.bdTheme('#d0d0d0', '#404040')}; + border-radius: 8px; + padding: 40px; + text-align: center; + } + + .placeholder-icon { + font-size: 48px; + margin-bottom: 16px; + } + + .placeholder-text { + font-size: 16px; + color: ${cssManager.bdTheme('#666', '#999')}; + margin-bottom: 16px; + } + + .youtube-url-input { + width: 100%; + max-width: 400px; + padding: 12px; + border: 1px solid ${cssManager.bdTheme('#ddd', '#444')}; + border-radius: 6px; + font-size: 14px; + margin-bottom: 16px; + background: ${cssManager.bdTheme('#fff', '#222')}; + color: ${cssManager.bdTheme('#000', '#fff')}; + } + + .youtube-embed-btn { + padding: 10px 24px; + background: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; + } + + .youtube-embed-btn:hover { + background: ${cssManager.bdTheme('#0052a3', '#3d7dd9')}; + } + + /* Markdown block styles */ + .block.markdown { + padding: 0; + margin: 16px 0; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: all 0.15s ease; + } + + .block.markdown:focus { + outline: none; + } + + .block.markdown.selected { + box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')}; + } + + .markdown-toolbar, + .html-toolbar { + background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')}; + padding: 8px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; + } + + .markdown-label, + .html-label { + font-size: 12px; + text-transform: uppercase; + color: ${cssManager.bdTheme('#666', '#999')}; + font-weight: 500; + } + + .toggle-preview { + padding: 6px 12px; + background: ${cssManager.bdTheme('#fff', '#333')}; + border: 1px solid ${cssManager.bdTheme('#ddd', '#555')}; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + } + + .toggle-preview:hover { + background: ${cssManager.bdTheme('#f0f0f0', '#444')}; + } + + .markdown-content, + .html-content { + min-height: 200px; + } + + .markdown-editor, + .html-editor { + width: 100%; + min-height: 200px; + padding: 16px; + border: none; + resize: vertical; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; + font-size: 14px; + background: ${cssManager.bdTheme('#fff', '#1a1a1a')}; + color: ${cssManager.bdTheme('#000', '#fff')}; + } + + .markdown-preview, + .html-preview { + padding: 16px; + min-height: 200px; + background: ${cssManager.bdTheme('#fff', '#1a1a1a')}; + } + + .markdown-preview h1, + .markdown-preview h2, + .markdown-preview h3 { + margin-top: 16px; + margin-bottom: 8px; + } + + .markdown-preview h1:first-child, + .markdown-preview h2:first-child, + .markdown-preview h3:first-child { + margin-top: 0; + } + + /* HTML block styles */ + .block.html { + padding: 0; + margin: 16px 0; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: all 0.15s ease; + } + + .block.html:focus { + outline: none; + } + + .block.html.selected { + box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')}; + } + + /* Attachment block styles */ + .block.attachment { + padding: 0; + margin: 16px 0; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: all 0.15s ease; + } + + .block.attachment:focus { + outline: none; + } + + .block.attachment.selected { + box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')}; + } + + .block.attachment.drag-over { + background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')}; + } + + .attachment-header { + background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')}; + padding: 16px; + display: flex; + align-items: center; + gap: 12px; + border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; + } + + .attachment-icon { + font-size: 24px; + } + + .attachment-title { + font-size: 16px; + font-weight: 500; + } + + .attachment-list { + padding: 16px; + min-height: 100px; + } + + .attachment-placeholder { + text-align: center; + padding: 40px; + border: 2px dashed ${cssManager.bdTheme('#d0d0d0', '#404040')}; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + } + + .attachment-placeholder:hover { + background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')}; + border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + } + + .placeholder-hint { + font-size: 13px; + color: ${cssManager.bdTheme('#999', '#666')}; + margin-top: 8px; + } + + .attachment-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: ${cssManager.bdTheme('#f8f8f8', '#222')}; + border-radius: 6px; + margin-bottom: 8px; + transition: background 0.2s ease; + } + + .attachment-item:hover { + background: ${cssManager.bdTheme('#f0f0f0', '#2a2a2a')}; + } + + .file-icon { + font-size: 24px; + flex-shrink: 0; + } + + .file-info { + flex: 1; + min-width: 0; + } + + .file-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .file-size { + font-size: 12px; + color: ${cssManager.bdTheme('#666', '#999')}; + } + + .remove-file { + width: 24px; + height: 24px; + border: none; + background: transparent; + color: ${cssManager.bdTheme('#999', '#666')}; + font-size: 20px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: all 0.2s ease; + } + + .attachment-item:hover .remove-file { + opacity: 1; + } + + .remove-file:hover { + color: ${cssManager.bdTheme('#d32f2f', '#f44336')}; + } + + .add-more-files { + width: 100%; + padding: 10px; + background: transparent; + border: 1px dashed ${cssManager.bdTheme('#ddd', '#444')}; + border-radius: 6px; + color: ${cssManager.bdTheme('#666', '#999')}; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + } + + .add-more-files:hover { + background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')}; + border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + } `, ]; protected shouldUpdate(changedProperties: Map): boolean { // If selection state changed, we need to update for non-editable blocks - if (changedProperties.has('isSelected') && (this.block?.type === 'divider' || this.block?.type === 'image')) { + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (changedProperties.has('isSelected') && this.block && nonEditableTypes.includes(this.block.type)) { // For non-editable blocks, we need to update the selected class const element = this.shadowRoot?.querySelector('.block') as HTMLElement; if (element) { @@ -416,6 +749,18 @@ export class DeesWysiwygBlock extends DeesElement { } else if (this.block.type === 'divider') { this.setupDividerBlock(); return; // Divider blocks don't need the standard editable setup + } else if (this.block.type === 'youtube') { + this.setupYouTubeBlock(); + return; + } else if (this.block.type === 'markdown') { + this.setupMarkdownBlock(); + return; + } else if (this.block.type === 'html') { + this.setupHtmlBlock(); + return; + } else if (this.block.type === 'attachment') { + this.setupAttachmentBlock(); + return; } // Now find the actual editable block element @@ -470,7 +815,6 @@ export class DeesWysiwygBlock extends DeesElement { const pos = this.getCursorPosition(editableBlock); if (pos !== null) { this.lastKnownCursorPosition = pos; - console.log('Cursor position after mouseup:', pos); } // Selection will be handled by selectionchange event @@ -483,7 +827,6 @@ export class DeesWysiwygBlock extends DeesElement { const pos = this.getCursorPosition(editableBlock); if (pos !== null) { this.lastKnownCursorPosition = pos; - console.log('Cursor position after click:', pos); } }, 0); }); @@ -538,7 +881,6 @@ export class DeesWysiwygBlock extends DeesElement { if (startInBlock || endInBlock) { if (selectedText !== this.lastSelectedText) { this.lastSelectedText = selectedText; - console.log('✅ Selection detected in block using getComposedRanges:', selectedText); // Create range and get rect const range = WysiwygSelection.createRangeFromInfo(selectionInfo); @@ -664,6 +1006,113 @@ export class DeesWysiwygBlock extends DeesElement { `; } + if (this.block.type === 'youtube') { + const selectedClass = this.isSelected ? ' selected' : ''; + const videoId = this.block.metadata?.videoId || ''; + const url = this.block.metadata?.url || ''; + + return ` +
+ ${videoId ? ` +
+ +
+ ` : ` +
+
▶️
+
Enter YouTube URL
+ + +
+ `} +
+ `; + } + + if (this.block.type === 'markdown') { + const selectedClass = this.isSelected ? ' selected' : ''; + const showPreview = this.block.metadata?.showPreview !== false; + + return ` +
+
+ + Markdown +
+
+ ${showPreview ? ` +
+ ` : ` + + `} +
+
+ `; + } + + if (this.block.type === 'html') { + const selectedClass = this.isSelected ? ' selected' : ''; + const showPreview = this.block.metadata?.showPreview !== false; + + return ` +
+
+ + HTML +
+
+ ${showPreview ? ` +
+ ` : ` + + `} +
+
+ `; + } + + if (this.block.type === 'attachment') { + const selectedClass = this.isSelected ? ' selected' : ''; + const files = this.block.metadata?.files || []; + + return ` +
+
+
📎
+
File Attachments
+
+
+ ${files.length > 0 ? files.map((file: any) => ` +
+
${this.getFileIcon(file.type)}
+
+
${file.name}
+
${this.formatFileSize(file.size)}
+
+ +
+ `).join('') : ` +
+
Click to add files
+
or drag and drop
+
+ `} +
+ + ${files.length > 0 ? '' : ''} +
+ `; + } + const placeholder = this.getPlaceholder(); const selectedClass = this.isSelected ? ' selected' : ''; return ` @@ -697,16 +1146,11 @@ export class DeesWysiwygBlock extends DeesElement { public focus(): void { // Handle non-editable blocks - if (this.block?.type === 'image') { - const imageBlock = this.shadowRoot?.querySelector('.block.image') as HTMLDivElement; - if (imageBlock) { - imageBlock.focus(); - } - return; - } else if (this.block?.type === 'divider') { - const dividerBlock = this.shadowRoot?.querySelector('.block.divider') as HTMLDivElement; - if (dividerBlock) { - dividerBlock.focus(); + const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment']; + if (this.block && nonEditableTypes.includes(this.block.type)) { + const blockElement = this.shadowRoot?.querySelector(`.block.${this.block.type}`) as HTMLDivElement; + if (blockElement) { + blockElement.focus(); } return; } @@ -735,7 +1179,8 @@ export class DeesWysiwygBlock extends DeesElement { public focusWithCursor(position: 'start' | 'end' | number = 'end'): void { // Non-editable blocks don't support cursor positioning - if (this.block?.type === 'image' || this.block?.type === 'divider') { + const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment']; + if (this.block && nonEditableTypes.includes(this.block.type)) { this.focus(); return; } @@ -950,7 +1395,439 @@ export class DeesWysiwygBlock extends DeesElement { } }); } + + /** + * Setup YouTube block functionality + */ + private setupYouTubeBlock(): void { + const youtubeBlock = this.shadowRoot?.querySelector('.block.youtube') as HTMLDivElement; + if (!youtubeBlock) return; + + // Handle click to select + youtubeBlock.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (!target.classList.contains('youtube-url-input') && !target.classList.contains('youtube-embed-btn')) { + e.stopPropagation(); + youtubeBlock.focus(); + this.handlers?.onFocus?.(); + } + }); + + // Handle URL input and embed button + const urlInput = youtubeBlock.querySelector('.youtube-url-input') as HTMLInputElement; + const embedBtn = youtubeBlock.querySelector('.youtube-embed-btn') as HTMLButtonElement; + + if (urlInput && embedBtn) { + const embedVideo = () => { + const url = urlInput.value.trim(); + if (url) { + // Extract video ID from YouTube URL + const videoId = this.extractYouTubeVideoId(url); + if (videoId) { + this.block.metadata = { ...this.block.metadata, videoId, url }; + this.block.content = url; // Store URL as content + + // Re-render the block + const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement; + if (container) { + container.innerHTML = this.renderBlockContent(); + this.setupYouTubeBlock(); // Re-setup event handlers + } + + // Notify parent of change + this.handlers?.onInput?.(new InputEvent('input')); + } else { + alert('Invalid YouTube URL'); + } + } + }; + + embedBtn.addEventListener('click', embedVideo); + urlInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + embedVideo(); + } + }); + } + + // Handle focus/blur + youtubeBlock.addEventListener('focus', () => this.handlers?.onFocus?.()); + youtubeBlock.addEventListener('blur', () => this.handlers?.onBlur?.()); + + // Handle keyboard events + youtubeBlock.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + this.handlers?.onKeyDown?.(e); + } else { + this.handlers?.onKeyDown?.(e); + } + }); + } + + /** + * Setup Markdown block functionality + */ + private setupMarkdownBlock(): void { + const markdownBlock = this.shadowRoot?.querySelector('.block.markdown') as HTMLDivElement; + if (!markdownBlock) return; + + // Handle click to select + markdownBlock.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (!target.classList.contains('markdown-editor') && !target.classList.contains('toggle-preview')) { + e.stopPropagation(); + markdownBlock.focus(); + this.handlers?.onFocus?.(); + } + }); + + // Handle preview toggle + const toggleBtn = markdownBlock.querySelector('.toggle-preview') as HTMLButtonElement; + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + const showPreview = toggleBtn.dataset.active !== 'true'; + this.block.metadata = { ...this.block.metadata, showPreview }; + + // Re-render + const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement; + if (container) { + container.innerHTML = this.renderBlockContent(); + this.setupMarkdownBlock(); + + // If switching to preview, render markdown + if (showPreview) { + this.renderMarkdownPreview(); + } + } + }); + } + + // Handle editor input + const editor = markdownBlock.querySelector('.markdown-editor') as HTMLTextAreaElement; + if (editor) { + editor.addEventListener('input', () => { + this.block.content = editor.value; + this.handlers?.onInput?.(new InputEvent('input')); + }); + + // Auto-resize textarea + const autoResize = () => { + editor.style.height = 'auto'; + editor.style.height = editor.scrollHeight + 'px'; + }; + editor.addEventListener('input', autoResize); + autoResize(); + } + + // Render preview if needed + if (this.block.metadata?.showPreview) { + this.renderMarkdownPreview(); + } + + // Handle focus/blur + markdownBlock.addEventListener('focus', () => this.handlers?.onFocus?.()); + markdownBlock.addEventListener('blur', () => this.handlers?.onBlur?.()); + + // Handle keyboard events + markdownBlock.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + this.handlers?.onKeyDown?.(e); + } else { + this.handlers?.onKeyDown?.(e); + } + }); + } + + /** + * Setup HTML block functionality + */ + private setupHtmlBlock(): void { + const htmlBlock = this.shadowRoot?.querySelector('.block.html') as HTMLDivElement; + if (!htmlBlock) return; + + // Handle click to select + htmlBlock.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (!target.classList.contains('html-editor') && !target.classList.contains('toggle-preview')) { + e.stopPropagation(); + htmlBlock.focus(); + this.handlers?.onFocus?.(); + } + }); + + // Handle preview toggle + const toggleBtn = htmlBlock.querySelector('.toggle-preview') as HTMLButtonElement; + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + const showPreview = toggleBtn.dataset.active !== 'true'; + this.block.metadata = { ...this.block.metadata, showPreview }; + + // Re-render + const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement; + if (container) { + container.innerHTML = this.renderBlockContent(); + this.setupHtmlBlock(); + + // If switching to preview, render HTML + if (showPreview) { + this.renderHtmlPreview(); + } + } + }); + } + + // Handle editor input + const editor = htmlBlock.querySelector('.html-editor') as HTMLTextAreaElement; + if (editor) { + editor.addEventListener('input', () => { + this.block.content = editor.value; + this.handlers?.onInput?.(new InputEvent('input')); + }); + + // Auto-resize textarea + const autoResize = () => { + editor.style.height = 'auto'; + editor.style.height = editor.scrollHeight + 'px'; + }; + editor.addEventListener('input', autoResize); + autoResize(); + } + + // Render preview if needed + if (this.block.metadata?.showPreview) { + this.renderHtmlPreview(); + } + + // Handle focus/blur + htmlBlock.addEventListener('focus', () => this.handlers?.onFocus?.()); + htmlBlock.addEventListener('blur', () => this.handlers?.onBlur?.()); + + // Handle keyboard events + htmlBlock.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + this.handlers?.onKeyDown?.(e); + } else { + this.handlers?.onKeyDown?.(e); + } + }); + } + + /** + * Setup Attachment block functionality + */ + private setupAttachmentBlock(): void { + const attachmentBlock = this.shadowRoot?.querySelector('.block.attachment') as HTMLDivElement; + if (!attachmentBlock) return; + + // Handle click to select + attachmentBlock.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (!target.classList.contains('remove-file')) { + e.stopPropagation(); + attachmentBlock.focus(); + this.handlers?.onFocus?.(); + } + }); + + // Handle file input + const fileInput = attachmentBlock.querySelector('input[type="file"]') as HTMLInputElement; + const placeholder = attachmentBlock.querySelector('.attachment-placeholder'); + const addMoreBtn = attachmentBlock.querySelector('.add-more-files') as HTMLButtonElement; + + const triggerFileInput = () => { + if (fileInput) fileInput.click(); + }; + + if (placeholder) { + placeholder.addEventListener('click', triggerFileInput); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', triggerFileInput); + } + + if (fileInput) { + fileInput.addEventListener('change', async (e) => { + const files = Array.from((e.target as HTMLInputElement).files || []); + if (files.length > 0) { + await this.handleFileAttachments(files); + } + }); + } + + // Handle file removal + attachmentBlock.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains('remove-file')) { + const fileId = target.dataset.fileId; + if (fileId) { + const files = this.block.metadata?.files || []; + this.block.metadata = { + ...this.block.metadata, + files: files.filter((f: any) => f.id !== fileId) + }; + + // Re-render + const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement; + if (container) { + container.innerHTML = this.renderBlockContent(); + this.setupAttachmentBlock(); + } + + this.handlers?.onInput?.(new InputEvent('input')); + } + } + }); + + // Handle drag and drop + attachmentBlock.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + attachmentBlock.classList.add('drag-over'); + }); + + attachmentBlock.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + attachmentBlock.classList.remove('drag-over'); + }); + + attachmentBlock.addEventListener('drop', async (e) => { + e.preventDefault(); + e.stopPropagation(); + attachmentBlock.classList.remove('drag-over'); + + const files = Array.from(e.dataTransfer?.files || []); + if (files.length > 0) { + await this.handleFileAttachments(files); + } + }); + + // Handle focus/blur + attachmentBlock.addEventListener('focus', () => this.handlers?.onFocus?.()); + attachmentBlock.addEventListener('blur', () => this.handlers?.onBlur?.()); + + // Handle keyboard events + attachmentBlock.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + this.handlers?.onKeyDown?.(e); + } else { + this.handlers?.onKeyDown?.(e); + } + }); + } + + /** + * Extract YouTube video ID from URL + */ + private extractYouTubeVideoId(url: string): string | null { + const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/; + const match = url.match(regex); + return match ? match[1] : null; + } + + /** + * Render Markdown preview + */ + private async renderMarkdownPreview(): Promise { + const preview = this.shadowRoot?.querySelector('.markdown-preview') as HTMLDivElement; + if (!preview || !this.block.content) return; + + // Simple markdown to HTML conversion (you might want to use a proper markdown parser) + let html = this.block.content + .replace(/^### (.*$)/gim, '

$1

') + .replace(/^## (.*$)/gim, '

$1

') + .replace(/^# (.*$)/gim, '

$1

') + .replace(/\*\*(.*)\*\*/g, '$1') + .replace(/\*(.*)\*/g, '$1') + .replace(/\[([^\]]*)\]\(([^\)]*)\)/g, '$1') + .replace(/\n/g, '
'); + + preview.innerHTML = html; + } + + /** + * Render HTML preview + */ + private renderHtmlPreview(): void { + const preview = this.shadowRoot?.querySelector('.html-preview') as HTMLDivElement; + if (!preview || !this.block.content) return; + + // Render HTML in a sandboxed way + preview.innerHTML = this.block.content; + } + + /** + * Handle file attachments + */ + private async handleFileAttachments(files: File[]): Promise { + const existingFiles = this.block.metadata?.files || []; + const newFiles: any[] = []; + + for (const file of files) { + // Convert to base64 for storage (in production, upload to server) + const reader = new FileReader(); + const base64 = await new Promise((resolve) => { + reader.onload = (e) => resolve(e.target?.result as string); + reader.readAsDataURL(file); + }); + + newFiles.push({ + id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + name: file.name, + size: file.size, + type: file.type, + data: base64 + }); + } + + this.block.metadata = { + ...this.block.metadata, + files: [...existingFiles, ...newFiles] + }; + + // Re-render + const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement; + if (container) { + container.innerHTML = this.renderBlockContent(); + this.setupAttachmentBlock(); + } + + this.handlers?.onInput?.(new InputEvent('input')); + } + /** + * Get file icon based on mime type + */ + private getFileIcon(mimeType: string): string { + if (mimeType.startsWith('image/')) return '🖼️'; + if (mimeType.startsWith('video/')) return '🎥'; + if (mimeType.startsWith('audio/')) return '🎵'; + if (mimeType.includes('pdf')) return '📄'; + if (mimeType.includes('zip') || mimeType.includes('compressed')) return '🗄️'; + if (mimeType.includes('sheet') || mimeType.includes('excel')) return '📊'; + if (mimeType.includes('document') || mimeType.includes('word')) return '📝'; + if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📋'; + if (mimeType.includes('text')) return '📃'; + return '📁'; + } + + /** + * Format file size to human readable + */ + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + /** * Setup image block functionality */ diff --git a/ts_web/elements/wysiwyg/wysiwyg.converters.ts b/ts_web/elements/wysiwyg/wysiwyg.converters.ts index 85ecbe1..550d25e 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.converters.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.converters.ts @@ -7,6 +7,14 @@ export class WysiwygConverters { return div.innerHTML; } + static formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + static getHtmlOutput(blocks: IBlock[]): string { return blocks.map(block => { // Check if content already contains HTML formatting @@ -44,6 +52,29 @@ export class WysiwygConverters { return `${altText}`; } return ''; + case 'youtube': + const videoId = block.metadata?.videoId; + if (videoId) { + return ``; + } + return ''; + case 'markdown': + // Return the raw markdown content wrapped in a div + return `
${this.escapeHtml(block.content)}
`; + case 'html': + // Return the raw HTML content (already HTML) + return block.content; + case 'attachment': + const files = block.metadata?.files || []; + if (files.length > 0) { + return `
${files.map((file: any) => + `
+ ${this.escapeHtml(file.name)} + (${this.formatFileSize(file.size)}) +
` + ).join('')}
`; + } + return ''; default: return `

${content}

`; } @@ -78,6 +109,22 @@ export class WysiwygConverters { const imageUrl = block.metadata?.url; const altText = block.content || 'Image'; return imageUrl ? `![${altText}](${imageUrl})` : ''; + case 'youtube': + const videoId = block.metadata?.videoId; + const url = block.metadata?.url || (videoId ? `https://youtube.com/watch?v=${videoId}` : ''); + return url ? `[YouTube Video](${url})` : ''; + case 'markdown': + // Return the raw markdown content + return block.content; + case 'html': + // Return as HTML comment in markdown + return ``; + case 'attachment': + const files = block.metadata?.files || []; + if (files.length > 0) { + return files.map((file: any) => `- [${file.name}](${file.data})`).join('\n'); + } + return ''; default: return block.content; } diff --git a/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts b/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts index 16b0b57..561584a 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts @@ -233,8 +233,9 @@ export class WysiwygKeyboardHandler { private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise { const blockOps = this.component.blockOperations; - // Handle non-editable blocks (divider, image) - if (block.type === 'divider' || block.type === 'image') { + // Handle non-editable blocks + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (nonEditableTypes.includes(block.type)) { e.preventDefault(); // If it's the only block, delete it and create a new paragraph @@ -311,13 +312,13 @@ export class WysiwygKeyboardHandler { const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { - // If previous block is non-editable (divider/image), select it first - if (prevBlock.type === 'divider' || prevBlock.type === 'image') { + // If previous block is non-editable, select it first + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (nonEditableTypes.includes(prevBlock.type)) { await blockOps.focusBlock(prevBlock.id); return; } - console.log('Backspace at start: Merging with previous block'); // Save checkpoint for undo this.component.saveToHistory(false); @@ -397,8 +398,9 @@ export class WysiwygKeyboardHandler { private async handleDelete(e: KeyboardEvent, block: IBlock): Promise { const blockOps = this.component.blockOperations; - // Handle non-editable blocks (divider, image) - same as backspace - if (block.type === 'divider' || block.type === 'image') { + // Handle non-editable blocks - same as backspace + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (nonEditableTypes.includes(block.type)) { e.preventDefault(); // If it's the only block, delete it and create a new paragraph @@ -435,9 +437,10 @@ export class WysiwygKeyboardHandler { blockOps.removeBlock(block.id); // Focus the appropriate block - if (nextBlock && nextBlock.type !== 'divider' && nextBlock.type !== 'image') { + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (nextBlock && !nonEditableTypes.includes(nextBlock.type)) { await blockOps.focusBlock(nextBlock.id, 'start'); - } else if (prevBlock && prevBlock.type !== 'divider' && prevBlock.type !== 'image') { + } else if (prevBlock && !nonEditableTypes.includes(prevBlock.type)) { await blockOps.focusBlock(prevBlock.id, 'end'); } else if (nextBlock) { // If next block is also non-editable, just select it @@ -474,7 +477,8 @@ export class WysiwygKeyboardHandler { if (cursorPos === textLength) { const nextBlock = blockOps.getNextBlock(block.id); - if (nextBlock && (nextBlock.type === 'divider' || nextBlock.type === 'image')) { + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (nextBlock && nonEditableTypes.includes(nextBlock.type)) { e.preventDefault(); await blockOps.focusBlock(nextBlock.id); return; @@ -489,13 +493,14 @@ export class WysiwygKeyboardHandler { */ private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise { // For non-editable blocks, always navigate to previous block - if (block.type === 'divider' || block.type === 'image') { + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (nonEditableTypes.includes(block.type)) { e.preventDefault(); const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { - await blockOps.focusBlock(prevBlock.id, prevBlock.type === 'divider' || prevBlock.type === 'image' ? undefined : 'end'); + await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end'); } return; } @@ -522,14 +527,13 @@ export class WysiwygKeyboardHandler { // Check if we're on the first line if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) { - console.log('ArrowUp: On first line, navigating to previous block'); e.preventDefault(); const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { - console.log('ArrowUp: Focusing previous block:', prevBlock.id); - await blockOps.focusBlock(prevBlock.id, prevBlock.type === 'divider' || prevBlock.type === 'image' ? undefined : 'end'); + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end'); } } // Otherwise, let browser handle normal navigation @@ -540,13 +544,15 @@ export class WysiwygKeyboardHandler { */ private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise { // For non-editable blocks, always navigate to next block - if (block.type === 'divider' || block.type === 'image') { + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (nonEditableTypes.includes(block.type)) { e.preventDefault(); const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock) { - await blockOps.focusBlock(nextBlock.id, nextBlock.type === 'divider' || nextBlock.type === 'image' ? undefined : 'start'); + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start'); } return; } @@ -573,14 +579,13 @@ export class WysiwygKeyboardHandler { // Check if we're on the last line if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) { - console.log('ArrowDown: On last line, navigating to next block'); e.preventDefault(); const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock) { - console.log('ArrowDown: Focusing next block:', nextBlock.id); - await blockOps.focusBlock(nextBlock.id, nextBlock.type === 'divider' || nextBlock.type === 'image' ? undefined : 'start'); + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start'); } } // Otherwise, let browser handle normal navigation @@ -607,13 +612,15 @@ export class WysiwygKeyboardHandler { */ private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise { // For non-editable blocks, navigate to previous block - if (block.type === 'divider' || block.type === 'image') { + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (nonEditableTypes.includes(block.type)) { e.preventDefault(); const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { - await blockOps.focusBlock(prevBlock.id, prevBlock.type === 'divider' || prevBlock.type === 'image' ? undefined : 'end'); + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end'); } return; } @@ -640,16 +647,15 @@ export class WysiwygKeyboardHandler { // Check if cursor is at the beginning of the block const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots); - console.log('ArrowLeft: Cursor position:', cursorPos, 'in block:', block.id); if (cursorPos === 0) { const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); - console.log('ArrowLeft: At start, previous block:', prevBlock?.id); if (prevBlock) { e.preventDefault(); - await blockOps.focusBlock(prevBlock.id, prevBlock.type === 'divider' || prevBlock.type === 'image' ? undefined : 'end'); + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end'); } } // Otherwise, let the browser handle normal left arrow navigation @@ -660,13 +666,15 @@ export class WysiwygKeyboardHandler { */ private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise { // For non-editable blocks, navigate to next block - if (block.type === 'divider' || block.type === 'image') { + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + if (nonEditableTypes.includes(block.type)) { e.preventDefault(); const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock) { - await blockOps.focusBlock(nextBlock.id, nextBlock.type === 'divider' || nextBlock.type === 'image' ? undefined : 'start'); + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start'); } return; } @@ -701,7 +709,8 @@ export class WysiwygKeyboardHandler { if (nextBlock) { e.preventDefault(); - await blockOps.focusBlock(nextBlock.id, nextBlock.type === 'divider' || nextBlock.type === 'image' ? undefined : 'start'); + const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; + await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start'); } } // Otherwise, let the browser handle normal right arrow navigation diff --git a/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts b/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts index eba9630..09de756 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts @@ -58,6 +58,10 @@ export class WysiwygShortcuts { { type: 'list', label: 'List', icon: '•' }, { type: 'image', label: 'Image', icon: '🖼' }, { type: 'divider', label: 'Divider', icon: '—' }, + { type: 'youtube', label: 'YouTube', icon: '▶️' }, + { type: 'markdown', label: 'Markdown', icon: 'M↓' }, + { type: 'html', label: 'HTML', icon: '' }, + { type: 'attachment', label: 'File Attachment', icon: '📎' }, ]; } diff --git a/ts_web/elements/wysiwyg/wysiwyg.types.ts b/ts_web/elements/wysiwyg/wysiwyg.types.ts index daa8a42..35420ab 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.types.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.types.ts @@ -1,6 +1,6 @@ export interface IBlock { id: string; - type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider'; + type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider' | 'youtube' | 'markdown' | 'html' | 'attachment'; content: string; metadata?: any; }