update
This commit is contained in:
@ -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
|
||||
- See `phase4-summary.md` for details
|
||||
|
||||
### 🔄 Phase 5: Other Text Blocks (In Progress)
|
||||
- [ ] Quote block
|
||||
- [ ] Code block
|
||||
- [ ] List block
|
||||
### ✅ Phase 5: Other Text Blocks
|
||||
- [x] Quote block - Completed with custom styling
|
||||
- [x] Code block - Completed with syntax highlighting, line numbers, and copy button
|
||||
- [x] List block - Completed with bullet and numbered list support
|
||||
|
||||
### 📋 Phase 6: Media Blocks (Planned)
|
||||
- [ ] Image block
|
||||
- [ ] YouTube block
|
||||
### 🔄 Phase 6: Media Blocks (In Progress)
|
||||
- [x] Image block - Completed with click upload, drag-drop, and base64 encoding
|
||||
- [x] YouTube block - Completed with URL parsing and video embedding
|
||||
- [ ] Attachment block
|
||||
|
||||
### 📋 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-2 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||
| heading-3 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||
| quote | ❌ | ❌ | ❌ | |
|
||||
| code | ❌ | ❌ | ❌ | |
|
||||
| list | ❌ | ❌ | ❌ | |
|
||||
| image | ❌ | ❌ | ❌ | |
|
||||
| youtube | ❌ | ❌ | ❌ | |
|
||||
| markdown | ❌ | ❌ | ❌ | |
|
||||
| html | ❌ | ❌ | ❌ | |
|
||||
| attachment | ❌ | ❌ | ❌ | |
|
||||
| quote | ✅ | ✅ | ✅ | Complete with custom styling |
|
||||
| code | ✅ | ✅ | ✅ | Complete with highlighting, line numbers, copy |
|
||||
| list | ✅ | ✅ | ✅ | Complete with bullet/numbered support |
|
||||
| image | ✅ | ✅ | ✅ | Complete with upload, drag-drop support |
|
||||
| youtube | ✅ | ✅ | ✅ | Complete with URL parsing, video embedding |
|
||||
| attachment | ❌ | ❌ | ❌ | Phase 6 |
|
||||
| markdown | ❌ | ❌ | ❌ | Phase 7 |
|
||||
| html | ❌ | ❌ | ❌ | Phase 7 |
|
||||
|
||||
## 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/text/paragraph.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
|
||||
- `dees-wysiwyg-block.ts` - Updated to use registry pattern
|
||||
|
||||
## Next Steps
|
||||
1. Continue with quote block migration
|
||||
2. Follow established patterns from paragraph/heading handlers
|
||||
3. Test thoroughly after each migration
|
||||
1. Begin Phase 6: Media blocks migration
|
||||
- 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
|
||||
4. Update documentation as blocks are completed
|
@ -1,4 +1,8 @@
|
||||
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 {
|
||||
shadowRoot: ShadowRoot;
|
||||
@ -23,15 +27,6 @@ export interface IBlockHandler {
|
||||
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 {
|
||||
abstract type: string;
|
||||
|
519
ts_web/elements/wysiwyg/blocks/content/html.block.ts
Normal file
519
ts_web/elements/wysiwyg/blocks/content/html.block.ts
Normal 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"></></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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
562
ts_web/elements/wysiwyg/blocks/content/markdown.block.ts
Normal file
562
ts_web/elements/wysiwyg/blocks/content/markdown.block.ts
Normal 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')};
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
@ -22,22 +22,19 @@ export {
|
||||
// Text block handlers
|
||||
export { ParagraphBlockHandler } from './text/paragraph.block.js';
|
||||
export { HeadingBlockHandler } from './text/heading.block.js';
|
||||
// TODO: Export when implemented
|
||||
// export { QuoteBlockHandler } from './text/quote.block.js';
|
||||
// export { CodeBlockHandler } from './text/code.block.js';
|
||||
// export { ListBlockHandler } from './text/list.block.js';
|
||||
export { QuoteBlockHandler } from './text/quote.block.js';
|
||||
export { CodeBlockHandler } from './text/code.block.js';
|
||||
export { ListBlockHandler } from './text/list.block.js';
|
||||
|
||||
// Media block handlers
|
||||
// TODO: Export when implemented
|
||||
// export { ImageBlockHandler } from './media/image.block.js';
|
||||
// export { YoutubeBlockHandler } from './media/youtube.block.js';
|
||||
// export { AttachmentBlockHandler } from './media/attachment.block.js';
|
||||
export { ImageBlockHandler } from './media/image.block.js';
|
||||
export { YouTubeBlockHandler } from './media/youtube.block.js';
|
||||
export { AttachmentBlockHandler } from './media/attachment.block.js';
|
||||
|
||||
// Content block handlers
|
||||
export { DividerBlockHandler } from './content/divider.block.js';
|
||||
// TODO: Export when implemented
|
||||
// export { MarkdownBlockHandler } from './content/markdown.block.js';
|
||||
// export { HtmlBlockHandler } from './content/html.block.js';
|
||||
export { MarkdownBlockHandler } from './content/markdown.block.js';
|
||||
export { HtmlBlockHandler } from './content/html.block.js';
|
||||
|
||||
// Utilities
|
||||
// TODO: Export when implemented
|
||||
|
477
ts_web/elements/wysiwyg/blocks/media/attachment.block.ts
Normal file
477
ts_web/elements/wysiwyg/blocks/media/attachment.block.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
406
ts_web/elements/wysiwyg/blocks/media/image.block.ts
Normal file
406
ts_web/elements/wysiwyg/blocks/media/image.block.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
337
ts_web/elements/wysiwyg/blocks/media/youtube.block.ts
Normal file
337
ts_web/elements/wysiwyg/blocks/media/youtube.block.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
@ -243,6 +243,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
onCompositionStart: () => this.isComposing = true,
|
||||
onCompositionEnd: () => this.isComposing = false,
|
||||
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
|
||||
onRequestUpdate: () => this.updateBlockElement(block.id),
|
||||
};
|
||||
wrapper.appendChild(blockComponent);
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -10,14 +10,26 @@
|
||||
* to the new block handler architecture using a unified HeadingBlockHandler.
|
||||
* Phase 5 Complete: Quote, Code, and List blocks have been successfully migrated
|
||||
* 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 { ParagraphBlockHandler } from './blocks/text/paragraph.block.js';
|
||||
import { HeadingBlockHandler } from './blocks/text/heading.block.js';
|
||||
import { QuoteBlockHandler } from './blocks/text/quote.block.js';
|
||||
import { CodeBlockHandler } from './blocks/text/code.block.js';
|
||||
import { ListBlockHandler } from './blocks/text/list.block.js';
|
||||
import {
|
||||
BlockRegistry,
|
||||
DividerBlockHandler,
|
||||
ParagraphBlockHandler,
|
||||
HeadingBlockHandler,
|
||||
QuoteBlockHandler,
|
||||
CodeBlockHandler,
|
||||
ListBlockHandler,
|
||||
ImageBlockHandler,
|
||||
YouTubeBlockHandler,
|
||||
AttachmentBlockHandler,
|
||||
MarkdownBlockHandler,
|
||||
HtmlBlockHandler
|
||||
} from './blocks/index.js';
|
||||
|
||||
// Initialize and register all block handlers
|
||||
export function registerAllBlockHandlers(): void {
|
||||
@ -33,14 +45,14 @@ export function registerAllBlockHandlers(): void {
|
||||
BlockRegistry.register('code', new CodeBlockHandler());
|
||||
BlockRegistry.register('list', new ListBlockHandler());
|
||||
|
||||
// TODO: Register media blocks when implemented
|
||||
// BlockRegistry.register('image', new ImageBlockHandler());
|
||||
// BlockRegistry.register('youtube', new YoutubeBlockHandler());
|
||||
// BlockRegistry.register('attachment', new AttachmentBlockHandler());
|
||||
// Register media blocks
|
||||
BlockRegistry.register('image', new ImageBlockHandler());
|
||||
BlockRegistry.register('youtube', new YouTubeBlockHandler());
|
||||
BlockRegistry.register('attachment', new AttachmentBlockHandler());
|
||||
|
||||
// TODO: Register other content blocks when implemented
|
||||
// BlockRegistry.register('markdown', new MarkdownBlockHandler());
|
||||
// BlockRegistry.register('html', new HtmlBlockHandler());
|
||||
// Register other content blocks
|
||||
BlockRegistry.register('markdown', new MarkdownBlockHandler());
|
||||
BlockRegistry.register('html', new HtmlBlockHandler());
|
||||
}
|
||||
|
||||
// Ensure blocks are registered when this module is imported
|
||||
|
@ -85,4 +85,5 @@ export interface IBlockEventHandlers {
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onMouseUp?: (e: MouseEvent) => void;
|
||||
onRequestUpdate?: () => void; // Request immediate re-render of the block
|
||||
}
|
Reference in New Issue
Block a user