This commit is contained in:
2025-06-26 13:18:34 +00:00
parent 4d42911198
commit 342bd7d7c2
12 changed files with 2369 additions and 1208 deletions

View File

@@ -23,14 +23,14 @@ This document tracks the progress of migrating all WYSIWYG blocks to the new blo
- All three heading levels (h1, h2, h3) using unified handler - All three heading levels (h1, h2, h3) using unified handler
- See `phase4-summary.md` for details - See `phase4-summary.md` for details
### 🔄 Phase 5: Other Text Blocks (In Progress) ### Phase 5: Other Text Blocks
- [ ] Quote block - [x] Quote block - Completed with custom styling
- [ ] Code block - [x] Code block - Completed with syntax highlighting, line numbers, and copy button
- [ ] List block - [x] List block - Completed with bullet and numbered list support
### 📋 Phase 6: Media Blocks (Planned) ### 🔄 Phase 6: Media Blocks (In Progress)
- [ ] Image block - [x] Image block - Completed with click upload, drag-drop, and base64 encoding
- [ ] YouTube block - [x] YouTube block - Completed with URL parsing and video embedding
- [ ] Attachment block - [ ] Attachment block
### 📋 Phase 7: Content Blocks (Planned) ### 📋 Phase 7: Content Blocks (Planned)
@@ -46,14 +46,14 @@ This document tracks the progress of migrating all WYSIWYG blocks to the new blo
| heading-1 | ✅ | ✅ | ✅ | Using HeadingBlockHandler | | heading-1 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
| heading-2 | ✅ | ✅ | ✅ | Using HeadingBlockHandler | | heading-2 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
| heading-3 | ✅ | ✅ | ✅ | Using HeadingBlockHandler | | heading-3 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
| quote | | | | | | quote | | | | Complete with custom styling |
| code | | | | | | code | | | | Complete with highlighting, line numbers, copy |
| list | | | | | | list | | | | Complete with bullet/numbered support |
| image | | | | | | image | | | | Complete with upload, drag-drop support |
| youtube | | | | | | youtube | | | | Complete with URL parsing, video embedding |
| markdown | ❌ | ❌ | ❌ | | | attachment | ❌ | ❌ | ❌ | Phase 6 |
| html | ❌ | ❌ | ❌ | | | markdown | ❌ | ❌ | ❌ | Phase 7 |
| attachment | ❌ | ❌ | ❌ | | | html | ❌ | ❌ | ❌ | Phase 7 |
## Files Modified During Migration ## Files Modified During Migration
@@ -68,11 +68,20 @@ This document tracks the progress of migrating all WYSIWYG blocks to the new blo
- `blocks/content/divider.block.ts` - `blocks/content/divider.block.ts`
- `blocks/text/paragraph.block.ts` - `blocks/text/paragraph.block.ts`
- `blocks/text/heading.block.ts` - `blocks/text/heading.block.ts`
- `blocks/text/quote.block.ts`
- `blocks/text/code.block.ts`
- `blocks/text/list.block.ts`
- `blocks/media/image.block.ts`
- `blocks/media/youtube.block.ts`
### Main Component Updates ### Main Component Updates
- `dees-wysiwyg-block.ts` - Updated to use registry pattern - `dees-wysiwyg-block.ts` - Updated to use registry pattern
## Next Steps ## Next Steps
1. Continue with quote block migration 1. Begin Phase 6: Media blocks migration
2. Follow established patterns from paragraph/heading handlers - Start with image block (most common media type)
- Implement YouTube block for video embedding
- Create attachment block for file uploads
2. Follow established patterns from existing handlers
3. Test thoroughly after each migration 3. Test thoroughly after each migration
4. Update documentation as blocks are completed

View File

