feat: Add WYSIWYG editor components and utilities

- Implemented WysiwygModalManager for managing modals related to code blocks and block settings.
- Created WysiwygSelection for handling text selection across Shadow DOM boundaries.
- Introduced WysiwygShortcuts for managing keyboard shortcuts and slash menu items.
- Developed wysiwygStyles for consistent styling of the WYSIWYG editor.
- Defined types for blocks, slash menu items, and shortcut patterns in wysiwyg.types.ts.
This commit is contained in:
2025-09-18 14:23:42 +00:00
parent ba0f1602a1
commit ee470775b2
39 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,294 @@
# Critical WYSIWYG Knowledge - DO NOT LOSE
This document captures all the hard-won knowledge from our WYSIWYG editor development. These patterns and solutions took significant effort to discover and MUST be preserved during refactoring.
## 1. Static Rendering to Prevent Focus Loss
### Problem
When using Lit's reactive rendering, every state change would cause a re-render, which would:
- Lose cursor position
- Lose focus state
- Interrupt typing
- Break IME (Input Method Editor) support
### Solution
We render blocks **statically** and manage updates imperatively:
```typescript
// In dees-wysiwyg-block.ts
render(): TemplateResult {
if (!this.block) return html``;
// Render empty container - content set in firstUpdated
return html`<div class="wysiwyg-block-container"></div>`;
}
firstUpdated(): void {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container');
if (container && this.block) {
container.innerHTML = this.renderBlockContent();
}
}
```
### Critical Pattern
- NEVER use reactive properties that would trigger re-renders during typing
- Use `shouldUpdate()` to prevent unnecessary renders
- Manage content updates imperatively through event handlers
## 2. Shadow DOM Selection Handling
### Problem
The Web Selection API doesn't work across Shadow DOM boundaries by default. This broke:
- Text selection
- Cursor position tracking
- Formatting detection
- Content splitting for Enter key
### Solution
Use the `getComposedRanges` API with all relevant shadow roots:
```typescript
// From paragraph.block.ts
const wysiwygBlock = element.closest('dees-wysiwyg-block');
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = (wysiwygBlock as any)?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
```
### Critical Pattern
- ALWAYS collect all shadow roots in the hierarchy
- Use `WysiwygSelection` utility methods that handle shadow DOM
- Never use raw `window.getSelection()` without shadow root context
## 3. Cursor Position Tracking
### Problem
Cursor position would be lost during various operations, making it impossible to:
- Split content at the right position for Enter key
- Restore cursor after operations
- Track position for formatting
### Solution
Track cursor position through multiple events and maintain `lastKnownCursorPosition`:
```typescript
// Track on every relevant event
private lastKnownCursorPosition: number = 0;
// In event handlers:
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Fallback when selection not available:
if (!selectionInfo && this.lastKnownCursorPosition !== null) {
// Use last known position
}
```
### Critical Events to Track
- `input` - After text changes
- `keydown` - Before key press
- `keyup` - After key press
- `mouseup` - After mouse selection
- `click` - With setTimeout(0) for browser to set cursor
## 4. Content Splitting for Enter Key
### Problem
Splitting content at cursor position while preserving HTML formatting was complex due to:
- Need to preserve formatting tags
- Shadow DOM complications
- Cursor position accuracy
### Solution
Use Range API to split content while preserving HTML:
```typescript
getSplitContent(): { before: string; after: string } | null {
// Create ranges for before and after cursor
const beforeRange = document.createRange();
const afterRange = document.createRange();
beforeRange.setStart(element, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(element, element.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
return { before: beforeHtml, after: afterHtml };
}
```
## 5. Focus Management
### Problem
Focus would be lost or not properly set due to:
- Timing issues with DOM updates
- Shadow DOM complications
- Browser inconsistencies
### Solution
Use defensive focus management with fallbacks:
```typescript
focus(element: HTMLElement): void {
const block = element.querySelector('.block');
if (!block) return;
// Ensure focusable
if (!block.hasAttribute('contenteditable')) {
block.setAttribute('contenteditable', 'true');
}
block.focus();
// Fallback with microtask if focus failed
if (document.activeElement !== block && element.shadowRoot?.activeElement !== block) {
Promise.resolve().then(() => {
block.focus();
});
}
}
```
## 6. Selection Event Handling for Formatting
### Problem
Need to show formatting menu when text is selected, but selection events don't bubble across Shadow DOM.
### Solution
Dispatch custom events with selection information:
```typescript
// Listen for selection changes
document.addEventListener('selectionchange', () => {
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (selectedText !== this.lastSelectedText) {
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch custom event
this.dispatchSelectionEvent(element, {
text: selectedText,
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
});
// Custom event bubbles through Shadow DOM
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
```
## 7. IME (Input Method Editor) Support
### Problem
Composition events for non-Latin input methods would break without proper handling.
### Solution
Track composition state and handle events:
```typescript
// In dees-input-wysiwyg.ts
public isComposing: boolean = false;
// In block handlers
element.addEventListener('compositionstart', () => {
handlers.onCompositionStart(); // Sets isComposing = true
});
element.addEventListener('compositionend', () => {
handlers.onCompositionEnd(); // Sets isComposing = false
});
// Don't process certain operations during composition
if (this.isComposing) return;
```
## 8. Programmatic Rendering
### Problem
Lit's declarative rendering would cause focus loss and performance issues with many blocks.
### Solution
Render blocks programmatically:
```typescript
public renderBlocksProgrammatically() {
if (!this.editorContentRef) return;
// Clear existing blocks
this.editorContentRef.innerHTML = '';
// Create and append block elements
this.blocks.forEach(block => {
const blockWrapper = this.createBlockElement(block);
this.editorContentRef.appendChild(blockWrapper);
});
}
```
## 9. Block Handler Architecture Requirements
When creating new block handlers, they MUST:
1. **Implement all cursor/selection methods** even if not applicable
2. **Use Shadow DOM-aware selection utilities**
3. **Track cursor position through events**
4. **Handle focus with fallbacks**
5. **Preserve HTML content when getting/setting**
6. **Dispatch selection events for formatting**
7. **Support IME composition events**
8. **Clean up event listeners on disconnect**
## 10. Testing Considerations
### webhelpers.fixture() Issue
The test helper `webhelpers.fixture()` triggers property changes during initialization that can cause null reference errors. Always:
1. Check for null/undefined before accessing nested properties
2. Set required properties in specific order when testing
3. Consider manual element creation for complex test scenarios
## Summary
These patterns represent hours of debugging and problem-solving. When refactoring:
1. **NEVER** remove static rendering approach
2. **ALWAYS** use Shadow DOM-aware selection utilities
3. **MAINTAIN** cursor position tracking through all events
4. **PRESERVE** the complex content splitting logic
5. **KEEP** all focus management fallbacks
6. **ENSURE** selection events bubble through Shadow DOM
7. **SUPPORT** IME composition events
8. **TEST** thoroughly with actual typing, not just unit tests
Any changes that break these patterns will result in a degraded user experience that took significant effort to achieve.