@@ -1,4 +1,8 @@
import type { IBlock } from '../wysiwyg.types.js'; import type { IBlock } from '../wysiwyg.types.js';
import type { IBlockEventHandlers } from '../wysiwyg.interfaces.js';
// Re-export types from the interfaces
export type { IBlockEventHandlers } from '../wysiwyg.interfaces.js';
export interface IBlockContext { export interface IBlockContext {
shadowRoot: ShadowRoot; shadowRoot: ShadowRoot;
@@ -23,15 +27,6 @@ export interface IBlockHandler {
getSplitContent?(element: HTMLElement, context?: IBlockContext): { before: string; after: string } | null; getSplitContent?(element: HTMLElement, context?: IBlockContext): { before: string; after: string } | null;
} }
export interface IBlockEventHandlers {
onInput: (e: InputEvent) => void;
onKeyDown: (e: KeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void;
}
export abstract class BaseBlockHandler implements IBlockHandler { export abstract class BaseBlockHandler implements IBlockHandler {
abstract type: string; abstract type: string;

View File

@@ -0,0 +1,519 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* HTMLBlockHandler - Handles raw HTML content with preview/edit toggle
*
* Features:
* - Live HTML preview (sandboxed)
* - Edit/preview mode toggle
* - Syntax highlighting in edit mode
* - HTML validation hints
* - Auto-save on mode switch
*/
export class HtmlBlockHandler extends BaseBlockHandler {
type = 'html';
render(block: IBlock, isSelected: boolean): string {
const isEditMode = block.metadata?.isEditMode ?? true;
const content = block.content || '';
return `
<div class="html-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
data-edit-mode="${isEditMode}">
<div class="html-header">
<div class="html-icon">&lt;/&gt;</div>
<div class="html-title">HTML</div>
<button class="html-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
${isEditMode ? '👁️' : '✏️'}
</button>
</div>
<div class="html-content">
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
</div>
</div>
`;
}
private renderEditor(content: string): string {
return `
<textarea class="html-editor"
placeholder="Enter HTML content..."
spellcheck="false">${this.escapeHtml(content)}</textarea>
`;
}
private renderPreview(content: string): string {
return `
<div class="html-preview">
${content || '<div class="preview-empty">No content to preview</div>'}
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.html-block-container') as HTMLElement;
const toggleBtn = element.querySelector('.html-toggle-mode') as HTMLButtonElement;
if (!container || !toggleBtn) {
console.error('HtmlBlockHandler: Could not find required elements');
return;
}
// Initialize metadata
if (!block.metadata) block.metadata = {};
if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true;
// Toggle mode button
toggleBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Save current content if in edit mode
if (block.metadata.isEditMode) {
const editor = container.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
block.content = editor.value;
}
}
// Toggle mode
block.metadata.isEditMode = !block.metadata.isEditMode;
// Request UI update
handlers.onRequestUpdate?.();
});
// Setup based on mode
if (block.metadata.isEditMode) {
this.setupEditor(element, block, handlers);
} else {
this.setupPreview(element, block, handlers);
}
}
private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (!editor) return;
// Focus handling
editor.addEventListener('focus', () => handlers.onFocus());
editor.addEventListener('blur', () => handlers.onBlur());
// Content changes
editor.addEventListener('input', () => {
block.content = editor.value;
this.validateHtml(editor.value);
});
// Keyboard shortcuts
editor.addEventListener('keydown', (e) => {
// Tab handling for indentation
if (e.key === 'Tab') {
e.preventDefault();
const start = editor.selectionStart;
const end = editor.selectionEnd;
const value = editor.value;
if (e.shiftKey) {
// Unindent
const beforeCursor = value.substring(0, start);
const lastNewline = beforeCursor.lastIndexOf('\n');
const lineStart = lastNewline + 1;
const lineContent = value.substring(lineStart, start);
if (lineContent.startsWith(' ')) {
editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start);
editor.selectionStart = editor.selectionEnd = start - 2;
}
} else {
// Indent
editor.value = value.substring(0, start) + ' ' + value.substring(end);
editor.selectionStart = editor.selectionEnd = start + 2;
}
block.content = editor.value;
return;
}
// Auto-close tags (Ctrl/Cmd + /)
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
this.autoCloseTag(editor);
block.content = editor.value;
return;
}
// Pass other key events to handlers
handlers.onKeyDown(e);
});
// Auto-resize
this.autoResize(editor);
editor.addEventListener('input', () => this.autoResize(editor));
}
private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.html-block-container') as HTMLElement;
const preview = element.querySelector('.html-preview') as HTMLElement;
if (!container || !preview) return;
// Make preview focusable
preview.setAttribute('tabindex', '0');
// Focus handling
preview.addEventListener('focus', () => handlers.onFocus());
preview.addEventListener('blur', () => handlers.onBlur());
// Keyboard navigation
preview.addEventListener('keydown', (e) => {
// Switch to edit mode on Enter
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
block.metadata.isEditMode = true;
handlers.onRequestUpdate?.();
return;
}
handlers.onKeyDown(e);
});
// Sandbox styles and scripts in preview
this.sandboxContent(preview);
}
private autoCloseTag(editor: HTMLTextAreaElement): void {
const cursorPos = editor.selectionStart;
const text = editor.value;
// Find the opening tag
let tagStart = cursorPos;
while (tagStart > 0 && text[tagStart - 1] !== '<') {
tagStart--;
}
if (tagStart > 0) {
const tagContent = text.substring(tagStart, cursorPos);
const tagMatch = tagContent.match(/^(\w+)/);
if (tagMatch) {
const tagName = tagMatch[1];
const closingTag = `</${tagName}>`;
// Insert closing tag
editor.value = text.substring(0, cursorPos) + '>' + closingTag + text.substring(cursorPos);
editor.selectionStart = editor.selectionEnd = cursorPos + 1;
}
}
}
private autoResize(editor: HTMLTextAreaElement): void {
editor.style.height = 'auto';
editor.style.height = editor.scrollHeight + 'px';
}
private validateHtml(html: string): boolean {
// Basic HTML validation
const openTags: string[] = [];
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g;
let match;
while ((match = tagRegex.exec(html)) !== null) {
const isClosing = match[0].startsWith('</');
const tagName = match[1].toLowerCase();
if (isClosing) {
if (openTags.length === 0 || openTags[openTags.length - 1] !== tagName) {
console.warn(`Mismatched closing tag: ${tagName}`);
return false;
}
openTags.pop();
} else if (!match[0].endsWith('/>')) {
// Not a self-closing tag
openTags.push(tagName);
}
}
if (openTags.length > 0) {
console.warn(`Unclosed tags: ${openTags.join(', ')}`);
return false;
}
return true;
}
private sandboxContent(preview: HTMLElement): void {
// Remove any script tags
const scripts = preview.querySelectorAll('script');
scripts.forEach(script => script.remove());
// Remove event handlers
const allElements = preview.querySelectorAll('*');
allElements.forEach(el => {
// Remove all on* attributes
Array.from(el.attributes).forEach(attr => {
if (attr.name.startsWith('on')) {
el.removeAttribute(attr.name);
}
});
});
// Prevent forms from submitting
const forms = preview.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
});
});
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
return editor.value;
}
// If in preview mode, return the stored content
const container = element.querySelector('.html-block-container');
const blockId = container?.getAttribute('data-block-id');
// In real implementation, would need access to block data
return '';
}
setContent(element: HTMLElement, content: string): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
editor.value = content;
this.autoResize(editor);
}
}
getCursorPosition(element: HTMLElement): number | null {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
return editor ? editor.selectionStart : null;
}
setCursorToStart(element: HTMLElement): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
editor.selectionStart = editor.selectionEnd = 0;
editor.focus();
} else {
this.focus(element);
}
}
setCursorToEnd(element: HTMLElement): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
const length = editor.value.length;
editor.selectionStart = editor.selectionEnd = length;
editor.focus();
} else {
this.focus(element);
}
}
focus(element: HTMLElement): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
editor.focus();
} else {
const preview = element.querySelector('.html-preview') as HTMLElement;
preview?.focus();
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
if (position === 'start') {
this.setCursorToStart(element);
} else if (position === 'end') {
this.setCursorToEnd(element);
} else if (typeof position === 'number') {
editor.selectionStart = editor.selectionEnd = position;
editor.focus();
}
} else {
this.focus(element);
}
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (!editor) return null;
const cursorPos = editor.selectionStart;
return {
before: editor.value.substring(0, cursorPos),
after: editor.value.substring(cursorPos)
};
}
getStyles(): string {
return `
/* HTML Block Container */
.html-block-container {
position: relative;
margin: 12px 0;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
background: ${cssManager.bdTheme('#ffffff', '#111827')};
}
.html-block-container.selected {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Header */
.html-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
}
.html-icon {
font-size: 14px;
font-weight: 600;
opacity: 0.8;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
}
.html-title {
flex: 1;
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.html-toggle-mode {
padding: 4px 8px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.html-toggle-mode:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
}
/* Content */
.html-content {
position: relative;
min-height: 120px;
}
/* Editor */
.html-editor {
width: 100%;
min-height: 120px;
padding: 12px;
background: transparent;
border: none;
outline: none;
resize: none;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
overflow: hidden;
}
.html-editor::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Preview */
.html-preview {
padding: 12px;
min-height: 96px;
outline: none;
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
}
.preview-empty {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
font-style: italic;
}
/* Sandboxed HTML preview styles */
.html-preview * {
max-width: 100%;
}
.html-preview img {
height: auto;
}
.html-preview a {
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
text-decoration: none;
}
.html-preview a:hover {
text-decoration: underline;
}
.html-preview table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
}
.html-preview th,
.html-preview td {
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
padding: 8px;
text-align: left;
}
.html-preview th {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
font-weight: 600;
}
.html-preview pre {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
padding: 12px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
}
.html-preview code {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
.html-preview pre code {
background: transparent;
padding: 0;
}
`;
}
}

View File

@@ -0,0 +1,562 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* MarkdownBlockHandler - Handles markdown content with preview/edit toggle
*
* Features:
* - Live markdown preview
* - Edit/preview mode toggle
* - Syntax highlighting in edit mode
* - Common markdown shortcuts
* - Auto-save on mode switch
*/
export class MarkdownBlockHandler extends BaseBlockHandler {
type = 'markdown';
render(block: IBlock, isSelected: boolean): string {
const isEditMode = block.metadata?.isEditMode ?? true;
const content = block.content || '';
return `
<div class="markdown-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
data-edit-mode="${isEditMode}">
<div class="markdown-header">
<div class="markdown-icon">M↓</div>
<div class="markdown-title">Markdown</div>
<button class="markdown-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
${isEditMode ? '👁️' : '✏️'}
</button>
</div>
<div class="markdown-content">
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
</div>
</div>
`;
}
private renderEditor(content: string): string {
return `
<textarea class="markdown-editor"
placeholder="Enter markdown content..."
spellcheck="false">${this.escapeHtml(content)}</textarea>
`;
}
private renderPreview(content: string): string {
const html = this.parseMarkdown(content);
return `
<div class="markdown-preview">
${html || '<div class="preview-empty">No content to preview</div>'}
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.markdown-block-container') as HTMLElement;
const toggleBtn = element.querySelector('.markdown-toggle-mode') as HTMLButtonElement;
if (!container || !toggleBtn) {
console.error('MarkdownBlockHandler: Could not find required elements');
return;
}
// Initialize metadata
if (!block.metadata) block.metadata = {};
if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true;
// Toggle mode button
toggleBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Save current content if in edit mode
if (block.metadata.isEditMode) {
const editor = container.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
block.content = editor.value;
}
}
// Toggle mode
block.metadata.isEditMode = !block.metadata.isEditMode;
// Request UI update
handlers.onRequestUpdate?.();
});
// Setup based on mode
if (block.metadata.isEditMode) {
this.setupEditor(element, block, handlers);
} else {
this.setupPreview(element, block, handlers);
}
}
private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (!editor) return;
// Focus handling
editor.addEventListener('focus', () => handlers.onFocus());
editor.addEventListener('blur', () => handlers.onBlur());
// Content changes
editor.addEventListener('input', () => {
block.content = editor.value;
});
// Keyboard shortcuts
editor.addEventListener('keydown', (e) => {
// Tab handling for indentation
if (e.key === 'Tab') {
e.preventDefault();
const start = editor.selectionStart;
const end = editor.selectionEnd;
const value = editor.value;
if (e.shiftKey) {
// Unindent
const beforeCursor = value.substring(0, start);
const lastNewline = beforeCursor.lastIndexOf('\n');
const lineStart = lastNewline + 1;
const lineContent = value.substring(lineStart, start);
if (lineContent.startsWith(' ')) {
editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start);
editor.selectionStart = editor.selectionEnd = start - 2;
}
} else {
// Indent
editor.value = value.substring(0, start) + ' ' + value.substring(end);
editor.selectionStart = editor.selectionEnd = start + 2;
}
block.content = editor.value;
return;
}
// Bold shortcut (Ctrl/Cmd + B)
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault();
this.wrapSelection(editor, '**', '**');
block.content = editor.value;
return;
}
// Italic shortcut (Ctrl/Cmd + I)
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
e.preventDefault();
this.wrapSelection(editor, '_', '_');
block.content = editor.value;
return;
}
// Link shortcut (Ctrl/Cmd + K)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.insertLink(editor);
block.content = editor.value;
return;
}
// Pass other key events to handlers
handlers.onKeyDown(e);
});
// Auto-resize
this.autoResize(editor);
editor.addEventListener('input', () => this.autoResize(editor));
}
private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.markdown-block-container') as HTMLElement;
const preview = element.querySelector('.markdown-preview') as HTMLElement;
if (!container || !preview) return;
// Make preview focusable
preview.setAttribute('tabindex', '0');
// Focus handling
preview.addEventListener('focus', () => handlers.onFocus());
preview.addEventListener('blur', () => handlers.onBlur());
// Keyboard navigation
preview.addEventListener('keydown', (e) => {
// Switch to edit mode on Enter
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
block.metadata.isEditMode = true;
handlers.onRequestUpdate?.();
return;
}
handlers.onKeyDown(e);
});
}
private wrapSelection(editor: HTMLTextAreaElement, before: string, after: string): void {
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = editor.value.substring(start, end);
const replacement = before + (selectedText || 'text') + after;
editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
if (selectedText) {
editor.selectionStart = start;
editor.selectionEnd = start + replacement.length;
} else {
editor.selectionStart = start + before.length;
editor.selectionEnd = start + before.length + 4; // 'text'.length
}
editor.focus();
}
private insertLink(editor: HTMLTextAreaElement): void {
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = editor.value.substring(start, end);
const linkText = selectedText || 'link text';
const replacement = `[${linkText}](url)`;
editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
// Select the URL part
editor.selectionStart = start + linkText.length + 3; // '[linktext]('.length
editor.selectionEnd = start + linkText.length + 6; // '[linktext](url'.length
editor.focus();
}
private autoResize(editor: HTMLTextAreaElement): void {
editor.style.height = 'auto';
editor.style.height = editor.scrollHeight + 'px';
}
private parseMarkdown(markdown: string): string {
// Basic markdown parsing - in production, use a proper markdown parser
let html = this.escapeHtml(markdown);
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Code blocks
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// Inline code
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// Lists
html = html.replace(/^\* (.+)$/gm, '<li>$1</li>');
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
// Wrap consecutive list items
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => {
return '<ul>' + match + '</ul>';
});
// Paragraphs
html = html.replace(/\n\n/g, '</p><p>');
html = '<p>' + html + '</p>';
// Clean up empty paragraphs
html = html.replace(/<p><\/p>/g, '');
html = html.replace(/<p>(<h[1-3]>)/g, '$1');
html = html.replace(/(<\/h[1-3]>)<\/p>/g, '$1');
html = html.replace(/<p>(<ul>)/g, '$1');
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
html = html.replace(/<p>(<pre>)/g, '$1');
html = html.replace(/(<\/pre>)<\/p>/g, '$1');
return html;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
return editor.value;
}
// If in preview mode, return the stored content
const container = element.querySelector('.markdown-block-container');
const blockId = container?.getAttribute('data-block-id');
// In real implementation, would need access to block data
return '';
}
setContent(element: HTMLElement, content: string): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
editor.value = content;
this.autoResize(editor);
}
}
getCursorPosition(element: HTMLElement): number | null {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
return editor ? editor.selectionStart : null;
}
setCursorToStart(element: HTMLElement): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
editor.selectionStart = editor.selectionEnd = 0;
editor.focus();
} else {
this.focus(element);
}
}
setCursorToEnd(element: HTMLElement): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
const length = editor.value.length;
editor.selectionStart = editor.selectionEnd = length;
editor.focus();
} else {
this.focus(element);
}
}
focus(element: HTMLElement): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
editor.focus();
} else {
const preview = element.querySelector('.markdown-preview') as HTMLElement;
preview?.focus();
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
if (position === 'start') {
this.setCursorToStart(element);
} else if (position === 'end') {
this.setCursorToEnd(element);
} else if (typeof position === 'number') {
editor.selectionStart = editor.selectionEnd = position;
editor.focus();
}
} else {
this.focus(element);
}
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (!editor) return null;
const cursorPos = editor.selectionStart;
return {
before: editor.value.substring(0, cursorPos),
after: editor.value.substring(cursorPos)
};
}
getStyles(): string {
return `
/* Markdown Block Container */
.markdown-block-container {
position: relative;
margin: 12px 0;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
background: ${cssManager.bdTheme('#ffffff', '#111827')};
}
.markdown-block-container.selected {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Header */
.markdown-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
}
.markdown-icon {
font-size: 14px;
font-weight: 600;
opacity: 0.8;
}
.markdown-title {
flex: 1;
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.markdown-toggle-mode {
padding: 4px 8px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.markdown-toggle-mode:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
}
/* Content */
.markdown-content {
position: relative;
min-height: 120px;
}
/* Editor */
.markdown-editor {
width: 100%;
min-height: 120px;
padding: 12px;
background: transparent;
border: none;
outline: none;
resize: none;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
overflow: hidden;
}
.markdown-editor::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Preview */
.markdown-preview {
padding: 12px;
min-height: 96px;
outline: none;
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
}
.preview-empty {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
font-style: italic;
}
/* Markdown preview styles */
.markdown-preview h1 {
font-size: 24px;
font-weight: 600;
margin: 16px 0 8px 0;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
.markdown-preview h2 {
font-size: 20px;
font-weight: 600;
margin: 14px 0 6px 0;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
.markdown-preview h3 {
font-size: 18px;
font-weight: 600;
margin: 12px 0 4px 0;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
.markdown-preview p {
margin: 8px 0;
}
.markdown-preview ul,
.markdown-preview ol {
margin: 8px 0;
padding-left: 24px;
}
.markdown-preview li {
margin: 4px 0;
}
.markdown-preview code {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
.markdown-preview pre {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
padding: 12px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
}
.markdown-preview pre code {
background: transparent;
padding: 0;
}
.markdown-preview strong {
font-weight: 600;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
.markdown-preview em {
font-style: italic;
}
.markdown-preview a {
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
text-decoration: none;
}
.markdown-preview a:hover {
text-decoration: underline;
}
.markdown-preview blockquote {
border-left: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
padding-left: 12px;
margin: 8px 0;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
`;
}
}

View File

@@ -22,22 +22,19 @@ export {
// Text block handlers // Text block handlers
export { ParagraphBlockHandler } from './text/paragraph.block.js'; export { ParagraphBlockHandler } from './text/paragraph.block.js';
export { HeadingBlockHandler } from './text/heading.block.js'; export { HeadingBlockHandler } from './text/heading.block.js';
// TODO: Export when implemented export { QuoteBlockHandler } from './text/quote.block.js';
// export { QuoteBlockHandler } from './text/quote.block.js'; export { CodeBlockHandler } from './text/code.block.js';
// export { CodeBlockHandler } from './text/code.block.js'; export { ListBlockHandler } from './text/list.block.js';
// export { ListBlockHandler } from './text/list.block.js';
// Media block handlers // Media block handlers
// TODO: Export when implemented export { ImageBlockHandler } from './media/image.block.js';
// export { ImageBlockHandler } from './media/image.block.js'; export { YouTubeBlockHandler } from './media/youtube.block.js';
// export { YoutubeBlockHandler } from './media/youtube.block.js'; export { AttachmentBlockHandler } from './media/attachment.block.js';
// export { AttachmentBlockHandler } from './media/attachment.block.js';
// Content block handlers // Content block handlers
export { DividerBlockHandler } from './content/divider.block.js'; export { DividerBlockHandler } from './content/divider.block.js';
// TODO: Export when implemented export { MarkdownBlockHandler } from './content/markdown.block.js';
// export { MarkdownBlockHandler } from './content/markdown.block.js'; export { HtmlBlockHandler } from './content/html.block.js';
// export { HtmlBlockHandler } from './content/html.block.js';
// Utilities // Utilities
// TODO: Export when implemented // TODO: Export when implemented

View File

@@ -0,0 +1,477 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* AttachmentBlockHandler - Handles file attachments
*
* Features:
* - Multiple file upload support
* - Click to upload or drag and drop
* - File type icons
* - Remove individual files
* - Base64 encoding (TODO: server upload in production)
*/
export class AttachmentBlockHandler extends BaseBlockHandler {
type = 'attachment';
render(block: IBlock, isSelected: boolean): string {
const files = block.metadata?.files || [];
return `
<div class="attachment-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
tabindex="0">
<div class="attachment-header">
<div class="attachment-icon">📎</div>
<div class="attachment-title">File Attachments</div>
</div>
<div class="attachment-list">
${files.length > 0 ? this.renderFiles(files) : this.renderPlaceholder()}
</div>
<input type="file"
class="attachment-file-input"
multiple
style="display: none;" />
${files.length > 0 ? '<button class="add-more-files">Add More Files</button>' : ''}
</div>
`;
}
private renderPlaceholder(): string {
return `
<div class="attachment-placeholder">
<div class="placeholder-text">Click to add files</div>
<div class="placeholder-hint">or drag and drop</div>
</div>
`;
}
private renderFiles(files: any[]): string {
return files.map((file: any) => `
<div class="attachment-item" data-file-id="${file.id}">
<div class="file-icon">${this.getFileIcon(file.type)}</div>
<div class="file-info">
<div class="file-name">${this.escapeHtml(file.name)}</div>
<div class="file-size">${this.formatFileSize(file.size)}</div>
</div>
<button class="remove-file" data-file-id="${file.id}">×</button>
</div>
`).join('');
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.attachment-block-container') as HTMLElement;
const fileInput = element.querySelector('.attachment-file-input') as HTMLInputElement;
if (!container || !fileInput) {
console.error('AttachmentBlockHandler: Could not find required elements');
return;
}
// Initialize files array if needed
if (!block.metadata) block.metadata = {};
if (!block.metadata.files) block.metadata.files = [];
// Click to upload on placeholder
const placeholder = container.querySelector('.attachment-placeholder');
if (placeholder) {
placeholder.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
fileInput.click();
});
}
// Add more files button
const addMoreBtn = container.querySelector('.add-more-files') as HTMLButtonElement;
if (addMoreBtn) {
addMoreBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
fileInput.click();
});
}
// File input change
fileInput.addEventListener('change', async (e) => {
const input = e.target as HTMLInputElement;
const files = input.files;
if (files && files.length > 0) {
await this.handleFileAttachments(files, block, handlers);
input.value = ''; // Clear input for next selection
}
});
// Remove file buttons
container.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('remove-file')) {
e.preventDefault();
e.stopPropagation();
const fileId = target.getAttribute('data-file-id');
if (fileId) {
this.removeFile(fileId, block, handlers);
}
}
});
// Drag and drop
container.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.add('drag-over');
});
container.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
});
container.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
await this.handleFileAttachments(files, block, handlers);
}
});
// Focus/blur
container.addEventListener('focus', () => handlers.onFocus());
container.addEventListener('blur', () => handlers.onBlur());
// Keyboard navigation
container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
// Only remove all files if container is focused, not when removing individual files
if (document.activeElement === container && block.metadata?.files?.length > 0) {
e.preventDefault();
block.metadata.files = [];
handlers.onRequestUpdate?.();
return;
}
}
handlers.onKeyDown(e);
});
}
private async handleFileAttachments(
files: FileList,
block: IBlock,
handlers: IBlockEventHandlers
): Promise<void> {
if (!block.metadata) block.metadata = {};
if (!block.metadata.files) block.metadata.files = [];
for (const file of Array.from(files)) {
try {
const dataUrl = await this.fileToDataUrl(file);
const fileData = {
id: this.generateId(),
name: file.name,
size: file.size,
type: file.type,
data: dataUrl
};
block.metadata.files.push(fileData);
} catch (error) {
console.error('Failed to attach file:', file.name, error);
}
}
// Update block content with file count
block.content = `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`;
// Request UI update
handlers.onRequestUpdate?.();
}
private removeFile(fileId: string, block: IBlock, handlers: IBlockEventHandlers): void {
if (!block.metadata?.files) return;
block.metadata.files = block.metadata.files.filter((f: any) => f.id !== fileId);
// Update content
block.content = block.metadata.files.length > 0
? `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`
: '';
// Request UI update
handlers.onRequestUpdate?.();
}
private fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result;
if (typeof result === 'string') {
resolve(result);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
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('rar') || mimeType.includes('tar')) return '🗄️';
if (mimeType.includes('sheet')) return '📊';
if (mimeType.includes('document') || mimeType.includes('msword')) return '📝';
if (mimeType.includes('presentation')) return '📋';
if (mimeType.includes('text')) return '📃';
return '📁';
}
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];
}
private generateId(): string {
return `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
// Content is the description of attached files
const block = this.getBlockFromElement(element);
return block?.content || '';
}
setContent(element: HTMLElement, content: string): void {
// Content is the description of attached files
const block = this.getBlockFromElement(element);
if (block) {
block.content = content;
}
}
private getBlockFromElement(element: HTMLElement): IBlock | null {
const container = element.querySelector('.attachment-block-container');
const blockId = container?.getAttribute('data-block-id');
if (!blockId) return null;
// Simplified version - in real implementation would need access to block data
return {
id: blockId,
type: 'attachment',
content: '',
metadata: {}
};
}
getCursorPosition(element: HTMLElement): number | null {
return null; // Attachment blocks don't have cursor position
}
setCursorToStart(element: HTMLElement): void {
this.focus(element);
}
setCursorToEnd(element: HTMLElement): void {
this.focus(element);
}
focus(element: HTMLElement): void {
const container = element.querySelector('.attachment-block-container') as HTMLElement;
container?.focus();
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
this.focus(element);
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
return null; // Attachment blocks can't be split
}
getStyles(): string {
return `
/* Attachment Block Container */
.attachment-block-container {
position: relative;
margin: 12px 0;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
outline: none;
background: ${cssManager.bdTheme('#ffffff', '#111827')};
}
.attachment-block-container.selected {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.attachment-block-container.drag-over {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
}
/* Header */
.attachment-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
}
.attachment-icon {
font-size: 18px;
opacity: 0.8;
}
.attachment-title {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
/* File List */
.attachment-list {
padding: 8px;
min-height: 80px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Placeholder */
.attachment-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
cursor: pointer;
transition: all 0.15s ease;
}
.attachment-placeholder:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
}
.placeholder-text {
font-size: 14px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 4px;
}
.placeholder-hint {
font-size: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* File Items */
.attachment-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
transition: all 0.15s ease;
}
.attachment-item:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
}
.file-icon {
font-size: 20px;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 11px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-top: 2px;
}
.remove-file {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.remove-file:hover {
background: ${cssManager.bdTheme('#fee2e2', '#991b1b')};
border-color: ${cssManager.bdTheme('#fca5a5', '#dc2626')};
color: ${cssManager.bdTheme('#dc2626', '#fca5a5')};
}
/* Add More Files Button */
.add-more-files {
margin: 8px;
padding: 6px 12px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
font-size: 13px;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
cursor: pointer;
transition: all 0.15s ease;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.add-more-files:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
}
/* Hidden file input */
.attachment-file-input {
display: none !important;
}
`;
}
}

View File

@@ -0,0 +1,406 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* ImageBlockHandler - Handles image upload, display, and interactions
*
* Features:
* - Click to upload
* - Drag and drop support
* - Base64 encoding (TODO: server upload in production)
* - Loading states
* - Alt text from filename
*/
export class ImageBlockHandler extends BaseBlockHandler {
type = 'image';
render(block: IBlock, isSelected: boolean): string {
const imageUrl = block.metadata?.url;
const altText = block.content || 'Image';
const isLoading = block.metadata?.loading;
return `
<div class="image-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
data-has-image="${!!imageUrl}"
tabindex="0">
${isLoading ? this.renderLoading() :
imageUrl ? this.renderImage(imageUrl, altText) :
this.renderPlaceholder()}
<input type="file"
class="image-file-input"
accept="image/*"
style="display: none;" />
</div>
`;
}
private renderPlaceholder(): string {
return `
<div class="image-upload-placeholder" style="cursor: pointer;">
<div class="upload-icon" style="pointer-events: none;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
<div class="upload-text" style="pointer-events: none;">Click to upload an image</div>
<div class="upload-hint" style="pointer-events: none;">or drag and drop</div>
</div>
`;
}
private renderImage(url: string, altText: string): string {
return `
<div class="image-container">
<img src="${url}" alt="${this.escapeHtml(altText)}" />
</div>
`;
}
private renderLoading(): string {
return `
<div class="image-loading">
<div class="loading-spinner"></div>
<div class="loading-text">Uploading image...</div>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.image-block-container') as HTMLElement;
const fileInput = element.querySelector('.image-file-input') as HTMLInputElement;
if (!container) {
console.error('ImageBlockHandler: Could not find container');
return;
}
if (!fileInput) {
console.error('ImageBlockHandler: Could not find file input');
return;
}
// Click to upload (only on placeholder)
const placeholder = container.querySelector('.image-upload-placeholder');
if (placeholder) {
placeholder.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('ImageBlockHandler: Placeholder clicked, opening file selector');
fileInput.click();
});
}
// Container click for focus
container.addEventListener('click', () => {
handlers.onFocus();
});
// File input change
fileInput.addEventListener('change', async (e) => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
console.log('ImageBlockHandler: File selected:', file.name);
await this.handleFileUpload(file, block, handlers);
}
});
// Drag and drop
container.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
if (!block.metadata?.url) {
container.classList.add('drag-over');
}
});
container.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
});
container.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/') && !block.metadata?.url) {
await this.handleFileUpload(file, block, handlers);
}
});
// Focus/blur
container.addEventListener('focus', () => handlers.onFocus());
container.addEventListener('blur', () => handlers.onBlur());
// Keyboard navigation
container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (block.metadata?.url) {
// Clear the image
block.metadata.url = undefined;
block.metadata.loading = false;
block.content = '';
handlers.onInput(new InputEvent('input'));
return;
}
}
handlers.onKeyDown(e);
});
}
private async handleFileUpload(
file: File,
block: IBlock,
handlers: IBlockEventHandlers
): Promise<void> {
console.log('ImageBlockHandler: Starting file upload', {
fileName: file.name,
fileSize: file.size,
blockId: block.id
});
// Validate file
if (!file.type.startsWith('image/')) {
console.error('Invalid file type:', file.type);
return;
}
// Check file size (10MB limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
console.error('File too large. Maximum size is 10MB');
return;
}
// Set loading state
if (!block.metadata) block.metadata = {};
block.metadata.loading = true;
block.metadata.fileName = file.name;
block.metadata.fileSize = file.size;
block.metadata.mimeType = file.type;
console.log('ImageBlockHandler: Set loading state, requesting update');
// Request immediate UI update for loading state
handlers.onRequestUpdate?.();
try {
// Convert to base64
const dataUrl = await this.fileToDataUrl(file);
// Update block
block.metadata.url = dataUrl;
block.metadata.loading = false;
// Set default alt text from filename
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
block.content = nameWithoutExt;
console.log('ImageBlockHandler: Upload complete, requesting update', {
hasUrl: !!block.metadata.url,
urlLength: dataUrl.length,
altText: block.content
});
// Request immediate UI update to show uploaded image
handlers.onRequestUpdate?.();
} catch (error) {
console.error('Failed to upload image:', error);
block.metadata.loading = false;
// Request UI update to clear loading state
handlers.onRequestUpdate?.();
}
}
private fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result;
if (typeof result === 'string') {
resolve(result);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
// Content is the alt text
const block = this.getBlockFromElement(element);
return block?.content || '';
}
setContent(element: HTMLElement, content: string): void {
// Content is the alt text
const block = this.getBlockFromElement(element);
if (block) {
block.content = content;
}
}
private getBlockFromElement(element: HTMLElement): IBlock | null {
const container = element.querySelector('.image-block-container');
const blockId = container?.getAttribute('data-block-id');
if (!blockId) return null;
// This is a simplified version - in real implementation,
// we'd need access to the block data
return {
id: blockId,
type: 'image',
content: '',
metadata: {}
};
}
getCursorPosition(element: HTMLElement): number | null {
return null; // Images don't have cursor position
}
setCursorToStart(element: HTMLElement): void {
this.focus(element);
}
setCursorToEnd(element: HTMLElement): void {
this.focus(element);
}
focus(element: HTMLElement): void {
const container = element.querySelector('.image-block-container') as HTMLElement;
container?.focus();
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
this.focus(element);
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
return null; // Images can't be split
}
getStyles(): string {
return `
/* Image Block Container */
.image-block-container {
position: relative;
margin: 12px 0;
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
outline: none;
cursor: pointer;
}
.image-block-container.selected {
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
}
/* Upload Placeholder */
.image-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
border: 2px dashed ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
transition: all 0.15s ease;
}
.image-block-container:hover .image-upload-placeholder {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
}
.image-block-container.drag-over .image-upload-placeholder {
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
background: ${cssManager.bdTheme('#eff6ff', '#1e1b4b')};
}
.upload-icon {
margin-bottom: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
}
.upload-text {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
margin-bottom: 4px;
}
.upload-hint {
font-size: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Image Container */
.image-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
}
.image-container img {
max-width: 100%;
height: auto;
display: block;
border-radius: 4px;
}
/* Loading State */
.image-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-top-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 14px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* File input hidden */
.image-file-input {
display: none !important;
}
`;
}
}