View File

@@ -0,0 +1,44 @@
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;
component: any; // Reference to the wysiwyg-block component
}
export interface IBlockHandler {
type: string;
render(block: IBlock, isSelected: boolean): string;
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void;
getStyles(): string;
getPlaceholder?(): string;
// Optional methods for editable blocks - now with context
getContent?(element: HTMLElement, context?: IBlockContext): string;
setContent?(element: HTMLElement, content: string, context?: IBlockContext): void;
getCursorPosition?(element: HTMLElement, context?: IBlockContext): number | null;
setCursorToStart?(element: HTMLElement, context?: IBlockContext): void;
setCursorToEnd?(element: HTMLElement, context?: IBlockContext): void;
focus?(element: HTMLElement, context?: IBlockContext): void;
focusWithCursor?(element: HTMLElement, position: 'start' | 'end' | number, context?: IBlockContext): void;
getSplitContent?(element: HTMLElement, context?: IBlockContext): { before: string; after: string } | null;
}
export abstract class BaseBlockHandler implements IBlockHandler {
abstract type: string;
abstract render(block: IBlock, isSelected: boolean): string;
// Default implementation for common setup
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
// Common setup logic
}
// Common styles can be defined here
getStyles(): string {
return '';
}
}

View File

@@ -0,0 +1,17 @@
import type { IBlockHandler } from './block.base.js';
export class BlockRegistry {
private static handlers = new Map<string, IBlockHandler>();
static register(type: string, handler: IBlockHandler): void {
this.handlers.set(type, handler);
}
static getHandler(type: string): IBlockHandler | undefined {
return this.handlers.get(type);
}
static getAllTypes(): string[] {
return Array.from(this.handlers.keys());
}
}

View File