View File

@@ -0,0 +1,337 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* YouTubeBlockHandler - Handles YouTube video embedding
*
* Features:
* - YouTube URL parsing and validation
* - Video ID extraction from various YouTube URL formats
* - Embedded iframe player
* - Clean minimalist design
*/
export class YouTubeBlockHandler extends BaseBlockHandler {
type = 'youtube';
render(block: IBlock, isSelected: boolean): string {
const videoId = block.metadata?.videoId;
const url = block.metadata?.url || '';
return `
<div class="youtube-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
data-has-video="${!!videoId}">
${videoId ? this.renderVideo(videoId) : this.renderPlaceholder(url)}
</div>
`;
}
private renderPlaceholder(url: string): string {
return `
<div class="youtube-placeholder">
<div class="placeholder-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"/>
</svg>
</div>
<div class="placeholder-text">Enter YouTube URL</div>
<input type="url"
class="youtube-url-input"
placeholder="https://youtube.com/watch?v=..."
value="${this.escapeHtml(url)}" />
<button class="youtube-embed-btn">Embed Video</button>
</div>
`;
}
private renderVideo(videoId: string): string {
return `
<div class="youtube-container">
<iframe
src="https://www.youtube.com/embed/${videoId}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.youtube-block-container') as HTMLElement;
if (!container) return;
// If video is already embedded, just handle focus/blur
if (block.metadata?.videoId) {
container.setAttribute('tabindex', '0');
container.addEventListener('focus', () => handlers.onFocus());
container.addEventListener('blur', () => handlers.onBlur());
// Handle deletion
container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
handlers.onKeyDown(e);
} else {
handlers.onKeyDown(e);
}
});
return;
}
// Setup placeholder interactions
const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement;
const embedBtn = element.querySelector('.youtube-embed-btn') as HTMLButtonElement;
if (!urlInput || !embedBtn) return;
// Focus management
urlInput.addEventListener('focus', () => handlers.onFocus());
urlInput.addEventListener('blur', () => handlers.onBlur());
// Handle embed button click
embedBtn.addEventListener('click', () => {
this.embedVideo(urlInput.value, block, handlers);
});
// Handle Enter key in input
urlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.embedVideo(urlInput.value, block, handlers);
} else if (e.key === 'Escape') {
e.preventDefault();
urlInput.blur();
}
});
// Handle paste event
urlInput.addEventListener('paste', (e) => {
// Allow paste to complete first
setTimeout(() => {
const pastedUrl = urlInput.value;
if (this.extractYouTubeVideoId(pastedUrl)) {
// Auto-embed if valid YouTube URL was pasted
this.embedVideo(pastedUrl, block, handlers);
}
}, 0);
});
// Update URL in metadata as user types
urlInput.addEventListener('input', () => {
if (!block.metadata) block.metadata = {};
block.metadata.url = urlInput.value;
});
}
private embedVideo(url: string, block: IBlock, handlers: IBlockEventHandlers): void {
const videoId = this.extractYouTubeVideoId(url);
if (!videoId) {
// Could show an error message here
console.error('Invalid YouTube URL');
return;
}
// Update block metadata
if (!block.metadata) block.metadata = {};
block.metadata.videoId = videoId;
block.metadata.url = url;
// Set content as video title (could be fetched from API in the future)
block.content = `YouTube Video: ${videoId}`;
// Request immediate UI update to show embedded video
handlers.onRequestUpdate?.();
}
private extractYouTubeVideoId(url: string): string | null {
// Handle various YouTube URL formats
const patterns = [
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/,
/youtube\.com\/embed\/([^"&?\/ ]{11})/,
/youtube\.com\/watch\?v=([^"&?\/ ]{11})/,
/youtu\.be\/([^"&?\/ ]{11})/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return match[1];
}
}
return null;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
// Content is the video description/title
const block = this.getBlockFromElement(element);
return block?.content || '';
}
setContent(element: HTMLElement, content: string): void {
// Content is the video description/title
const block = this.getBlockFromElement(element);
if (block) {
block.content = content;
}
}
private getBlockFromElement(element: HTMLElement): IBlock | null {
const container = element.querySelector('.youtube-block-container');
const blockId = container?.getAttribute('data-block-id');
if (!blockId) return null;
// Simplified version - in real implementation would need access to block data
return {
id: blockId,
type: 'youtube',
content: '',
metadata: {}
};
}
getCursorPosition(element: HTMLElement): number | null {
return null; // YouTube blocks don't have cursor position
}
setCursorToStart(element: HTMLElement): void {
this.focus(element);
}
setCursorToEnd(element: HTMLElement): void {
this.focus(element);
}
focus(element: HTMLElement): void {
const container = element.querySelector('.youtube-block-container') as HTMLElement;
const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement;
if (urlInput) {
urlInput.focus();
} else if (container) {
container.focus();
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
this.focus(element);
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
return null; // YouTube blocks can't be split
}
getStyles(): string {
return `
/* YouTube Block Container */
.youtube-block-container {
position: relative;
margin: 12px 0;
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
outline: none;
}
.youtube-block-container.selected {
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
}
/* YouTube Placeholder */
.youtube-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 24px;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
gap: 12px;
}
.placeholder-icon {
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
opacity: 0.8;
}
.placeholder-text {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.youtube-url-input {
width: 100%;
max-width: 400px;
padding: 8px 12px;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
background: ${cssManager.bdTheme('#ffffff', '#111827')};
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: all 0.15s ease;
outline: none;
}
.youtube-url-input:focus {
border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: ${cssManager.bdTheme('#ffffff', '#1f2937')};
}
.youtube-url-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
}
.youtube-embed-btn {
padding: 6px 16px;
background: ${cssManager.bdTheme('#111827', '#f9fafb')};
color: ${cssManager.bdTheme('#f9fafb', '#111827')};
border: 1px solid transparent;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
}
.youtube-embed-btn:hover {
background: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.youtube-embed-btn:active {
transform: scale(0.98);
}
/* YouTube Container */
.youtube-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
background: ${cssManager.bdTheme('#000000', '#000000')};
}
.youtube-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 6px;
}
`;
}
}

View File

@@ -243,6 +243,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
onCompositionStart: () => this.isComposing = true, onCompositionStart: () => this.isComposing = true,
onCompositionEnd: () => this.isComposing = false, onCompositionEnd: () => this.isComposing = false,
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e), onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
onRequestUpdate: () => this.updateBlockElement(block.id),
}; };
wrapper.appendChild(blockComponent); wrapper.appendChild(blockComponent);

File diff suppressed because it is too large Load Diff

View File

@@ -10,14 +10,26 @@
* to the new block handler architecture using a unified HeadingBlockHandler. * to the new block handler architecture using a unified HeadingBlockHandler.
* Phase 5 Complete: Quote, Code, and List blocks have been successfully migrated * Phase 5 Complete: Quote, Code, and List blocks have been successfully migrated
* to the new block handler architecture. * to the new block handler architecture.
* Phase 6 Complete: Image, YouTube, and Attachment blocks have been successfully migrated
* to the new block handler architecture.
* Phase 7 Complete: Markdown and HTML blocks have been successfully migrated
* to the new block handler architecture.
*/ */
import { BlockRegistry, DividerBlockHandler } from './blocks/index.js'; import {
import { ParagraphBlockHandler } from './blocks/text/paragraph.block.js'; BlockRegistry,
import { HeadingBlockHandler } from './blocks/text/heading.block.js'; DividerBlockHandler,
import { QuoteBlockHandler } from './blocks/text/quote.block.js'; ParagraphBlockHandler,
import { CodeBlockHandler } from './blocks/text/code.block.js'; HeadingBlockHandler,
import { ListBlockHandler } from './blocks/text/list.block.js'; QuoteBlockHandler,
CodeBlockHandler,
ListBlockHandler,
ImageBlockHandler,
YouTubeBlockHandler,
AttachmentBlockHandler,
MarkdownBlockHandler,
HtmlBlockHandler
} from './blocks/index.js';
// Initialize and register all block handlers // Initialize and register all block handlers
export function registerAllBlockHandlers(): void { export function registerAllBlockHandlers(): void {
@@ -33,14 +45,14 @@ export function registerAllBlockHandlers(): void {
BlockRegistry.register('code', new CodeBlockHandler()); BlockRegistry.register('code', new CodeBlockHandler());
BlockRegistry.register('list', new ListBlockHandler()); BlockRegistry.register('list', new ListBlockHandler());
// TODO: Register media blocks when implemented // Register media blocks
// BlockRegistry.register('image', new ImageBlockHandler()); BlockRegistry.register('image', new ImageBlockHandler());
// BlockRegistry.register('youtube', new YoutubeBlockHandler()); BlockRegistry.register('youtube', new YouTubeBlockHandler());
// BlockRegistry.register('attachment', new AttachmentBlockHandler()); BlockRegistry.register('attachment', new AttachmentBlockHandler());
// TODO: Register other content blocks when implemented // Register other content blocks
// BlockRegistry.register('markdown', new MarkdownBlockHandler()); BlockRegistry.register('markdown', new MarkdownBlockHandler());
// BlockRegistry.register('html', new HtmlBlockHandler()); BlockRegistry.register('html', new HtmlBlockHandler());
} }
// Ensure blocks are registered when this module is imported // Ensure blocks are registered when this module is imported

View File

@@ -85,4 +85,5 @@ export interface IBlockEventHandlers {
onCompositionStart: () => void; onCompositionStart: () => void;
onCompositionEnd: () => void; onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void; onMouseUp?: (e: MouseEvent) => void;
onRequestUpdate?: () => void; // Request immediate re-render of the block
} }