@@ -0,0 +1,64 @@
/**
* Common styles shared across all block types
*/
export const commonBlockStyles = `
/* Common block spacing and layout */
/* TODO: Extract common spacing from existing blocks */
/* Common focus states */
/* TODO: Extract common focus styles */
/* Common selected states */
/* TODO: Extract common selection styles */
/* Common hover states */
/* TODO: Extract common hover styles */
/* Common transition effects */
/* TODO: Extract common transitions */
/* Common placeholder styles */
/* TODO: Extract common placeholder styles */
/* Common error states */
/* TODO: Extract common error styles */
/* Common loading states */
/* TODO: Extract common loading styles */
`;
/**
* Helper function to generate consistent block classes
*/
export const getBlockClasses = (
type: string,
isSelected: boolean,
additionalClasses: string[] = []
): string => {
const classes = ['block', type];
if (isSelected) {
classes.push('selected');
}
classes.push(...additionalClasses);
return classes.join(' ');
};
/**
* Helper function to generate consistent data attributes
*/
export const getBlockDataAttributes = (
blockId: string,
blockType: string,
additionalAttributes: Record<string, string> = {}
): string => {
const attributes = {
'data-block-id': blockId,
'data-block-type': blockType,
...additionalAttributes
};
return Object.entries(attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
};

View File

@@ -0,0 +1,80 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
export class DividerBlockHandler extends BaseBlockHandler {
type = 'divider';
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
return `
<div class="block divider${selectedClass}" data-block-id="${block.id}" data-block-type="${block.type}" tabindex="0">
<hr>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const dividerBlock = element.querySelector('.block.divider') as HTMLDivElement;
if (!dividerBlock) return;
// Handle click to select
dividerBlock.addEventListener('click', (e) => {
e.stopPropagation();
// Focus will trigger the selection
dividerBlock.focus();
// Ensure focus handler is called immediately
handlers.onFocus?.();
});
// Handle focus/blur
dividerBlock.addEventListener('focus', () => {
handlers.onFocus?.();
});
dividerBlock.addEventListener('blur', () => {
handlers.onBlur?.();
});
// Handle keyboard events
dividerBlock.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
// Let the keyboard handler in the parent component handle the deletion
handlers.onKeyDown?.(e);
} else {
// Handle navigation keys
handlers.onKeyDown?.(e);
}
});
}
getStyles(): string {
return `
.block.divider {
padding: 8px 0;
margin: 16px 0;
cursor: pointer;
position: relative;
border-radius: 4px;
transition: all 0.15s ease;
}
.block.divider:focus {
outline: none;
}
.block.divider.selected {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.05)', 'rgba(77, 148, 255, 0.08)')};
box-shadow: inset 0 0 0 2px ${cssManager.bdTheme('rgba(0, 102, 204, 0.2)', 'rgba(77, 148, 255, 0.2)')};
}
.block.divider hr {
border: none;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
margin: 0;
pointer-events: none;
}
`;
}
}

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

@@ -0,0 +1,43 @@
/**
* Main exports for the blocks module
*/
// Core interfaces and base classes
export {
type IBlockHandler,
type IBlockEventHandlers,
BaseBlockHandler
} from './block.base.js';
// Block registry for registration and retrieval
export { BlockRegistry } from './block.registry.js';
// Common styles and helpers
export {
commonBlockStyles,
getBlockClasses,
getBlockDataAttributes
} from './block.styles.js';
// Text block handlers
export { ParagraphBlockHandler } from './text/paragraph.block.js';
export { HeadingBlockHandler } from './text/heading.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
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';
export { MarkdownBlockHandler } from './content/markdown.block.js';
export { HtmlBlockHandler } from './content/html.block.js';
// Utilities
// TODO: Export when implemented
// export * from './utils/file.utils.js';
// export * from './utils/media.utils.js';
// export * from './utils/markdown.utils.js';

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

@@ -0,0 +1,716 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
import hlight from 'highlight.js';
import { cssGeistFontFamily, cssMonoFontFamily } from '../../../00fonts.js';
import { PROGRAMMING_LANGUAGES } from '../../wysiwyg.constants.js';
/**
* CodeBlockHandler with improved architecture
*
* Key features:
* 1. Simple DOM structure
* 2. Line number handling
* 3. Syntax highlighting only when not focused (grey text while editing)
* 4. Clean event handling
* 5. Copy button functionality
*/
export class CodeBlockHandler extends BaseBlockHandler {
type = 'code';
private highlightTimer: any = null;
render(block: IBlock, isSelected: boolean): string {
const language = block.metadata?.language || 'typescript';
const content = block.content || '';
const lineCount = content.split('\n').length;
// Generate line numbers
let lineNumbersHtml = '';
for (let i = 1; i <= lineCount; i++) {
lineNumbersHtml += `<div class="line-number">${i}</div>`;
}
// Generate language options
const languageOptions = PROGRAMMING_LANGUAGES.map(lang => {
const value = lang.toLowerCase();
return `<option value="${value}" ${value === language ? 'selected' : ''}>${lang}</option>`;
}).join('');
return `
<div class="code-block-container${isSelected ? ' selected' : ''}" data-language="${language}">
<div class="code-header">
<select class="language-selector" data-block-id="${block.id}">
${languageOptions}
</select>
<button class="copy-button" title="Copy code">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
<div class="code-body">
<div class="line-numbers">${lineNumbersHtml}</div>
<div class="code-content">
<pre class="code-pre"><code class="code-editor"
contenteditable="true"
data-block-id="${block.id}"
data-block-type="${block.type}"
spellcheck="false">${this.escapeHtml(content)}</code></pre>
</div>
</div>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
const container = element.querySelector('.code-block-container') as HTMLElement;
const copyButton = element.querySelector('.copy-button') as HTMLButtonElement;
const languageSelector = element.querySelector('.language-selector') as HTMLSelectElement;
if (!editor || !container) return;
// Setup language selector
if (languageSelector) {
languageSelector.addEventListener('change', (e) => {
const newLanguage = (e.target as HTMLSelectElement).value;
block.metadata = { ...block.metadata, language: newLanguage };
container.setAttribute('data-language', newLanguage);
// Update the syntax highlighting if content exists and not focused
if (block.content && document.activeElement !== editor) {
this.applyHighlighting(element, block);
}
// Notify about the change
if (handlers.onInput) {
handlers.onInput(new InputEvent('input'));
}
});
}
// Setup copy button
if (copyButton) {
copyButton.addEventListener('click', async () => {
const content = editor.textContent || '';
try {
await navigator.clipboard.writeText(content);
// Show feedback
const copyText = copyButton.querySelector('.copy-text') as HTMLElement;
const originalText = copyText.textContent;
copyText.textContent = 'Copied!';
copyButton.classList.add('copied');
// Reset after 2 seconds
setTimeout(() => {
copyText.textContent = originalText;
copyButton.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = content;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
// @ts-ignore - execCommand is deprecated but needed for fallback
document.execCommand('copy');
// Show feedback
const copyText = copyButton.querySelector('.copy-text') as HTMLElement;
const originalText = copyText.textContent;
copyText.textContent = 'Copied!';
copyButton.classList.add('copied');
setTimeout(() => {
copyText.textContent = originalText;
copyButton.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
}
});
}
// Track if we're currently editing
let isEditing = false;
// Focus handler
editor.addEventListener('focus', () => {
isEditing = true;
container.classList.add('editing');
// Remove all syntax highlighting when focused
const content = editor.textContent || '';
editor.textContent = content; // This removes all HTML formatting
// Restore cursor position after removing highlighting
requestAnimationFrame(() => {
const range = document.createRange();
const selection = window.getSelection();
if (editor.firstChild) {
range.setStart(editor.firstChild, 0);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
}
});
handlers.onFocus();
});
// Blur handler
editor.addEventListener('blur', () => {
isEditing = false;
container.classList.remove('editing');
// Apply final highlighting on blur
this.applyHighlighting(element, block);
handlers.onBlur();
});
// Input handler
editor.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Update line numbers
this.updateLineNumbers(element);
// Clear any pending highlight timer (no highlighting while editing)
clearTimeout(this.highlightTimer);
});
// Keydown handler
editor.addEventListener('keydown', (e) => {
// Handle Tab key for code blocks
if (e.key === 'Tab') {
e.preventDefault();
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const textNode = document.createTextNode(' ');
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
handlers.onInput(new InputEvent('input'));
this.updateLineNumbers(element);
}
return;
}
// Check cursor position for navigation keys
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
const cursorPos = this.getCursorPosition(element);
const textLength = editor.textContent?.length || 0;
// For ArrowLeft at position 0 or ArrowRight at end, let parent handle navigation
if ((e.key === 'ArrowLeft' && cursorPos === 0) ||
(e.key === 'ArrowRight' && cursorPos === textLength)) {
// Pass to parent handler for inter-block navigation
handlers.onKeyDown(e);
return;
}
// For ArrowUp/Down, check if we're at first/last line
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const lines = (editor.textContent || '').split('\n');
const currentLine = this.getCurrentLineIndex(editor);
if ((e.key === 'ArrowUp' && currentLine === 0) ||
(e.key === 'ArrowDown' && currentLine === lines.length - 1)) {
// Let parent handle navigation to prev/next block
handlers.onKeyDown(e);
return;
}
}
}
// Pass other keys to parent handler
handlers.onKeyDown(e);
});
// Paste handler - plain text only
editor.addEventListener('paste', (e) => {
e.preventDefault();
const text = e.clipboardData?.getData('text/plain');
if (text) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
handlers.onInput(new InputEvent('input'));
this.updateLineNumbers(element);
}
}
});
// Composition handlers
editor.addEventListener('compositionstart', () => handlers.onCompositionStart());
editor.addEventListener('compositionend', () => handlers.onCompositionEnd());
// Initial syntax highlighting if content exists and not focused
if (block.content && document.activeElement !== editor) {
requestAnimationFrame(() => {
this.applyHighlighting(element, block);
});
}
}
private updateLineNumbers(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
const lineNumbersContainer = element.querySelector('.line-numbers') as HTMLElement;
if (!editor || !lineNumbersContainer) return;
const content = editor.textContent || '';
const lines = content.split('\n');
const lineCount = lines.length || 1;
let lineNumbersHtml = '';
for (let i = 1; i <= lineCount; i++) {
lineNumbersHtml += `<div class="line-number">${i}</div>`;
}
lineNumbersContainer.innerHTML = lineNumbersHtml;
}
private getCurrentLineIndex(editor: HTMLElement): number {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(editor);
preCaretRange.setEnd(range.startContainer, range.startOffset);
const textBeforeCursor = preCaretRange.toString();
const linesBeforeCursor = textBeforeCursor.split('\n');
return linesBeforeCursor.length - 1; // 0-indexed
}
private applyHighlighting(element: HTMLElement, block: IBlock): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return;
// Store cursor position
const cursorPos = this.getCursorPosition(element);
// Get plain text content
const content = editor.textContent || '';
const language = block.metadata?.language || 'typescript';
// Apply highlighting
try {
const result = hlight.highlight(content, {
language: language,
ignoreIllegals: true
});
// Only update if we have valid highlighted content
if (result.value) {
editor.innerHTML = result.value;
// Restore cursor position if editor is focused
if (document.activeElement === editor && cursorPos !== null) {
requestAnimationFrame(() => {
WysiwygSelection.setCursorPosition(editor, cursorPos);
});
}
}
} catch (error) {
// If highlighting fails, keep plain text
console.warn('Syntax highlighting failed:', error);
}
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
const editor = element.querySelector('.code-editor') as HTMLElement;
return editor?.textContent || '';
}
setContent(element: HTMLElement, content: string): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return;
editor.textContent = content;
this.updateLineNumbers(element);
// Apply highlighting if not focused
if (document.activeElement !== editor) {
const block: IBlock = {
id: editor.dataset.blockId || '',
type: 'code',
content: content,
metadata: {
language: element.querySelector('.code-block-container')?.getAttribute('data-language') || 'typescript'
}
};
this.applyHighlighting(element, block);
}
}
getCursorPosition(element: HTMLElement): number | null {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!editor.contains(range.startContainer)) return null;
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(editor);
preCaretRange.setEnd(range.startContainer, range.startOffset);
return preCaretRange.toString().length;
}
setCursorToStart(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (editor) {
WysiwygSelection.setCursorPosition(editor, 0);
}
}
setCursorToEnd(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (editor) {
const length = editor.textContent?.length || 0;
WysiwygSelection.setCursorPosition(editor, length);
}
}
focus(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
editor?.focus();
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return;
editor.focus();
requestAnimationFrame(() => {
if (position === 'start') {
this.setCursorToStart(element);
} else if (position === 'end') {
this.setCursorToEnd(element);
} else if (typeof position === 'number') {
WysiwygSelection.setCursorPosition(editor, position);
}
});
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
const position = this.getCursorPosition(element);
if (position === null) return null;
const content = this.getContent(element);
return {
before: content.substring(0, position),
after: content.substring(position)
};
}
getStyles(): string {
return `
/* Code Block Container - Minimalist shadcn style */
.code-block-container {
position: relative;
margin: 12px 0;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
}
.code-block-container.selected {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.code-block-container.editing {
border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
/* Header - Simplified */
.code-header {
background: transparent;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.language-selector {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: ${cssGeistFontFamily};
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
}
.language-selector:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#e5e7eb', '#374151')};
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.language-selector:focus {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Copy Button - Minimal */
.copy-button {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 12px;
font-family: ${cssGeistFontFamily};
cursor: pointer;
transition: all 0.15s ease;
outline: none;
}
.copy-button:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#e5e7eb', '#374151')};
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.copy-button:active {
transform: scale(0.98);
}
.copy-button.copied {
color: ${cssManager.bdTheme('#059669', '#10b981')};
}
.copy-icon {
flex-shrink: 0;
opacity: 0.7;
}
.copy-button:hover .copy-icon {
opacity: 1;
}
.copy-text {
min-width: 40px;
text-align: center;
}
/* Code Body */
.code-body {
display: flex;
position: relative;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
/* Line Numbers - Subtle */
.line-numbers {
flex-shrink: 0;
padding: 12px 0;
background: transparent;
text-align: right;
user-select: none;
min-width: 40px;
border-right: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
}
.line-number {
padding: 0 12px 0 8px;
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
font-family: ${cssMonoFontFamily};
font-size: 13px;
line-height: 20px;
height: 20px;
}
/* Code Content */
.code-content {
flex: 1;
overflow-x: auto;
position: relative;
}
.code-pre {
margin: 0;
padding: 0;
background: transparent;
}
.code-editor {
display: block;
padding: 12px 16px;
margin: 0;
font-family: ${cssMonoFontFamily};
font-size: 13px;
line-height: 20px;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
background: transparent;
border: none;
outline: none;
white-space: pre-wrap;
word-wrap: break-word;
min-height: 60px;
overflow: visible;
}
/* Placeholder */
.code-editor:empty::before {
content: "// Type or paste code here...";
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
pointer-events: none;
}
/* When editing (focused), show grey text without highlighting */
.code-block-container.editing .code-editor {
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')} !important;
}
.code-block-container.editing .code-editor * {
color: inherit !important;
}
/* Syntax Highlighting - Muted colors */
.code-editor .hljs-keyword {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
font-weight: 500;
}
.code-editor .hljs-string {
color: ${cssManager.bdTheme('#059669', '#10b981')};
}
.code-editor .hljs-number {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.code-editor .hljs-function {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.code-editor .hljs-comment {
color: ${cssManager.bdTheme('#6b7280', '#6b7280')};
font-style: italic;
}
.code-editor .hljs-variable,
.code-editor .hljs-attr {
color: ${cssManager.bdTheme('#ea580c', '#fb923c')};
}
.code-editor .hljs-class,
.code-editor .hljs-title {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
font-weight: 500;
}
.code-editor .hljs-params {
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.code-editor .hljs-built_in {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.code-editor .hljs-literal {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.code-editor .hljs-meta {
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.code-editor .hljs-punctuation {
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.code-editor .hljs-tag {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.code-editor .hljs-attribute {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.code-editor .hljs-selector-tag {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.code-editor .hljs-selector-class {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.code-editor .hljs-selector-id {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
/* Selection */
.code-editor::selection,
.code-editor *::selection {
background: ${cssManager.bdTheme('rgba(99, 102, 241, 0.2)', 'rgba(99, 102, 241, 0.3)')};
}
/* Scrollbar styling - Minimal */
.code-content::-webkit-scrollbar {
height: 6px;
}
.code-content::-webkit-scrollbar-track {
background: transparent;
}
.code-content::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
border-radius: 3px;
}
.code-content::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
`;
}
}

View File

@@ -0,0 +1,533 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class HeadingBlockHandler extends BaseBlockHandler {
type: string;
private level: 1 | 2 | 3;
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
constructor(type: 'heading-1' | 'heading-2' | 'heading-3') {
super();
this.type = type;
this.level = parseInt(type.split('-')[1]) as 1 | 2 | 3;
}
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
return `
<div
class="block heading-${this.level}${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
data-block-id="${block.id}"
data-block-type="${block.type}"
></div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) {
console.error('HeadingBlockHandler.setup: No heading block element found');
return;
}
// Set initial content if needed
if (block.content && !headingBlock.innerHTML) {
headingBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
headingBlock.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler with cursor tracking
headingBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onKeyDown(e);
});
// Focus handler
headingBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
headingBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
headingBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
headingBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
headingBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Selection will be handled by selectionchange event
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
headingBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler for additional cursor tracking
headingBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection change handler
this.setupSelectionHandler(element, headingBlock, block);
}
private setupSelectionHandler(element: HTMLElement, headingBlock: HTMLDivElement, block: IBlock): void {
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root - in setup, we need to traverse
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
this.selectionHandler = checkSelection;
// Clean up on disconnect (will be called by dees-wysiwyg-block)
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
// Return styles for all heading levels
return `
.block.heading-1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
margin: 24px 0 8px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
margin: 20px 0 6px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-3 {
font-size: 20px;
font-weight: 600;
line-height: 1.4;
margin: 16px 0 4px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
`;
}
getPlaceholder(): string {
switch(this.level) {
case 1:
return 'Heading 1';
case 2:
return 'Heading 2';
case 3:
return 'Heading 3';
default:
return 'Heading';
}
}
/**
* Helper to get the last text node in an element
*/
private getLastTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = element.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(element.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
// Helper methods for heading functionality (mostly the same as paragraph)
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual heading element
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
return null;
}
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(headingBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
return position;
}
getContent(element: HTMLElement, context?: any): string {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return '';
// For headings, get the innerHTML which includes formatting tags
const content = headingBlock.innerHTML || '';
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === headingBlock ||
element.shadowRoot?.activeElement === headingBlock;
headingBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
headingBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (headingBlock) {
WysiwygBlocks.setCursorToStart(headingBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (headingBlock) {
WysiwygBlocks.setCursorToEnd(headingBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return;
// Ensure the element is focusable
if (!headingBlock.hasAttribute('contenteditable')) {
headingBlock.setAttribute('contenteditable', 'true');
}
headingBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== headingBlock && element.shadowRoot?.activeElement !== headingBlock) {
Promise.resolve().then(() => {
headingBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return;
// Ensure element is focusable first
if (!headingBlock.hasAttribute('contenteditable')) {
headingBlock.setAttribute('contenteditable', 'true');
}
// For 'end' position, we need to set up selection before focus to prevent browser default
if (position === 'end' && headingBlock.textContent && headingBlock.textContent.length > 0) {
// Set up the selection first
const sel = window.getSelection();
if (sel) {
const range = document.createRange();
const lastNode = this.getLastTextNode(headingBlock) || headingBlock;
if (lastNode.nodeType === Node.TEXT_NODE) {
range.setStart(lastNode, lastNode.textContent?.length || 0);
range.setEnd(lastNode, lastNode.textContent?.length || 0);
} else {
range.selectNodeContents(lastNode);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
}
}
// Now focus the element
headingBlock.focus();
// Set cursor position after focus is established (for non-end positions)
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end' && (!headingBlock.textContent || headingBlock.textContent.length === 0)) {
// Only call setCursorToEnd for empty blocks
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(headingBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
setCursor();
} else {
// Try again with a small delay - sometimes focus needs more time
setTimeout(() => {
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
setCursor();
}
}, 10);
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = headingBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = headingBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position first
const cursorPos = this.getCursorPosition(element, context);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
return {
before: '',
after: headingBlock.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(headingBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(headingBlock, headingBlock.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
return {
before: beforeHtml,
after: afterHtml
};
}
}

View File

@@ -0,0 +1,448 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class ListBlockHandler extends BaseBlockHandler {
type = 'list';
// Track cursor position and list state
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const listType = block.metadata?.listType || 'unordered';
const listTag = listType === 'ordered' ? 'ol' : 'ul';
// Render list content
const listContent = this.renderListContent(block.content, block.metadata);
return `
<div
class="block list${selectedClass}"
contenteditable="true"
data-block-id="${block.id}"
data-block-type="${block.type}"
>${listContent}</div>
`;
}
private renderListContent(content: string | undefined, metadata: any): string {
if (!content) return '<ul><li></li></ul>';
const listType = metadata?.listType || 'unordered';
const listTag = listType === 'ordered' ? 'ol' : 'ul';
// Split content by newlines to create list items
const lines = content.split('\n').filter(line => line.trim());
if (lines.length === 0) {
return `<${listTag}><li></li></${listTag}>`;
}
const listItems = lines.map(line => `<li>${line}</li>`).join('');
return `<${listTag}>${listItems}</${listTag}>`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) {
console.error('ListBlockHandler.setup: No list block element found');
return;
}
// Set initial content if needed
if (block.content && !listBlock.innerHTML) {
listBlock.innerHTML = this.renderListContent(block.content, block.metadata);
}
// Input handler
listBlock.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler
listBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Special handling for Enter key in lists
if (e.key === 'Enter' && !e.shiftKey) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const currentLi = range.startContainer.parentElement?.closest('li');
if (currentLi && currentLi.textContent === '') {
// Empty list item - exit list mode
e.preventDefault();
handlers.onKeyDown(e);
return;
}
// Otherwise, let browser create new list item naturally
}
}
handlers.onKeyDown(e);
});
// Focus handler
listBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
listBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
listBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
listBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
listBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onMouseUp?.(e);
});
// Click handler
listBlock.addEventListener('click', (e: MouseEvent) => {
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler
listBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection handler
this.setupSelectionHandler(element, listBlock, block);
}
private setupSelectionHandler(element: HTMLElement, listBlock: HTMLDivElement, block: IBlock): void {
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
document.addEventListener('selectionchange', checkSelection);
this.selectionHandler = checkSelection;
// Cleanup on disconnect
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
return `
/* List specific styles */
.block.list {
padding: 0;
}
.block.list ul,
.block.list ol {
margin: 0;
padding-left: 24px;
}
.block.list li {
margin: 4px 0;
line-height: 1.6;
}
.block.list li:last-child {
margin-bottom: 0;
}
`;
}
getPlaceholder(): string {
return '';
}
// Helper methods for list functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return null;
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return null;
if (!WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer)) {
return null;
}
// For lists, calculate position based on text content
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(listBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
return preCaretRange.toString().length;
}
getContent(element: HTMLElement, context?: any): string {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return '';
// Extract text content from list items
const listItems = listBlock.querySelectorAll('li');
const content = Array.from(listItems)
.map(li => li.textContent || '')
.join('\n');
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
const hadFocus = document.activeElement === listBlock ||
element.shadowRoot?.activeElement === listBlock;
// Get current metadata to preserve list type
const listElement = listBlock.querySelector('ul, ol');
const isOrdered = listElement?.tagName === 'OL';
// Update content
listBlock.innerHTML = this.renderListContent(content, { listType: isOrdered ? 'ordered' : 'unordered' });
if (hadFocus) {
listBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
const firstLi = listBlock.querySelector('li');
if (firstLi) {
const textNode = this.getFirstTextNode(firstLi);
if (textNode) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
const lastLi = listBlock.querySelector('li:last-child');
if (lastLi) {
const textNode = this.getLastTextNode(lastLi);
if (textNode) {
const range = document.createRange();
const selection = window.getSelection();
const textLength = textNode.textContent?.length || 0;
range.setStart(textNode, textLength);
range.setEnd(textNode, textLength);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}
private getFirstTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = 0; i < element.childNodes.length; i++) {
const firstText = this.getFirstTextNode(element.childNodes[i]);
if (firstText) return firstText;
}
return null;
}
private getLastTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = element.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(element.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
focus(element: HTMLElement, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
if (!listBlock.hasAttribute('contenteditable')) {
listBlock.setAttribute('contenteditable', 'true');
}
listBlock.focus();
if (document.activeElement !== listBlock && element.shadowRoot?.activeElement !== listBlock) {
Promise.resolve().then(() => {
listBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
if (!listBlock.hasAttribute('contenteditable')) {
listBlock.setAttribute('contenteditable', 'true');
}
listBlock.focus();
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// For numeric positions in lists, we need custom logic
// This is complex due to list structure, so default to end
this.setCursorToEnd(element, context);
}
};
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
setCursor();
} else {
Promise.resolve().then(() => {
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return null;
// For lists, we don't split content - instead let the keyboard handler
// create a new paragraph block when Enter is pressed on empty list item
return null;
}
}

View File

@@ -0,0 +1,499 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class ParagraphBlockHandler extends BaseBlockHandler {
type = 'paragraph';
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
return `
<div
class="block paragraph${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
data-block-id="${block.id}"
data-block-type="${block.type}"
></div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) {
console.error('ParagraphBlockHandler.setup: No paragraph block element found');
return;
}
// Set initial content if needed
if (block.content && !paragraphBlock.innerHTML) {
paragraphBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
paragraphBlock.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler with cursor tracking
paragraphBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onKeyDown(e);
});
// Focus handler
paragraphBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
paragraphBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
paragraphBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
paragraphBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
paragraphBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Selection will be handled by selectionchange event
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
paragraphBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler for additional cursor tracking
paragraphBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection change handler
this.setupSelectionHandler(element, paragraphBlock, block);
}
private setupSelectionHandler(element: HTMLElement, paragraphBlock: HTMLDivElement, block: IBlock): void {
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root - traverse from shadow root
const wysiwygBlock = (paragraphBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
this.selectionHandler = checkSelection;
// Clean up on disconnect (will be called by dees-wysiwyg-block)
const wysiwygBlock = element.closest('dees-wysiwyg-block');
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
return `
/* Paragraph specific styles */
.block.paragraph {
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
`;
}
getPlaceholder(): string {
return "Type '/' for commands...";
}
/**
* Helper to get the last text node in an element
*/
private getLastTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = element.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(element.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
// Helper methods for paragraph functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual paragraph element
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
return null;
}
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(paragraphBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
return position;
}
getContent(element: HTMLElement, context?: any): string {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return '';
// For paragraphs, get the innerHTML which includes formatting tags
const content = paragraphBlock.innerHTML || '';
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === paragraphBlock ||
element.shadowRoot?.activeElement === paragraphBlock;
paragraphBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
paragraphBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (paragraphBlock) {
WysiwygBlocks.setCursorToStart(paragraphBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (paragraphBlock) {
WysiwygBlocks.setCursorToEnd(paragraphBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return;
// Ensure the element is focusable
if (!paragraphBlock.hasAttribute('contenteditable')) {
paragraphBlock.setAttribute('contenteditable', 'true');
}
paragraphBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== paragraphBlock && element.shadowRoot?.activeElement !== paragraphBlock) {
Promise.resolve().then(() => {
paragraphBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return;
// Ensure element is focusable first
if (!paragraphBlock.hasAttribute('contenteditable')) {
paragraphBlock.setAttribute('contenteditable', 'true');
}
// For 'end' position, we need to set up selection before focus to prevent browser default
if (position === 'end' && paragraphBlock.textContent && paragraphBlock.textContent.length > 0) {
// Set up the selection first
const sel = window.getSelection();
if (sel) {
const range = document.createRange();
const lastNode = this.getLastTextNode(paragraphBlock) || paragraphBlock;
if (lastNode.nodeType === Node.TEXT_NODE) {
range.setStart(lastNode, lastNode.textContent?.length || 0);
range.setEnd(lastNode, lastNode.textContent?.length || 0);
} else {
range.selectNodeContents(lastNode);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
}
}
// Now focus the element
paragraphBlock.focus();
// Set cursor position after focus is established (for non-end positions)
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end' && (!paragraphBlock.textContent || paragraphBlock.textContent.length === 0)) {
// Only call setCursorToEnd for empty blocks
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(paragraphBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
setCursor();
} else {
// Try again with a small delay - sometimes focus needs more time
setTimeout(() => {
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
setCursor();
}
}, 10);
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = paragraphBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = paragraphBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position first
const cursorPos = this.getCursorPosition(element, context);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
return {
before: '',
after: paragraphBlock.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(paragraphBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(paragraphBlock, paragraphBlock.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
return {
before: beforeHtml,
after: afterHtml
};
}
}

View File

@@ -0,0 +1,457 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class QuoteBlockHandler extends BaseBlockHandler {
type = 'quote';
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
return `
<div
class="block quote${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
data-block-id="${block.id}"
data-block-type="${block.type}"
></div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
console.error('QuoteBlockHandler.setup: No quote block element found');
return;
}
// Set initial content if needed
if (block.content && !quoteBlock.innerHTML) {
quoteBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
quoteBlock.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler with cursor tracking
quoteBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onKeyDown(e);
});
// Focus handler
quoteBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
quoteBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
quoteBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
quoteBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
quoteBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Selection will be handled by selectionchange event
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
quoteBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler for additional cursor tracking
quoteBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection change handler
this.setupSelectionHandler(element, quoteBlock, block);
}
private setupSelectionHandler(element: HTMLElement, quoteBlock: HTMLDivElement, block: IBlock): void {
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root - traverse from shadow root
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
this.selectionHandler = checkSelection;
// Clean up on disconnect (will be called by dees-wysiwyg-block)
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
return `
/* Quote specific styles */
.block.quote {
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
padding-left: 20px;
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
font-style: italic;
line-height: 1.6;
margin: 16px 0;
}
`;
}
getPlaceholder(): string {
return 'Add a quote...';
}
// Helper methods for quote functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual quote element
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
return null;
}
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(quoteBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
return position;
}
getContent(element: HTMLElement, context?: any): string {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return '';
// For quotes, get the innerHTML which includes formatting tags
const content = quoteBlock.innerHTML || '';
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === quoteBlock ||
element.shadowRoot?.activeElement === quoteBlock;
quoteBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
quoteBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (quoteBlock) {
WysiwygBlocks.setCursorToStart(quoteBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (quoteBlock) {
WysiwygBlocks.setCursorToEnd(quoteBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Ensure the element is focusable
if (!quoteBlock.hasAttribute('contenteditable')) {
quoteBlock.setAttribute('contenteditable', 'true');
}
quoteBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== quoteBlock && element.shadowRoot?.activeElement !== quoteBlock) {
Promise.resolve().then(() => {
quoteBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Ensure element is focusable first
if (!quoteBlock.hasAttribute('contenteditable')) {
quoteBlock.setAttribute('contenteditable', 'true');
}
// Focus the element
quoteBlock.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(quoteBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = quoteBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = quoteBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position first
const cursorPos = this.getCursorPosition(element, context);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
return {
before: '',
after: quoteBlock.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(quoteBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(quoteBlock, quoteBlock.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
return {
before: beforeHtml,
after: afterHtml
};
}
}