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
};
}
}

View File

@@ -0,0 +1,221 @@
import {
customElement,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import { zIndexRegistry } from '../00zindex.js';
import { WysiwygFormatting } from './wysiwyg.formatting.js';
declare global {
interface HTMLElementTagNameMap {
'dees-formatting-menu': DeesFormattingMenu;
}
}
@customElement('dees-formatting-menu')
export class DeesFormattingMenu extends DeesElement {
private static instance: DeesFormattingMenu;
public static getInstance(): DeesFormattingMenu {
if (!DeesFormattingMenu.instance) {
DeesFormattingMenu.instance = new DeesFormattingMenu();
document.body.appendChild(DeesFormattingMenu.instance);
}
return DeesFormattingMenu.instance;
}
@state()
public visible: boolean = false;
@state()
private position: { x: number; y: number } = { x: 0, y: 0 };
@state()
private menuZIndex: number = 1000;
private callback: ((command: string) => void | Promise<void>) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
pointer-events: none;
top: 0;
left: 0;
width: 0;
height: 0;
}
.formatting-menu {
position: fixed;
background: ${cssManager.bdTheme('#ffffff', '#262626')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
border-radius: 6px;
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.15);
padding: 4px;
display: flex;
gap: 2px;
pointer-events: auto;
user-select: none;
animation: fadeInScale 0.15s ease-out;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95) translateY(5px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.format-button {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
font-weight: 600;
font-size: 14px;
position: relative;
}
.format-button:hover {
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
.format-button:active {
transform: scale(0.95);
}
.format-button.bold {
font-weight: 700;
}
.format-button.italic {
font-style: italic;
}
.format-button.underline {
text-decoration: underline;
}
.format-button .code-icon {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 12px;
}
`,
];
render(): TemplateResult {
if (!this.visible) return html``;
// Apply z-index to host element
this.style.zIndex = this.menuZIndex.toString();
return html`
<div
class="formatting-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
data-menu-type="formatting"
>
${WysiwygFormatting.formatButtons.map(button => html`
<button
class="format-button ${button.command}"
data-command="${button.command}"
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
>
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
</button>
`)}
</div>
`;
}
private applyFormat(command: string): void {
if (this.callback) {
this.callback(command);
}
// Don't hide menu after applying format (except for link)
if (command === 'link') {
this.hide();
}
}
public show(position: { x: number; y: number }, callback: (command: string) => void | Promise<void>): void {
console.log('FormattingMenu.show called:', { position, visible: this.visible });
this.position = position;
this.callback = callback;
// Get z-index from registry and apply immediately
this.menuZIndex = zIndexRegistry.getNextZIndex();
zIndexRegistry.register(this, this.menuZIndex);
this.style.zIndex = this.menuZIndex.toString();
this.visible = true;
}
public hide(): void {
this.visible = false;
this.callback = null;
// Unregister from z-index registry
zIndexRegistry.unregister(this);
}
public updatePosition(position: { x: number; y: number }): void {
this.position = position;
}
public firstUpdated(): void {
// Set up event delegation for the menu
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
const menu = this.shadowRoot?.querySelector('.formatting-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}
});
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const button = target.closest('.format-button') as HTMLElement;
if (button) {
e.preventDefault();
e.stopPropagation();
const command = button.getAttribute('data-command');
if (command) {
this.applyFormat(command);
}
}
});
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
const menu = this.shadowRoot?.querySelector('.formatting-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}
}, true); // Use capture phase
}
}

View File

@@ -0,0 +1,991 @@
import { DeesInputBase } from '../dees-input-base.js';
import { demoFunc } from '../dees-input-wysiwyg.demo.js';
import { DeesModal } from '../dees-modal.js';
import {
customElement,
type TemplateResult,
property,
static as html,
cssManager,
state,
} from '@design.estate/dees-element';
import {
type IBlock,
type OutputFormat,
wysiwygStyles,
WysiwygConverters,
WysiwygShortcuts,
WysiwygFormatting,
WysiwygBlockOperations,
WysiwygInputHandler,
WysiwygKeyboardHandler,
WysiwygDragDropHandler,
WysiwygModalManager,
WysiwygHistory,
WysiwygSelection,
DeesSlashMenu,
DeesFormattingMenu
} from './index.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-wysiwyg': DeesInputWysiwyg;
}
}
@customElement('dees-input-wysiwyg')
export class DeesInputWysiwyg extends DeesInputBase<string> {
public static demo = demoFunc;
@property({ type: String })
public value: string = '';
@property({ type: String })
public outputFormat: OutputFormat = 'html';
@state()
public blocks: IBlock[] = [
{
id: WysiwygShortcuts.generateBlockId(),
type: 'paragraph',
content: '',
}
];
// Not using @state to avoid re-renders when selection changes
public selectedBlockId: string | null = null;
// Slash menu is now globally rendered
public slashMenu = DeesSlashMenu.getInstance();
@state()
public draggedBlockId: string | null = null;
@state()
public dragOverBlockId: string | null = null;
@state()
public dragOverPosition: 'before' | 'after' | null = null;
// Formatting menu is now globally rendered
public formattingMenu = DeesFormattingMenu.getInstance();
@state()
private selectedText: string = '';
public editorContentRef: HTMLDivElement;
public isComposing: boolean = false;
// Handler instances
public blockOperations: WysiwygBlockOperations;
private inputHandler: WysiwygInputHandler;
private keyboardHandler: WysiwygKeyboardHandler;
private dragDropHandler: WysiwygDragDropHandler;
private history: WysiwygHistory;
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
wysiwygStyles
];
constructor() {
super();
// Initialize handlers
this.blockOperations = new WysiwygBlockOperations(this);
this.inputHandler = new WysiwygInputHandler(this);
this.keyboardHandler = new WysiwygKeyboardHandler(this);
this.dragDropHandler = new WysiwygDragDropHandler(this);
this.history = new WysiwygHistory();
}
async connectedCallback() {
await super.connectedCallback();
}
async disconnectedCallback() {
await super.disconnectedCallback();
// Selection listeners are now handled at block level
// Clean up handlers
this.inputHandler?.destroy();
// Clean up blur timeout
if (this.blurTimeout) {
clearTimeout(this.blurTimeout);
this.blurTimeout = null;
}
}
async firstUpdated() {
this.updateValue();
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
// Add click handler to editor content
if (this.editorContentRef) {
this.editorContentRef.addEventListener('click', (e) => this.handleEditorClick(e));
}
// We now rely on block-level selection detection
// No global selection listener needed
// Listen for custom selection events from blocks
this.addEventListener('block-text-selected', (e: CustomEvent) => {
if (!this.slashMenu.visible && e.detail.hasSelection && e.detail.text.length > 0) {
this.selectedText = e.detail.text;
// Use the rect from the event if available
if (e.detail.rect) {
const coords = {
x: e.detail.rect.left + (e.detail.rect.width / 2),
y: Math.max(45, e.detail.rect.top - 45)
};
// Show the formatting menu at the calculated position
this.formattingMenu.show(
coords,
async (command: string) => await this.applyFormat(command)
);
}
}
});
// Hide formatting menu when clicking outside
document.addEventListener('mousedown', (e) => {
// Check if click is on the formatting menu itself
const formattingMenuElement = this.formattingMenu.shadowRoot?.querySelector('.formatting-menu');
if (formattingMenuElement && formattingMenuElement.contains(e.target as Node)) {
return;
}
// Check if we have an active selection
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) {
// Don't hide if we still have a selection
return;
}
// Hide the menu
if (this.formattingMenu.visible) {
this.hideFormattingMenu();
}
});
// Add global keyboard listener for undo/redo
this.addEventListener('keydown', (e: KeyboardEvent) => {
// Check if the event is from within our editor
const target = e.target as HTMLElement;
if (!this.contains(target) && !this.shadowRoot?.contains(target)) {
return;
}
// Handle undo/redo
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 'z') {
e.preventDefault();
this.undo();
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') {
e.preventDefault();
this.redo();
}
});
// Save initial state to history
this.history.saveState(this.blocks, this.selectedBlockId);
// Render blocks programmatically
this.renderBlocksProgrammatically();
}
/**
* Renders all blocks programmatically without triggering re-renders
*/
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);
});
}
/**
* Creates a block element programmatically
*/
public createBlockElement(block: IBlock): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'block-wrapper';
wrapper.setAttribute('data-block-id', block.id);
// Add drag handle for non-divider blocks
if (block.type !== 'divider') {
const dragHandle = document.createElement('div');
dragHandle.className = 'drag-handle';
dragHandle.draggable = true;
dragHandle.addEventListener('dragstart', (e) => this.dragDropHandler.handleDragStart(e, block));
wrapper.appendChild(dragHandle);
}
// Create the block component
const blockComponent = document.createElement('dees-wysiwyg-block') as any;
blockComponent.block = block;
blockComponent.isSelected = this.selectedBlockId === block.id;
blockComponent.wysiwygComponent = this; // Pass parent reference
blockComponent.handlers = {
onInput: (e: InputEvent) => this.inputHandler.handleBlockInput(e, block),
onKeyDown: (e: KeyboardEvent) => this.keyboardHandler.handleBlockKeyDown(e, block),
onFocus: () => this.handleBlockFocus(block),
onBlur: () => this.handleBlockBlur(block),
onCompositionStart: () => this.isComposing = true,
onCompositionEnd: () => this.isComposing = false,
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
onRequestUpdate: () => this.updateBlockElement(block.id),
};
wrapper.appendChild(blockComponent);
// Remove settings button - context menu will handle this
// Add drag event listeners
wrapper.addEventListener('dragover', (e) => this.dragDropHandler.handleDragOver(e, block));
wrapper.addEventListener('drop', (e) => this.dragDropHandler.handleDrop(e, block));
wrapper.addEventListener('dragleave', () => this.dragDropHandler.handleDragLeave(block));
return wrapper;
}
/**
* Updates a specific block element
*/
public updateBlockElement(blockId: string) {
const block = this.blocks.find(b => b.id === blockId);
if (!block) return;
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${blockId}"]`);
if (!wrapper) return;
// Replace with new element
const newWrapper = this.createBlockElement(block);
wrapper.replaceWith(newWrapper);
}
render(): TemplateResult {
return html`
<dees-label
.label="${this.label}"
.description="${this.description}"
.required="${this.required}"
></dees-label>
<div class="wysiwyg-container">
<div
class="editor-content ${this.draggedBlockId ? 'dragging' : ''}"
id="editor-content"
>
<!-- Blocks will be rendered programmatically -->
</div>
</div>
`;
}
// Old renderBlock method removed - using programmatic rendering instead
public handleSlashMenuKeyboard(e: KeyboardEvent) {
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
this.slashMenu.navigate('down');
break;
case 'ArrowUp':
e.preventDefault();
this.slashMenu.navigate('up');
break;
case 'Enter':
e.preventDefault();
this.slashMenu.selectCurrent();
break;
case 'Escape':
e.preventDefault();
this.closeSlashMenu(true);
break;
}
}
public closeSlashMenu(clearSlash: boolean = false) {
if (clearSlash && this.selectedBlockId) {
// Clear the slash command from the content if menu is closing without selection
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
if (currentBlock) {
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
const content = blockComponent.getContent();
if (content.startsWith('/')) {
// Remove the entire slash command (slash + any filter text)
const cleanContent = content.replace(/^\/[^\s]*\s*/, '').trim();
blockComponent.setContent(cleanContent);
currentBlock.content = cleanContent;
// Focus and set cursor at beginning
requestAnimationFrame(() => {
blockComponent.focusWithCursor(0);
});
}
}
}
}
this.slashMenu.hide();
}
private handleBlockFocus(block: IBlock) {
// Clear any pending blur timeout when focusing
if (this.blurTimeout) {
clearTimeout(this.blurTimeout);
this.blurTimeout = null;
}
const prevSelectedId = this.selectedBlockId;
this.selectedBlockId = block.id;
// Only update selection UI if it changed
if (prevSelectedId !== block.id) {
// Update the previous block's selection state
if (prevSelectedId) {
const prevWrapper = this.shadowRoot?.querySelector(`[data-block-id="${prevSelectedId}"]`);
const prevBlockComponent = prevWrapper?.querySelector('dees-wysiwyg-block') as any;
if (prevBlockComponent) {
prevBlockComponent.isSelected = false;
}
}
// Update the new block's selection state
const wrapper = this.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = wrapper?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
blockComponent.isSelected = true;
}
}
}
private blurTimeout: any = null;
private handleBlockBlur(block: IBlock) {
// Clear any existing blur timeout
if (this.blurTimeout) {
clearTimeout(this.blurTimeout);
}
// Don't update value if slash menu is visible
if (this.slashMenu.visible) {
return;
}
// Sync content from the block that's losing focus
const wrapperElement = this.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent && blockComponent.getContent) {
const newContent = blockComponent.getContent();
// Only update if content actually changed
if (block.content !== newContent) {
block.content = newContent;
}
}
// Delay the blur handling to avoid interfering with typing
this.blurTimeout = setTimeout(() => {
// Check if we've refocused on another block
const activeElement = this.shadowRoot?.activeElement;
const isBlockFocused = activeElement?.classList.contains('block');
if (!isBlockFocused) {
// Only update value if we're truly blurring away from all blocks
this.updateValue();
}
}, 100);
// Don't immediately clear selectedBlockId or close menus
// Let click handlers decide what to do
}
private handleEditorClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Close slash menu if clicking outside of it
if (this.slashMenu.visible) {
this.closeSlashMenu(true);
}
// Focus last block if clicking on empty editor area
if (target.classList.contains('editor-content')) {
const lastBlock = this.blocks[this.blocks.length - 1];
this.blockOperations.focusBlock(lastBlock.id, lastBlock.type === 'divider' || lastBlock.type === 'image' ? undefined : 'end');
}
}
private createNewBlock(type: IBlock['type'] = 'paragraph', content: string = '', metadata?: any): IBlock {
return {
id: WysiwygShortcuts.generateBlockId(),
type,
content,
...(metadata && { metadata })
};
}
private async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
const blockIndex = this.blocks.findIndex(b => b.id === afterBlock.id);
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
// Insert the new block element programmatically
const afterWrapper = this.editorContentRef.querySelector(`[data-block-id="${afterBlock.id}"]`);
if (afterWrapper) {
const newWrapper = this.createBlockElement(newBlock);
afterWrapper.insertAdjacentElement('afterend', newWrapper);
}
this.updateValue();
if (focusNewBlock && newBlock.type !== 'divider') {
// Give DOM time to settle
await new Promise(resolve => setTimeout(resolve, 0));
await this.blockOperations.focusBlock(newBlock.id, 'start');
}
}
public async insertBlock(type: IBlock['type']) {
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
if (!currentBlock) {
this.closeSlashMenu();
return;
}
// Get the block component to extract clean content
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
// Clear the slash command from the content before transforming
if (blockComponent) {
const content = blockComponent.getContent();
if (content.startsWith('/')) {
// Remove the slash and any filter text (including non-word characters)
const cleanContent = content.replace(/^\/[^\s]*\s*/, '').trim();
blockComponent.setContent(cleanContent);
currentBlock.content = cleanContent;
}
}
// Close menu
this.closeSlashMenu(false);
// If it's a code block, default to TypeScript
if (type === 'code') {
currentBlock.metadata = { language: 'typescript' };
}
// Transform the current block
currentBlock.type = type;
currentBlock.content = currentBlock.content || '';
if (type === 'divider') {
currentBlock.content = ' ';
const newBlock = this.createNewBlock();
this.insertBlockAfter(currentBlock, newBlock);
} else if (type === 'list') {
currentBlock.metadata = { listType: 'bullet' };
// For lists, ensure we start with empty content
currentBlock.content = '';
} else if (type === 'image') {
// For image blocks, clear content and set empty metadata
currentBlock.content = '';
currentBlock.metadata = { url: '', loading: false };
} else if (type === 'youtube') {
// For YouTube blocks, clear content and set empty metadata
currentBlock.content = '';
currentBlock.metadata = { videoId: '', url: '' };
} else if (type === 'markdown') {
// For Markdown blocks, preserve content and default to edit mode
currentBlock.metadata = { showPreview: false };
} else if (type === 'html') {
// For HTML blocks, preserve content and default to edit mode
currentBlock.metadata = { showPreview: false };
} else if (type === 'attachment') {
// For attachment blocks, clear content and set empty files array
currentBlock.content = '';
currentBlock.metadata = { files: [] };
} else {
// For all other block types, ensure content is clean
currentBlock.content = currentBlock.content || '';
}
// Update the block element programmatically
this.updateBlockElement(currentBlock.id);
this.updateValue();
// Give DOM time to settle
await new Promise(resolve => setTimeout(resolve, 0));
// Focus the block after rendering
if (type === 'list') {
this.blockOperations.focusBlock(currentBlock.id, 'start');
// Additional list-specific focus handling
requestAnimationFrame(() => {
const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${currentBlock.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
blockComponent.focusListItem();
}
});
} else if (type !== 'divider' && type !== 'image' && type !== 'youtube' && type !== 'markdown' && type !== 'html' && type !== 'attachment') {
this.blockOperations.focusBlock(currentBlock.id, 'start');
} else if (type === 'image' || type === 'youtube' || type === 'markdown' || type === 'html' || type === 'attachment') {
// Focus the non-editable block
this.blockOperations.focusBlock(currentBlock.id);
}
}
public updateValue() {
if (this.outputFormat === 'html') {
this.value = WysiwygConverters.getHtmlOutput(this.blocks);
} else {
this.value = WysiwygConverters.getMarkdownOutput(this.blocks);
}
this.changeSubject.next(this.value);
// Save to history (debounced)
this.saveToHistory(true);
}
public getValue(): string {
return this.value;
}
public setValue(value: string): void {
this.value = value;
if (this.outputFormat === 'html') {
this.blocks = WysiwygConverters.parseHtmlToBlocks(value);
} else {
this.blocks = WysiwygConverters.parseMarkdownToBlocks(value);
}
if (this.blocks.length === 0) {
this.blocks = [{
id: WysiwygShortcuts.generateBlockId(),
type: 'paragraph',
content: '',
}];
}
this.changeSubject.next(this.value);
// Re-render blocks programmatically if we have the editor
if (this.editorContentRef) {
this.renderBlocksProgrammatically();
}
}
/**
* Export the editor content as raw blocks (lossless)
*/
public exportBlocks(): IBlock[] {
return JSON.parse(JSON.stringify(this.blocks));
}
/**
* Import raw blocks (lossless)
*/
public importBlocks(blocks: IBlock[]): void {
this.blocks = JSON.parse(JSON.stringify(blocks));
this.updateValue();
// Re-render blocks programmatically if we have the editor
if (this.editorContentRef) {
this.renderBlocksProgrammatically();
}
}
/**
* Export content as HTML regardless of outputFormat setting
*/
public exportAsHtml(): string {
return WysiwygConverters.getHtmlOutput(this.blocks);
}
/**
* Export content as Markdown regardless of outputFormat setting
*/
public exportAsMarkdown(): string {
return WysiwygConverters.getMarkdownOutput(this.blocks);
}
/**
* Get a JSON representation of the editor state (for saving)
*/
public exportState(): { blocks: IBlock[], outputFormat: OutputFormat } {
return {
blocks: this.exportBlocks(),
outputFormat: this.outputFormat
};
}
/**
* Restore editor state from JSON
*/
public importState(state: { blocks: IBlock[], outputFormat?: OutputFormat }): void {
if (state.outputFormat) {
this.outputFormat = state.outputFormat;
}
this.importBlocks(state.blocks);
}
private handleDragEnd(): void {
// Remove all drag-related classes
if (this.draggedBlockId) {
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${this.draggedBlockId}"]`);
if (wrapper) {
wrapper.classList.remove('dragging');
}
}
// Remove all drag-over classes
const allWrappers = this.editorContentRef.querySelectorAll('.block-wrapper');
allWrappers.forEach(wrapper => {
wrapper.classList.remove('drag-over-before', 'drag-over-after');
});
// Remove dragging class from editor content
this.editorContentRef.classList.remove('dragging');
this.draggedBlockId = null;
this.dragOverBlockId = null;
this.dragOverPosition = null;
}
public handleDrop(e: DragEvent, targetBlock: IBlock): void {
e.preventDefault();
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
const draggedIndex = this.blocks.findIndex(b => b.id === this.draggedBlockId);
const targetIndex = this.blocks.findIndex(b => b.id === targetBlock.id);
if (draggedIndex === -1 || targetIndex === -1) return;
// Remove the dragged block
const [draggedBlock] = this.blocks.splice(draggedIndex, 1);
// Calculate the new index
let newIndex = targetIndex;
if (this.dragOverPosition === 'after') {
newIndex = draggedIndex < targetIndex ? targetIndex : targetIndex + 1;
} else {
newIndex = draggedIndex < targetIndex ? targetIndex - 1 : targetIndex;
}
// Insert at new position
this.blocks.splice(newIndex, 0, draggedBlock);
// Re-render blocks programmatically to reflect the new order
this.renderBlocksProgrammatically();
// Update state
this.updateValue();
this.handleDragEnd();
// Focus the moved block
setTimeout(() => {
const movedBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${draggedBlock.id}"] .block`) as HTMLDivElement;
if (movedBlockElement && draggedBlock.type !== 'divider') {
movedBlockElement.focus();
}
}, 100);
}
private handleTextSelection(_e: MouseEvent): void {
// Don't interfere with slash menu
if (this.slashMenu.visible) return;
// Let the block component handle selection via custom event
}
private updateFormattingMenuPosition(): void {
// Get all shadow roots
const shadowRoots: ShadowRoot[] = [];
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
// Find all block shadow roots
const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper');
blockWrappers?.forEach(wrapper => {
const blockComponent = wrapper.querySelector('dees-wysiwyg-block');
if (blockComponent?.shadowRoot) {
shadowRoots.push(blockComponent.shadowRoot);
}
});
const coords = WysiwygFormatting.getSelectionCoordinates(...shadowRoots);
if (coords) {
// Show the global formatting menu at absolute coordinates
this.formattingMenu.show(
{ x: coords.x, y: coords.y },
async (command: string) => await this.applyFormat(command)
);
} else {
}
}
private hideFormattingMenu(): void {
this.formattingMenu.hide();
this.selectedText = '';
}
public async applyFormat(command: string): Promise<void> {
// Get all shadow roots
const shadowRoots: ShadowRoot[] = [];
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
// Find all block shadow roots
const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper');
blockWrappers?.forEach(wrapper => {
const blockComponent = wrapper.querySelector('dees-wysiwyg-block');
if (blockComponent?.shadowRoot) {
shadowRoots.push(blockComponent.shadowRoot);
}
});
// Get selection info using Shadow DOM-aware utilities
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Find which block contains the selection
let targetBlock: IBlock | undefined;
let targetBlockComponent: any;
const wrappers = this.shadowRoot!.querySelectorAll('.block-wrapper');
for (let i = 0; i < wrappers.length; i++) {
const wrapper = wrappers[i];
const blockComponent = wrapper.querySelector('dees-wysiwyg-block') as any;
if (blockComponent?.shadowRoot) {
const block = blockComponent.shadowRoot.querySelector('.block');
if (block && (
block.contains(selectionInfo.startContainer) ||
block.contains(selectionInfo.endContainer)
)) {
const blockId = wrapper.getAttribute('data-block-id');
targetBlock = this.blocks.find(b => b.id === blockId);
targetBlockComponent = blockComponent;
break;
}
}
}
if (!targetBlock || !targetBlockComponent) return;
// Create a range from our selection info
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
// Handle link command specially
if (command === 'link') {
const url = await this.showLinkDialog();
if (!url) {
// User cancelled - restore focus to block
targetBlockComponent.focus();
return;
}
// Apply link format
WysiwygFormatting.applyFormat(command, url, range, shadowRoots);
} else {
// Apply the format
WysiwygFormatting.applyFormat(command, undefined, range, shadowRoots);
}
// Update content after a microtask to ensure DOM is updated
await new Promise(resolve => setTimeout(resolve, 10));
// Force content update
targetBlock.content = targetBlockComponent.getContent();
// Update value to persist changes
this.updateValue();
// Restore focus to the block
targetBlockComponent.focus();
// For link command, close the formatting menu
if (command === 'link') {
this.hideFormattingMenu();
} else {
// Let selection handler update menu position
this.selectedText = '';
}
}
private async showLinkDialog(): Promise<string | null> {
return new Promise((resolve) => {
let linkUrl: string | null = null;
DeesModal.createAndShow({
heading: 'Add Link',
content: html`
<style>
.link-input {
width: 100%;
padding: 12px;
font-size: 16px;
border: 1px solid var(--dees-color-line-bright);
border-radius: 4px;
background: var(--dees-color-input);
color: var(--dees-color-text);
margin: 16px 0;
}
.link-input:focus {
outline: none;
border-color: var(--dees-color-primary);
}
</style>
<input
class="link-input"
type="url"
placeholder="https://example.com"
@keydown="${(e: KeyboardEvent) => {
if (e.key === 'Enter') {
const input = e.target as HTMLInputElement;
linkUrl = input.value;
// Find and click the OK button
const modal = input.closest('dees-modal');
if (modal) {
const okButton = modal.shadowRoot?.querySelector('.bottomButton:last-child') as HTMLElement;
if (okButton) okButton.click();
}
}
}}"
@input="${(e: InputEvent) => {
linkUrl = (e.target as HTMLInputElement).value;
}}"
/>
`,
menuOptions: [
{
name: 'Cancel',
action: async (modal) => {
modal.destroy();
resolve(null);
}
},
{
name: 'Add Link',
action: async (modal) => {
modal.destroy();
resolve(linkUrl);
}
}
]
});
// Focus the input after modal is shown
setTimeout(() => {
const input = document.querySelector('dees-modal .link-input') as HTMLInputElement;
if (input) {
input.focus();
}
}, 100);
});
}
/**
* Undo the last action
*/
private undo(): void {
const state = this.history.undo();
if (state) {
this.restoreState(state);
}
}
/**
* Redo the next action
*/
private redo(): void {
const state = this.history.redo();
if (state) {
this.restoreState(state);
}
}
/**
* Restore editor state from history
*/
private restoreState(state: { blocks: IBlock[]; selectedBlockId: string | null; cursorPosition?: { blockId: string; offset: number } }): void {
// Update blocks
this.blocks = state.blocks;
this.selectedBlockId = state.selectedBlockId;
// Re-render blocks
this.renderBlocksProgrammatically();
// Restore cursor position if available
if (state.cursorPosition) {
setTimeout(() => {
const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${state.cursorPosition!.blockId}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
blockComponent.focusWithCursor(state.cursorPosition!.offset);
}
}, 50);
} else if (state.selectedBlockId) {
// Just focus the selected block
setTimeout(() => {
this.blockOperations.focusBlock(state.selectedBlockId!);
}, 50);
}
// Update value
this.updateValue();
}
/**
* Save current state to history with cursor position
*/
public saveToHistory(debounce: boolean = true): void {
// Get current cursor position if a block is focused
let cursorPosition: { blockId: string; offset: number } | undefined;
if (this.selectedBlockId) {
const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${this.selectedBlockId}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent && typeof blockComponent.getCursorPosition === 'function') {
const editableElement = blockComponent.shadowRoot?.querySelector('.block') as HTMLElement;
if (editableElement) {
const offset = blockComponent.getCursorPosition(editableElement);
if (offset !== null) {
cursorPosition = {
blockId: this.selectedBlockId,
offset
};
}
}
}
}
if (debounce) {
this.history.saveState(this.blocks, this.selectedBlockId, cursorPosition);
} else {
this.history.saveCheckpoint(this.blocks, this.selectedBlockId, cursorPosition);
}
}
}

View File

@@ -0,0 +1,259 @@
import {
customElement,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import { zIndexRegistry } from '../00zindex.js';
import '../dees-icon.js';
import { type ISlashMenuItem } from './wysiwyg.types.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
declare global {
interface HTMLElementTagNameMap {
'dees-slash-menu': DeesSlashMenu;
}
}
@customElement('dees-slash-menu')
export class DeesSlashMenu extends DeesElement {
private static instance: DeesSlashMenu;
public static getInstance(): DeesSlashMenu {
if (!DeesSlashMenu.instance) {
DeesSlashMenu.instance = new DeesSlashMenu();
document.body.appendChild(DeesSlashMenu.instance);
}
return DeesSlashMenu.instance;
}
@state()
public visible: boolean = false;
@state()
private position: { x: number; y: number } = { x: 0, y: 0 };
@state()
private filter: string = '';
@state()
private selectedIndex: number = 0;
@state()
private menuZIndex: number = 1000;
private callback: ((type: string) => void) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
pointer-events: none;
top: 0;
left: 0;
width: 0;
height: 0;
}
.slash-menu {
position: fixed;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
padding: 4px;
min-width: 220px;
max-height: 300px;
overflow-y: auto;
pointer-events: auto;
user-select: none;
animation: fadeInScale 0.15s ease-out;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.98) translateY(-2px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.slash-menu-item {
padding: 8px 10px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 12px;
border-radius: 3px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
font-size: 14px;
}
.slash-menu-item:hover,
.slash-menu-item.selected {
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.slash-menu-item .icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.slash-menu-item:hover .icon,
.slash-menu-item.selected .icon {
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
}
`,
];
render(): TemplateResult {
if (!this.visible) return html``;
// Ensure z-index is applied to host element
this.style.zIndex = this.menuZIndex.toString();
const menuItems = this.getFilteredMenuItems();
return html`
<div
class="slash-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
data-menu-type="slash"
>
${menuItems.map((item, index) => html`
<div
class="slash-menu-item ${index === this.selectedIndex ? 'selected' : ''}"
data-item-type="${item.type}"
data-item-index="${index}"
>
<dees-icon class="icon" .icon="${item.icon}" iconSize="16"></dees-icon>
<span>${item.label}</span>
</div>
`)}
</div>
`;
}
private getFilteredMenuItems(): ISlashMenuItem[] {
const allItems = WysiwygShortcuts.getSlashMenuItems();
return allItems.filter(item =>
this.filter === '' ||
item.label.toLowerCase().includes(this.filter.toLowerCase())
);
}
private selectItem(type: string): void {
if (this.callback) {
this.callback(type);
}
this.hide();
}
public show(position: { x: number; y: number }, callback: (type: string) => void): void {
this.position = position;
this.callback = callback;
this.filter = '';
this.selectedIndex = 0;
// Get z-index from registry and apply immediately
this.menuZIndex = zIndexRegistry.getNextZIndex();
zIndexRegistry.register(this, this.menuZIndex);
this.style.zIndex = this.menuZIndex.toString();
this.visible = true;
}
public hide(): void {
this.visible = false;
this.callback = null;
this.filter = '';
this.selectedIndex = 0;
// Unregister from z-index registry
zIndexRegistry.unregister(this);
}
public updateFilter(filter: string): void {
this.filter = filter;
this.selectedIndex = 0;
}
public navigate(direction: 'up' | 'down'): void {
const items = this.getFilteredMenuItems();
if (direction === 'down') {
this.selectedIndex = (this.selectedIndex + 1) % items.length;
} else {
this.selectedIndex = this.selectedIndex === 0
? items.length - 1
: this.selectedIndex - 1;
}
}
public selectCurrent(): void {
const items = this.getFilteredMenuItems();
if (items[this.selectedIndex]) {
this.selectItem(items[this.selectedIndex].type);
}
}
public firstUpdated(): void {
// Set up event delegation
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
const menu = this.shadowRoot?.querySelector('.slash-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}
});
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
if (menuItem) {
e.preventDefault();
e.stopPropagation();
const itemType = menuItem.getAttribute('data-item-type');
if (itemType) {
this.selectItem(itemType);
}
}
});
this.shadowRoot?.addEventListener('mouseenter', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
if (menuItem) {
const index = parseInt(menuItem.getAttribute('data-item-index') || '0', 10);
this.selectedIndex = index;
}
}, true); // Use capture phase
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
const menu = this.shadowRoot?.querySelector('.slash-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}
}, true); // Use capture phase
}
}

View File

@@ -0,0 +1,965 @@
import {
customElement,
property,
static as html,
DeesElement,
type TemplateResult,
cssManager,
css,
} from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
import { WysiwygSelection } from './wysiwyg.selection.js';
import { BlockRegistry, type IBlockEventHandlers } from './blocks/index.js';
import './wysiwyg.blockregistration.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
import '../dees-contextmenu.js';
declare global {
interface HTMLElementTagNameMap {
'dees-wysiwyg-block': DeesWysiwygBlock;
}
}
@customElement('dees-wysiwyg-block')
export class DeesWysiwygBlock extends DeesElement {
async disconnectedCallback() {
await super.disconnectedCallback();
// Clean up selection handler
if ((this as any)._selectionHandler) {
document.removeEventListener('selectionchange', (this as any)._selectionHandler);
}
}
@property({ type: Object })
public block: IBlock;
@property({ type: Boolean })
public isSelected: boolean = false;
@property({ type: Object })
public handlers: IBlockEventHandlers;
@property({ type: Object })
public wysiwygComponent: any; // Reference to parent dees-input-wysiwyg
// Reference to the editable block element
private blockElement: HTMLDivElement | null = null;
// Track if we've initialized the content
private contentInitialized: boolean = false;
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private handlerStylesInjected = false;
// Block types that don't support contenteditable
private static readonly NON_EDITABLE_TYPES = ['image', 'divider', 'youtube'];
private injectHandlerStyles(): void {
// Only inject once per instance
if (this.handlerStylesInjected) return;
this.handlerStylesInjected = true;
// Get styles from all registered block handlers
let styles = '';
const blockTypes = BlockRegistry.getAllTypes();
for (const type of blockTypes) {
const handler = BlockRegistry.getHandler(type);
if (handler) {
styles += handler.getStyles();
}
}
if (styles) {
// Create and inject style element
const styleElement = document.createElement('style');
styleElement.textContent = styles;
this.shadowRoot?.appendChild(styleElement);
}
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.block {
padding: 4px 0;
min-height: 1.6em;
outline: none;
width: 100%;
word-wrap: break-word;
position: relative;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
}
.block:empty:not(:focus)::before {
content: attr(data-placeholder);
color: ${cssManager.bdTheme('#999', '#666')};
position: absolute;
pointer-events: none;
}
/* Block-specific styles moved to handlers */
/* Formatting styles */
.block :is(b, strong) {
font-weight: 600;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block :is(i, em) {
font-style: italic;
}
.block u {
text-decoration: underline;
}
.block s {
text-decoration: line-through;
}
.block code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 0.9em;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
padding: 2px 6px;
border-radius: 3px;
color: ${cssManager.bdTheme('#d14', '#ff6b6b')};
}
.block a {
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.15s ease;
cursor: pointer;
}
.block a:hover {
border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
/* Code block container and language styles moved to handler */
/* Selection styles */
.block ::selection {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
color: inherit;
}
/* Strike through */
.block :is(s, strike) {
text-decoration: line-through;
opacity: 0.7;
}
/* Block margin adjustments based on type */
:host-context(.block-wrapper:first-child) .block {
margin-top: 0 !important;
}
:host-context(.block-wrapper:last-child) .block {
margin-bottom: 0;
}
/* Selected state */
.block.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)')};
border-radius: 4px;
margin-left: -8px;
margin-right: -8px;
padding-left: 8px;
padding-right: 8px;
}
`,
];
protected shouldUpdate(changedProperties: Map<string, any>): boolean {
// If selection state changed, update the selected class without re-rendering
if (changedProperties.has('isSelected') && this.block) {
// Find the block element based on block type
let element: HTMLElement | null = null;
// Build the specific selector based on block type
const blockType = this.block.type;
const selector = `.block.${blockType}`;
element = this.shadowRoot?.querySelector(selector) as HTMLElement;
if (element) {
if (this.isSelected) {
element.classList.add('selected');
} else {
element.classList.remove('selected');
}
}
return false; // Don't re-render, just update the class
}
// Never update if only the block content changed
if (changedProperties.has('block') && this.block) {
const oldBlock = changedProperties.get('block');
if (oldBlock && oldBlock.id && oldBlock.type && oldBlock.id === this.block.id && oldBlock.type === this.block.type) {
// Only content or metadata changed, don't re-render
return false;
}
}
// Only update if the block type or id changes
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
}
public firstUpdated(): void {
// Mark that content has been initialized
this.contentInitialized = true;
// Inject handler styles if not already done
this.injectHandlerStyles();
// First, populate the container with the rendered content
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container && this.block) {
container.innerHTML = this.renderBlockContent();
}
// Check if we have a registered handler for this block type
if (this.block) {
const handler = BlockRegistry.getHandler(this.block.type);
if (handler) {
const blockElement = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
if (blockElement) {
handler.setup(blockElement, this.block, this.handlers);
}
return; // Block handler takes care of all setup
}
}
// Handle special block types
// Now find the actual editable block element
const editableBlock = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
// Ensure the block element maintains its content
if (editableBlock) {
editableBlock.setAttribute('data-block-id', this.block.id);
editableBlock.setAttribute('data-block-type', this.block.type);
// Set up all event handlers manually to avoid Lit re-renders
editableBlock.addEventListener('input', (e) => {
this.handlers?.onInput?.(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
editableBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
this.handlers?.onKeyDown?.(e);
});
editableBlock.addEventListener('focus', () => {
this.handlers?.onFocus?.();
});
editableBlock.addEventListener('blur', () => {
this.handlers?.onBlur?.();
});
editableBlock.addEventListener('compositionstart', () => {
this.handlers?.onCompositionStart?.();
});
editableBlock.addEventListener('compositionend', () => {
this.handlers?.onCompositionEnd?.();
});
editableBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Selection will be handled by selectionchange event
this.handlers?.onMouseUp?.(e);
});
editableBlock.addEventListener('click', () => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// 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.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: '',
blockId: this.block.id,
hasSelection: false
},
bubbles: true,
composed: true
}));
}
return;
}
// Get fresh reference to the editable block
const currentEditableBlock = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!currentEditableBlock) return;
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
// 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(currentEditableBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, 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.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: selectedText.trim(),
blockId: this.block.id,
range: range,
rect: rect,
hasSelection: true
},
bubbles: true,
composed: true
}));
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: '',
blockId: this.block.id,
hasSelection: false
},
bubbles: true,
composed: true
}));
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
(this as any)._selectionHandler = checkSelection;
// Add keyup handler for cursor position tracking
editableBlock.addEventListener('keyup', () => {
// Track cursor position
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set initial content if needed
if (this.block.content) {
editableBlock.innerHTML = this.block.content;
}
}
// Store reference to the block element for quick access
this.blockElement = editableBlock;
}
render(): TemplateResult {
if (!this.block) return html``;
// Since we need dynamic content, we'll render an empty container
// and set the innerHTML in firstUpdated
return html`<div class="wysiwyg-block-container"></div>`;
}
private renderBlockContent(): string {
if (!this.block) return '';
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler) {
return handler.render(this.block, this.isSelected);
}
// Default rendering for blocks without handlers
const selectedClass = this.isSelected ? ' selected' : '';
return `
<div
class="block ${this.block.type}${selectedClass}"
contenteditable="true"
></div>
`;
}
public focus(): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.focus) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.focus(container, context);
}
// Handle non-editable blocks
if (this.block && DeesWysiwygBlock.NON_EDITABLE_TYPES.includes(this.block.type)) {
const blockElement = this.shadowRoot?.querySelector(`.block.${this.block.type}`) as HTMLDivElement;
if (blockElement) {
blockElement.focus();
}
return;
}
// Get the actual editable element
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
// Ensure the element is focusable
if (!editableElement.hasAttribute('contenteditable')) {
editableElement.setAttribute('contenteditable', 'true');
}
editableElement.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== editableElement && this.shadowRoot?.activeElement !== editableElement) {
Promise.resolve().then(() => {
editableElement.focus();
});
}
}
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.focusWithCursor) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.focusWithCursor(container, position, context);
}
// Non-editable blocks don't support cursor positioning
if (this.block && DeesWysiwygBlock.NON_EDITABLE_TYPES.includes(this.block.type)) {
this.focus();
return;
}
// Get the actual editable element
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
// Ensure element is focusable first
if (!editableElement.hasAttribute('contenteditable')) {
editableElement.setAttribute('contenteditable', 'true');
}
// Focus the element
editableElement.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart();
} else if (position === 'end') {
this.setCursorToEnd();
} else if (typeof position === 'number') {
// Use the new selection utility to set cursor position
WysiwygSelection.setCursorPosition(editableElement, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
setCursor();
}
});
}
}
/**
* Get cursor position in the editable element
*/
public getCursorPosition(element: HTMLElement): number | null {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.getCursorPosition) {
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.getCursorPosition(element, context);
}
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getCursorPosition: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getCursorPosition: No selection found');
return null;
}
console.log('getCursorPosition: Range info:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
collapsed: selectionInfo.collapsed,
startContainerText: selectionInfo.startContainer.textContent
});
if (!element.contains(selectionInfo.startContainer)) {
console.log('getCursorPosition: Range not in element');
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
console.log('getCursorPosition: Calculated position:', {
position,
preCaretText: preCaretRange.toString(),
elementText: element.textContent,
elementTextLength: element.textContent?.length
});
return position;
}
public getContent(): string {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.getContent) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.getContent(container, context);
}
// Get the actual editable element
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return '';
// Get the innerHTML which includes formatting tags
const content = editableElement.innerHTML || '';
console.log('Getting content from block:', content);
return content;
}
public setContent(content: string): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.setContent) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.setContent(container, content, context);
}
// Get the actual editable element
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
// Store if we have focus
const hadFocus = document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement;
editableElement.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
editableElement.focus();
}
}
public setCursorToStart(): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.setCursorToStart) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.setCursorToStart(container, context);
}
// Always find the element fresh, don't rely on cached blockElement
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement) {
WysiwygBlocks.setCursorToStart(editableElement);
}
}
public setCursorToEnd(): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.setCursorToEnd) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.setCursorToEnd(container, context);
}
// Always find the element fresh, don't rely on cached blockElement
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement) {
WysiwygBlocks.setCursorToEnd(editableElement);
}
}
/**
* Get context menu items for this block
*/
public getContextMenuItems(): any[] {
if (!this.block || this.block.type === 'divider') {
return [];
}
const blockTypes = WysiwygShortcuts.getSlashMenuItems();
const currentType = this.block.type;
// Use the parent reference passed from dees-input-wysiwyg
const wysiwygComponent = this.wysiwygComponent;
const blockId = this.block.id;
// Create submenu items for block type change
const blockTypeItems = blockTypes
.filter(item => item.type !== currentType && item.type !== 'divider')
.map(item => ({
name: item.label,
iconName: item.icon.replace('lucide:', ''),
action: async () => {
if (wysiwygComponent && wysiwygComponent.blockOperations) {
// Transform the block type
const blockToTransform = wysiwygComponent.blocks.find((b: IBlock) => b.id === blockId);
if (blockToTransform) {
blockToTransform.type = item.type;
blockToTransform.content = blockToTransform.content || '';
// Handle special metadata for different block types
if (item.type === 'code') {
blockToTransform.metadata = { language: 'typescript' };
} else if (item.type === 'list') {
blockToTransform.metadata = { listType: 'bullet' };
} else if (item.type === 'image') {
blockToTransform.content = '';
blockToTransform.metadata = { url: '', loading: false };
} else if (item.type === 'youtube') {
blockToTransform.content = '';
blockToTransform.metadata = { videoId: '', url: '' };
} else if (item.type === 'markdown') {
blockToTransform.metadata = { showPreview: false };
} else if (item.type === 'html') {
blockToTransform.metadata = { showPreview: false };
} else if (item.type === 'attachment') {
blockToTransform.content = '';
blockToTransform.metadata = { files: [] };
}
// Update the block element
wysiwygComponent.updateBlockElement(blockId);
wysiwygComponent.updateValue();
// Focus the block after transformation
requestAnimationFrame(() => {
wysiwygComponent.blockOperations.focusBlock(blockId);
});
}
}
}
}));
const menuItems: any[] = [
{
name: 'Change Type',
iconName: 'type',
submenu: blockTypeItems
}
];
// Add copy/cut/paste for editable blocks
if (!['image', 'divider', 'youtube', 'attachment'].includes(this.block.type)) {
menuItems.push(
{ divider: true },
{
name: 'Cut',
iconName: 'scissors',
shortcut: 'Cmd+X',
action: async () => {
document.execCommand('cut');
}
},
{
name: 'Copy',
iconName: 'copy',
shortcut: 'Cmd+C',
action: async () => {
document.execCommand('copy');
}
},
{
name: 'Paste',
iconName: 'clipboard',
shortcut: 'Cmd+V',
action: async () => {
document.execCommand('paste');
}
}
);
}
// Add delete option
menuItems.push(
{ divider: true },
{
name: 'Delete Block',
iconName: 'trash2',
action: async () => {
if (wysiwygComponent && wysiwygComponent.blockOperations) {
wysiwygComponent.blockOperations.deleteBlock(blockId);
}
}
}
);
return menuItems;
}
/**
* Gets content split at cursor position
*/
public getSplitContent(): { before: string; after: string } | null {
console.log('getSplitContent: Starting...');
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
console.log('getSplitContent: Checking for handler', {
blockType: this.block.type,
hasHandler: !!handler,
hasSplitMethod: !!(handler && handler.getSplitContent)
});
if (handler && handler.getSplitContent) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
console.log('getSplitContent: Found container', {
container: !!container,
containerHTML: container?.innerHTML?.substring(0, 100)
});
const context = {
shadowRoot: this.shadowRoot!,
component: this
};
return handler.getSplitContent(container, context);
}
// Get the actual editable element first
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) {
console.log('getSplitContent: No editable element found');
return null;
}
console.log('getSplitContent: Element info:', {
blockType: this.block.type,
innerHTML: editableElement.innerHTML,
textContent: editableElement.textContent,
textLength: editableElement.textContent?.length
});
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getSplitContent: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
console.log('getSplitContent: Splitting with last known position:', {
pos,
fullTextLength: fullText.length,
before: fullText.substring(0, pos),
after: fullText.substring(pos)
});
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
console.log('getSplitContent: Selection range:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
startContainerInElement: editableElement.contains(selectionInfo.startContainer)
});
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(editableElement, selectionInfo.startContainer)) {
console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// For HTML content, get cursor position first
const cursorPos = this.getCursorPosition(editableElement);
console.log('getSplitContent: Cursor position for HTML split:', cursorPos);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
console.log('getSplitContent: Cursor at start or null, moving all content');
return {
before: '',
after: editableElement.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(editableElement, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(editableElement, editableElement.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;
console.log('getSplitContent: Final split result:', {
cursorPos,
beforeHtml,
beforeLength: beforeHtml.length,
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
afterHtml,
afterLength: afterHtml.length,
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
});
return {
before: beforeHtml,
after: afterHtml
};
}
}

View File

@@ -0,0 +1,18 @@
export * from './wysiwyg.types.js';
export * from './wysiwyg.interfaces.js';
export * from './wysiwyg.constants.js';
export * from './wysiwyg.styles.js';
export * from './wysiwyg.converters.js';
export * from './wysiwyg.shortcuts.js';
export * from './wysiwyg.blocks.js';
export * from './wysiwyg.formatting.js';
export * from './wysiwyg.selection.js';
export * from './wysiwyg.blockoperations.js';
export * from './wysiwyg.inputhandler.js';
export * from './wysiwyg.keyboardhandler.js';
export * from './wysiwyg.dragdrophandler.js';
export * from './wysiwyg.modalmanager.js';
export * from './wysiwyg.history.js';
export * from './dees-wysiwyg-block.js';
export * from './dees-slash-menu.js';
export * from './dees-formatting-menu.js';

View File

@@ -0,0 +1,61 @@
# Phase 2 Implementation Summary - Divider Block Migration
## Overview
Successfully migrated the divider block to the new block handler architecture as a proof of concept.
## Changes Made
### 1. Created Block Handler
- **File**: `blocks/content/divider.block.ts`
- Implemented `DividerBlockHandler` class extending `BaseBlockHandler`
- Extracted divider rendering logic from `dees-wysiwyg-block.ts`
- Extracted divider setup logic (event handlers)
- Extracted divider-specific styles
### 2. Registration System
- **File**: `wysiwyg.blockregistration.ts`
- Created registration module that registers all block handlers
- Currently registers only the divider handler
- Includes placeholders for future block types
### 3. Updated Block Component
- **File**: `dees-wysiwyg-block.ts`
- Added import for BlockRegistry and handler types
- Modified `renderBlockContent()` to check registry first
- Modified `firstUpdated()` to use registry for setup
- Added `injectHandlerStyles()` method to inject handler styles dynamically
- Removed hardcoded divider rendering logic
- Removed hardcoded divider styles
- Removed `setupDividerBlock()` method
### 4. Updated Exports
- **File**: `blocks/index.ts`
- Exported `DividerBlockHandler` class
## Key Features Preserved
✅ Visual appearance with gradient and icon
✅ Click to select behavior
✅ Keyboard navigation support (Tab, Arrow keys)
✅ Deletion with backspace/delete
✅ Focus/blur handling
✅ Proper styling for selected state
## Architecture Benefits
1. **Modularity**: Each block type is now self-contained
2. **Maintainability**: Block-specific logic is isolated
3. **Extensibility**: Easy to add new block types
4. **Type Safety**: Proper TypeScript interfaces
5. **Code Reuse**: Common functionality in BaseBlockHandler
## Next Steps
To migrate other block types, follow this pattern:
1. Create handler file in appropriate folder (text/, media/, content/)
2. Extract render logic, setup logic, and styles
3. Register in `wysiwyg.blockregistration.ts`
4. Remove hardcoded logic from `dees-wysiwyg-block.ts`
5. Export from `blocks/index.ts`
## Testing
- Project builds successfully without errors
- Existing tests pass
- Divider blocks render and function identically to before

View File

@@ -0,0 +1,75 @@
# Phase 4 Implementation Summary - Heading Blocks Migration
## Overview
Successfully migrated all heading blocks (h1, h2, h3) to the new block handler architecture using a unified HeadingBlockHandler class.
## Changes Made
### 1. Created Unified Heading Handler
- **File**: `blocks/text/heading.block.ts`
- Implemented `HeadingBlockHandler` class extending `BaseBlockHandler`
- Single handler class that accepts heading level (1, 2, or 3) in constructor
- Extracted all heading rendering logic from `dees-wysiwyg-block.ts`
- Extracted heading setup logic with full text editing support:
- Input handling with cursor tracking
- Selection handling with Shadow DOM support
- Focus/blur management
- Composition events for IME support
- Split content functionality
- Extracted all heading-specific styles for all three levels
### 2. Registration Updates
- **File**: `wysiwyg.blockregistration.ts`
- Registered three heading handlers using the same class:
- `heading-1``new HeadingBlockHandler('heading-1')`
- `heading-2``new HeadingBlockHandler('heading-2')`
- `heading-3``new HeadingBlockHandler('heading-3')`
- Updated imports to include HeadingBlockHandler
### 3. Updated Exports
- **File**: `blocks/index.ts`
- Exported `HeadingBlockHandler` class
- Removed TODO comment for heading handler
### 4. Handler Implementation Details
- **Dynamic Level Handling**: The handler determines the heading level from the block type
- **Shared Styles**: All heading levels share the same style method but render different CSS
- **Placeholder Support**: Each level has its own placeholder text
- **Full Text Editing**: Inherits all paragraph-like functionality:
- Cursor position tracking
- Text selection with Shadow DOM awareness
- Content splitting for Enter key handling
- Focus management with cursor positioning
## Key Features Preserved
✅ All three heading levels render with correct styles
✅ Font sizes: h1 (32px), h2 (24px), h3 (20px)
✅ Proper font weights and line heights
✅ Theme-aware colors using cssManager.bdTheme
✅ Contenteditable functionality
✅ Selection and cursor tracking
✅ Keyboard navigation
✅ Focus/blur handling
✅ Placeholder text for empty headings
## Architecture Benefits
1. **Code Reuse**: Single handler class for all heading levels
2. **Consistency**: All headings share the same behavior
3. **Maintainability**: Changes to heading behavior only need to be made once
4. **Type Safety**: Heading level is type-checked at construction
5. **Scalability**: Easy to add more heading levels if needed
## Testing Results
- ✅ TypeScript compilation successful
- ✅ All three heading handlers registered correctly
- ✅ Render method produces correct HTML with proper classes
- ✅ Placeholders set correctly for each level
- ✅ All handlers are instances of HeadingBlockHandler
## Next Steps
Continue with Phase 5 to migrate remaining text blocks:
- Quote block
- Code block
- List block
Each will follow the same pattern but with their specific requirements.

View File

@@ -0,0 +1,177 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
export class WysiwygBlockOperations {
private component: IWysiwygComponent;
constructor(component: IWysiwygComponent) {
this.component = component;
}
/**
* Creates a new block with the specified parameters
*/
createBlock(type: IBlock['type'] = 'paragraph', content: string = '', metadata?: any): IBlock {
return {
id: WysiwygShortcuts.generateBlockId(),
type,
content,
...(metadata && { metadata })
};
}
/**
* Inserts a block after the specified block
*/
async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
const blocks = this.component.blocks;
const blockIndex = blocks.findIndex((b: IBlock) => b.id === afterBlock.id);
this.component.blocks = [
...blocks.slice(0, blockIndex + 1),
newBlock,
...blocks.slice(blockIndex + 1)
];
// Insert the new block element programmatically if we have the editor
if (this.component.editorContentRef) {
const afterWrapper = this.component.editorContentRef.querySelector(`[data-block-id="${afterBlock.id}"]`);
if (afterWrapper) {
const newWrapper = this.component.createBlockElement(newBlock);
afterWrapper.insertAdjacentElement('afterend', newWrapper);
}
}
this.component.updateValue();
if (focusNewBlock && newBlock.type !== 'divider') {
// Give DOM time to settle
await new Promise(resolve => setTimeout(resolve, 0));
// Focus the new block
await this.focusBlock(newBlock.id, 'start');
}
}
/**
* Removes a block by its ID
*/
removeBlock(blockId: string): void {
// Save checkpoint before deletion
this.component.saveToHistory(false);
this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId);
// Remove the block element programmatically if we have the editor
if (this.component.editorContentRef) {
const wrapper = this.component.editorContentRef.querySelector(`[data-block-id="${blockId}"]`);
if (wrapper) {
wrapper.remove();
}
}
this.component.updateValue();
}
/**
* Finds a block by its ID
*/
findBlock(blockId: string): IBlock | undefined {
return this.component.blocks.find((b: IBlock) => b.id === blockId);
}
/**
* Gets the index of a block
*/
getBlockIndex(blockId: string): number {
return this.component.blocks.findIndex((b: IBlock) => b.id === blockId);
}
/**
* Focuses a specific block
*/
async focusBlock(blockId: string, cursorPosition: 'start' | 'end' | number = 'start'): Promise<void> {
const wrapperElement = this.component.shadowRoot!.querySelector(`[data-block-id="${blockId}"]`);
if (wrapperElement) {
const blockComponent = wrapperElement.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
// Wait a frame to ensure the block is rendered
await new Promise(resolve => requestAnimationFrame(resolve));
// Now focus with cursor position
blockComponent.focusWithCursor(cursorPosition);
}
}
}
/**
* Updates the content of a block
*/
updateBlockContent(blockId: string, content: string): void {
const block = this.findBlock(blockId);
if (block) {
block.content = content;
this.component.updateValue();
}
}
/**
* Transforms a block to a different type
*/
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void {
const block = this.findBlock(blockId);
if (block) {
// Save checkpoint before transformation
this.component.saveToHistory(false);
block.type = newType;
block.content = '';
if (metadata) {
block.metadata = metadata;
}
// Update the block element programmatically if we have the editor
if (this.component.editorContentRef) {
this.component.updateBlockElement(blockId);
}
this.component.updateValue();
}
}
/**
* Moves a block to a new position
*/
moveBlock(blockId: string, targetIndex: number): void {
const blocks = [...this.component.blocks];
const currentIndex = this.getBlockIndex(blockId);
if (currentIndex === -1 || targetIndex < 0 || targetIndex >= blocks.length) {
return;
}
const [movedBlock] = blocks.splice(currentIndex, 1);
blocks.splice(targetIndex, 0, movedBlock);
this.component.blocks = blocks;
this.component.updateValue();
}
/**
* Gets the previous block
*/
getPreviousBlock(blockId: string): IBlock | null {
const index = this.getBlockIndex(blockId);
return index > 0 ? this.component.blocks[index - 1] : null;
}
/**
* Gets the next block
*/
getNextBlock(blockId: string): IBlock | null {
const index = this.getBlockIndex(blockId);
return index < this.component.blocks.length - 1 ? this.component.blocks[index + 1] : null;
}
}

View File

@@ -0,0 +1,59 @@
/**
* Block Registration Module
* Handles registration of all block handlers with the BlockRegistry
*
* Phase 2 Complete: Divider block has been successfully migrated
* to the new block handler architecture.
* Phase 3 Complete: Paragraph block has been successfully migrated
* to the new block handler architecture.
* Phase 4 Complete: All heading blocks (h1, h2, h3) have been successfully migrated
* to the new block handler architecture using a unified HeadingBlockHandler.
* Phase 5 Complete: Quote, Code, and List blocks have been successfully migrated
* to the new block handler architecture.
* Phase 6 Complete: Image, YouTube, and Attachment blocks have been successfully migrated
* to the new block handler architecture.
* Phase 7 Complete: Markdown and HTML blocks have been successfully migrated
* to the new block handler architecture.
*/
import {
BlockRegistry,
DividerBlockHandler,
ParagraphBlockHandler,
HeadingBlockHandler,
QuoteBlockHandler,
CodeBlockHandler,
ListBlockHandler,
ImageBlockHandler,
YouTubeBlockHandler,
AttachmentBlockHandler,
MarkdownBlockHandler,
HtmlBlockHandler
} from './blocks/index.js';
// Initialize and register all block handlers
export function registerAllBlockHandlers(): void {
// Register content blocks
BlockRegistry.register('divider', new DividerBlockHandler());
// Register text blocks
BlockRegistry.register('paragraph', new ParagraphBlockHandler());
BlockRegistry.register('heading-1', new HeadingBlockHandler('heading-1'));
BlockRegistry.register('heading-2', new HeadingBlockHandler('heading-2'));
BlockRegistry.register('heading-3', new HeadingBlockHandler('heading-3'));
BlockRegistry.register('quote', new QuoteBlockHandler());
BlockRegistry.register('code', new CodeBlockHandler());
BlockRegistry.register('list', new ListBlockHandler());
// Register media blocks
BlockRegistry.register('image', new ImageBlockHandler());
BlockRegistry.register('youtube', new YouTubeBlockHandler());
BlockRegistry.register('attachment', new AttachmentBlockHandler());
// Register other content blocks
BlockRegistry.register('markdown', new MarkdownBlockHandler());
BlockRegistry.register('html', new HtmlBlockHandler());
}
// Ensure blocks are registered when this module is imported
registerAllBlockHandlers();

View File

@@ -0,0 +1,202 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygConverters } from './wysiwyg.converters.js';
export class WysiwygBlocks {
static renderListContent(content: string, metadata?: any): string {
const items = content.split('\n').filter(item => item.trim());
if (items.length === 0) return '';
const listTag = metadata?.listType === 'ordered' ? 'ol' : 'ul';
// Don't escape HTML to preserve formatting
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
}
static renderBlock(
block: IBlock,
isSelected: boolean,
handlers: {
onInput: (e: InputEvent) => void;
onKeyDown: (e: KeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void;
}
): TemplateResult {
if (block.type === 'divider') {
return html`
<div
class="block divider"
data-block-id="${block.id}"
>
<hr>
</div>
`;
}
if (block.type === 'list') {
return html`
<div
class="block list ${isSelected ? 'selected' : ''}"
data-block-id="${block.id}"
contenteditable="true"
@input="${handlers.onInput}"
@keydown="${handlers.onKeyDown}"
@focus="${handlers.onFocus}"
@blur="${handlers.onBlur}"
@compositionstart="${handlers.onCompositionStart}"
@compositionend="${handlers.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
console.log('Block mouseup event fired');
if (handlers.onMouseUp) handlers.onMouseUp(e);
}}"
.innerHTML="${this.renderListContent(block.content, block.metadata)}"
></div>
`;
}
// Special rendering for code blocks with language indicator
if (block.type === 'code') {
const language = block.metadata?.language || 'plain text';
return html`
<div class="code-block-container">
<div class="code-language">${language}</div>
<div
class="block ${block.type} ${isSelected ? 'selected' : ''}"
contenteditable="true"
@input="${handlers.onInput}"
@keydown="${handlers.onKeyDown}"
@focus="${handlers.onFocus}"
@blur="${handlers.onBlur}"
@compositionstart="${handlers.onCompositionStart}"
@compositionend="${handlers.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
console.log('Block mouseup event fired');
if (handlers.onMouseUp) handlers.onMouseUp(e);
}}"
.textContent="${block.content || ''}"
></div>
</div>
`;
}
const blockElement = html`
<div
class="block ${block.type} ${isSelected ? 'selected' : ''}"
contenteditable="true"
@input="${handlers.onInput}"
@keydown="${handlers.onKeyDown}"
@focus="${handlers.onFocus}"
@blur="${handlers.onBlur}"
@compositionstart="${handlers.onCompositionStart}"
@compositionend="${handlers.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
console.log('Block mouseup event fired');
if (handlers.onMouseUp) handlers.onMouseUp(e);
}}"
.innerHTML="${block.content || ''}"
></div>
`;
return blockElement;
}
static setCursorToEnd(element: HTMLElement): void {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
// Handle different content types
if (element.childNodes.length === 0) {
// Empty element - add a zero-width space to enable cursor
const textNode = document.createTextNode('\u200B');
element.appendChild(textNode);
range.setStart(textNode, 1);
range.collapse(true);
} else {
// Find the last text node or element
const lastNode = this.getLastNode(element);
if (lastNode.nodeType === Node.TEXT_NODE) {
range.setStart(lastNode, lastNode.textContent?.length || 0);
} else {
range.setStartAfter(lastNode);
}
range.collapse(true);
}
sel.removeAllRanges();
sel.addRange(range);
// Remove zero-width space if it was added
if (element.textContent === '\u200B') {
element.textContent = '';
}
}
static setCursorToStart(element: HTMLElement): void {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
// Handle different content types
if (element.childNodes.length === 0) {
// Empty element
range.setStart(element, 0);
range.collapse(true);
} else {
// Find the first text node or element
const firstNode = this.getFirstNode(element);
if (firstNode.nodeType === Node.TEXT_NODE) {
range.setStart(firstNode, 0);
} else {
range.setStartBefore(firstNode);
}
range.collapse(true);
}
sel.removeAllRanges();
sel.addRange(range);
}
private static getLastNode(element: Node): Node {
if (element.childNodes.length === 0) {
return element;
}
const lastChild = element.childNodes[element.childNodes.length - 1];
if (lastChild.nodeType === Node.TEXT_NODE || lastChild.childNodes.length === 0) {
return lastChild;
}
return this.getLastNode(lastChild);
}
private static getFirstNode(element: Node): Node {
if (element.childNodes.length === 0) {
return element;
}
const firstChild = element.childNodes[0];
if (firstChild.nodeType === Node.TEXT_NODE || firstChild.childNodes.length === 0) {
return firstChild;
}
return this.getFirstNode(firstChild);
}
static focusListItem(listElement: HTMLElement): void {
const firstLi = listElement.querySelector('li');
if (firstLi) {
firstLi.focus();
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(firstLi);
range.collapse(true);
sel!.removeAllRanges();
sel!.addRange(range);
}
}
}

View File

@@ -0,0 +1,27 @@
/**
* Shared constants for the WYSIWYG editor
*/
/**
* Available programming languages for code blocks
*/
export const PROGRAMMING_LANGUAGES = [
'JavaScript',
'TypeScript',
'Python',
'Java',
'C++',
'C#',
'Go',
'Rust',
'HTML',
'CSS',
'SQL',
'Shell',
'JSON',
'YAML',
'Markdown',
'Plain Text'
] as const;
export type ProgrammingLanguage = typeof PROGRAMMING_LANGUAGES[number];

View File

@@ -0,0 +1,329 @@
import { type IBlock } from './wysiwyg.types.js';
export class WysiwygConverters {
static escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
static formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
static getHtmlOutput(blocks: IBlock[]): string {
return blocks.map(block => {
// Check if content already contains HTML formatting
const content = block.content.includes('<') && block.content.includes('>')
? block.content // Already contains HTML formatting
: this.escapeHtml(block.content);
switch (block.type) {
case 'paragraph':
return block.content ? `<p>${content}</p>` : '';
case 'heading-1':
return `<h1>${content}</h1>`;
case 'heading-2':
return `<h2>${content}</h2>`;
case 'heading-3':
return `<h3>${content}</h3>`;
case 'quote':
return `<blockquote>${content}</blockquote>`;
case 'code':
return `<pre><code>${this.escapeHtml(block.content)}</code></pre>`;
case 'list':
const items = block.content.split('\n').filter(item => item.trim());
if (items.length > 0) {
const listTag = block.metadata?.listType === 'ordered' ? 'ol' : 'ul';
// Don't escape HTML in list items to preserve formatting
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
}
return '';
case 'divider':
return '<hr>';
case 'image':
const imageUrl = block.metadata?.url;
if (imageUrl) {
const altText = this.escapeHtml(block.content || 'Image');
return `<img src="${imageUrl}" alt="${altText}" />`;
}
return '';
case 'youtube':
const videoId = block.metadata?.videoId;
if (videoId) {
return `<iframe width="560" height="315" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
}
return '';
case 'markdown':
// Return the raw markdown content wrapped in a div
return `<div class="markdown-content">${this.escapeHtml(block.content)}</div>`;
case 'html':
// Return the raw HTML content (already HTML)
return block.content;
case 'attachment':
const files = block.metadata?.files || [];
if (files.length > 0) {
return `<div class="attachments">${files.map((file: any) =>
`<div class="attachment-item" data-file-id="${file.id}">
<a href="${file.data}" download="${file.name}">${this.escapeHtml(file.name)}</a>
<span class="file-size">(${this.formatFileSize(file.size)})</span>
</div>`
).join('')}</div>`;
}
return '';
default:
return `<p>${content}</p>`;
}
}).filter(html => html !== '').join('\n');
}
static getMarkdownOutput(blocks: IBlock[]): string {
return blocks.map(block => {
switch (block.type) {
case 'paragraph':
return block.content;
case 'heading-1':
return `# ${block.content}`;
case 'heading-2':
return `## ${block.content}`;
case 'heading-3':
return `### ${block.content}`;
case 'quote':
return `> ${block.content}`;
case 'code':
return `\`\`\`\n${block.content}\n\`\`\``;
case 'list':
const items = block.content.split('\n').filter(item => item.trim());
if (block.metadata?.listType === 'ordered') {
return items.map((item, index) => `${index + 1}. ${item}`).join('\n');
} else {
return items.map(item => `- ${item}`).join('\n');
}
case 'divider':
return '---';
case 'image':
const imageUrl = block.metadata?.url;
const altText = block.content || 'Image';
return imageUrl ? `![${altText}](${imageUrl})` : '';
case 'youtube':
const videoId = block.metadata?.videoId;
const url = block.metadata?.url || (videoId ? `https://youtube.com/watch?v=${videoId}` : '');
return url ? `[YouTube Video](${url})` : '';
case 'markdown':
// Return the raw markdown content
return block.content;
case 'html':
// Return as HTML comment in markdown
return `<!-- HTML Block\n${block.content}\n-->`;
case 'attachment':
const files = block.metadata?.files || [];
if (files.length > 0) {
return files.map((file: any) => `- [${file.name}](${file.data})`).join('\n');
}
return '';
default:
return block.content;
}
}).filter(md => md !== '').join('\n\n');
}
static parseHtmlToBlocks(html: string): IBlock[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const blocks: IBlock[] = [];
const processNode = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'paragraph',
content: node.textContent.trim(),
});
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
const tagName = element.tagName.toLowerCase();
switch (tagName) {
case 'p':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'paragraph',
content: element.innerHTML || '',
});
break;
case 'h1':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-1',
content: element.innerHTML || '',
});
break;
case 'h2':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-2',
content: element.innerHTML || '',
});
break;
case 'h3':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-3',
content: element.innerHTML || '',
});
break;
case 'blockquote':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'quote',
content: element.innerHTML || '',
});
break;
case 'pre':
case 'code':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'code',
content: element.textContent || '',
});
break;
case 'ul':
case 'ol':
const listItems = Array.from(element.querySelectorAll('li'));
// Use innerHTML to preserve formatting
const content = listItems.map(li => li.innerHTML || '').join('\n');
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'list',
content: content,
metadata: { listType: tagName === 'ol' ? 'ordered' : 'bullet' }
});
break;
case 'hr':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'divider',
content: ' ',
});
break;
case 'img':
const imgElement = element as HTMLImageElement;
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'image',
content: imgElement.alt || '',
metadata: { url: imgElement.src }
});
break;
default:
// Process children for other elements
element.childNodes.forEach(child => processNode(child));
}
}
};
doc.body.childNodes.forEach(node => processNode(node));
return blocks;
}
static parseMarkdownToBlocks(markdown: string): IBlock[] {
const lines = markdown.split('\n');
const blocks: IBlock[] = [];
let currentListItems: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('# ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-1',
content: line.substring(2),
});
} else if (line.startsWith('## ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-2',
content: line.substring(3),
});
} else if (line.startsWith('### ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-3',
content: line.substring(4),
});
} else if (line.startsWith('> ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'quote',
content: line.substring(2),
});
} else if (line.startsWith('```')) {
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'code',
content: codeLines.join('\n'),
});
} else if (line.match(/^(\*|-) /)) {
currentListItems.push(line.substring(2));
// Check if next line is not a list item
if (i === lines.length - 1 || (!lines[i + 1].match(/^(\*|-) /))) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'list',
content: currentListItems.join('\n'),
metadata: { listType: 'bullet' }
});
currentListItems = [];
}
} else if (line.match(/^\d+\. /)) {
currentListItems.push(line.replace(/^\d+\. /, ''));
// Check if next line is not a numbered list item
if (i === lines.length - 1 || (!lines[i + 1].match(/^\d+\. /))) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'list',
content: currentListItems.join('\n'),
metadata: { listType: 'ordered' }
});
currentListItems = [];
}
} else if (line === '---' || line === '***' || line === '___') {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'divider',
content: ' ',
});
} else if (line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/)) {
// Parse markdown image syntax ![alt](url)
const match = line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/);
if (match) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'image',
content: match[1] || '',
metadata: { url: match[2] }
});
}
} else if (line.trim()) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'paragraph',
content: line,
});
}
}
return blocks;
}
}

View File

@@ -0,0 +1,490 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
export class WysiwygDragDropHandler {
private component: IWysiwygComponent;
private draggedBlockId: string | null = null;
private dragOverBlockId: string | null = null;
private dragOverPosition: 'before' | 'after' | null = null;
private dropIndicator: HTMLElement | null = null;
private initialMouseY: number = 0;
private initialBlockY: number = 0;
private draggedBlockElement: HTMLElement | null = null;
private draggedBlockHeight: number = 0;
private draggedBlockContentHeight: number = 0;
private draggedBlockMarginTop: number = 0;
private lastUpdateTime: number = 0;
private updateThrottle: number = 80; // milliseconds
constructor(component: IWysiwygComponent) {
this.component = component;
}
/**
* Gets the current drag state
*/
get dragState() {
return {
draggedBlockId: this.draggedBlockId,
dragOverBlockId: this.dragOverBlockId,
dragOverPosition: this.dragOverPosition
};
}
/**
* Handles drag start
*/
handleDragStart(e: DragEvent, block: IBlock): void {
if (!e.dataTransfer) return;
this.draggedBlockId = block.id;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', block.id);
// Hide the default drag image
const emptyImg = new Image();
emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=';
e.dataTransfer.setDragImage(emptyImg, 0, 0);
// Store initial mouse position and block element
this.initialMouseY = e.clientY;
this.draggedBlockElement = this.component.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
if (this.draggedBlockElement) {
// Get the wrapper rect for measurements
const rect = this.draggedBlockElement.getBoundingClientRect();
this.initialBlockY = rect.top;
// Get the inner block element for proper measurements
const innerBlock = this.draggedBlockElement.querySelector('.block');
if (innerBlock) {
const innerRect = innerBlock.getBoundingClientRect();
const computedStyle = window.getComputedStyle(innerBlock);
this.draggedBlockMarginTop = parseInt(computedStyle.marginTop) || 0;
this.draggedBlockContentHeight = innerRect.height;
}
// The drop indicator should match the wrapper height exactly
// The wrapper already includes all the space the block occupies
this.draggedBlockHeight = rect.height;
console.log('Drag measurements:', {
wrapperHeight: rect.height,
marginTop: this.draggedBlockMarginTop,
dropIndicatorHeight: this.draggedBlockHeight,
contentHeight: this.draggedBlockContentHeight,
blockId: block.id
});
// Create drop indicator
this.createDropIndicator();
// Set up drag event listeners
document.addEventListener('dragover', this.handleGlobalDragOver);
document.addEventListener('dragend', this.handleGlobalDragEnd);
}
// Update component state
this.component.draggedBlockId = this.draggedBlockId;
// Add dragging class after a small delay
setTimeout(() => {
if (this.draggedBlockElement) {
this.draggedBlockElement.classList.add('dragging');
}
if (this.component.editorContentRef) {
this.component.editorContentRef.classList.add('dragging');
}
}, 10);
}
/**
* Handles drag end
*/
handleDragEnd(): void {
// Clean up visual state
const allBlocks = this.component.editorContentRef.querySelectorAll('.block-wrapper');
allBlocks.forEach((block: HTMLElement) => {
block.classList.remove('dragging', 'move-up', 'move-down');
block.style.removeProperty('--drag-offset');
block.style.removeProperty('transform');
});
// Remove dragging class from editor
if (this.component.editorContentRef) {
this.component.editorContentRef.classList.remove('dragging');
}
// Reset drag state
this.draggedBlockId = null;
this.dragOverBlockId = null;
this.dragOverPosition = null;
this.draggedBlockElement = null;
this.draggedBlockHeight = 0;
this.draggedBlockContentHeight = 0;
this.draggedBlockMarginTop = 0;
this.initialBlockY = 0;
// Update component state
this.component.draggedBlockId = null;
this.component.dragOverBlockId = null;
this.component.dragOverPosition = null;
}
/**
* Handles drag over
*/
handleDragOver(e: DragEvent, block: IBlock): void {
e.preventDefault();
if (!e.dataTransfer || !this.draggedBlockId || this.draggedBlockId === block.id) return;
e.dataTransfer.dropEffect = 'move';
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
this.dragOverBlockId = block.id;
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
// Update component state
this.component.dragOverBlockId = this.dragOverBlockId;
this.component.dragOverPosition = this.dragOverPosition;
// The parent component already handles drag-over classes programmatically
}
/**
* Handles drag leave
*/
handleDragLeave(block: IBlock): void {
if (this.dragOverBlockId === block.id) {
this.dragOverBlockId = null;
this.dragOverPosition = null;
// Update component state
this.component.dragOverBlockId = null;
this.component.dragOverPosition = null;
// The parent component already handles removing drag-over classes programmatically
}
}
/**
* Handles drop
*/
handleDrop(e: DragEvent, targetBlock: IBlock): void {
e.preventDefault();
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
// The parent component already has a handleDrop method that handles this programmatically
// We'll delegate to that to ensure proper programmatic rendering
this.component.handleDrop(e, targetBlock);
}
/**
* Checks if a block is being dragged
*/
isDragging(blockId: string): boolean {
return this.draggedBlockId === blockId;
}
/**
* Checks if a block has drag over state
*/
isDragOver(blockId: string): boolean {
return this.dragOverBlockId === blockId;
}
/**
* Gets drag over CSS classes for a block
*/
getDragOverClasses(blockId: string): string {
if (!this.isDragOver(blockId)) return '';
return this.dragOverPosition === 'before' ? 'drag-over-before' : 'drag-over-after';
}
/**
* Creates the drop indicator element
*/
private createDropIndicator(): void {
this.dropIndicator = document.createElement('div');
this.dropIndicator.className = 'drop-indicator';
this.dropIndicator.style.display = 'none';
this.component.editorContentRef.appendChild(this.dropIndicator);
}
/**
* Handles global dragover to update dragged block position and move other blocks
*/
private handleGlobalDragOver = (e: DragEvent): void => {
e.preventDefault();
if (!this.draggedBlockElement) return;
// Calculate vertical offset from initial position
const deltaY = e.clientY - this.initialMouseY;
// Apply transform to move the dragged block vertically
this.draggedBlockElement.style.transform = `translateY(${deltaY}px)`;
// Throttle position updates to reduce stuttering
const now = Date.now();
if (now - this.lastUpdateTime < this.updateThrottle) {
return;
}
this.lastUpdateTime = now;
// Calculate which blocks should move
this.updateBlockPositions(e.clientY);
};
/**
* Updates block positions based on cursor position
*/
private updateBlockPositions(mouseY: number): void {
const blocks = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[];
const draggedIndex = blocks.findIndex(b => b.getAttribute('data-block-id') === this.draggedBlockId);
if (draggedIndex === -1) return;
// Reset all transforms first (except the dragged block)
blocks.forEach(block => {
if (block.getAttribute('data-block-id') !== this.draggedBlockId) {
block.classList.remove('move-up', 'move-down');
block.style.removeProperty('--drag-offset');
}
});
// Calculate where the dragged block should be inserted
let newIndex = blocks.length; // Default to end
for (let i = 0; i < blocks.length; i++) {
if (i === draggedIndex) continue;
const block = blocks[i];
const rect = block.getBoundingClientRect();
const blockTop = rect.top;
// Check if mouse is above this block's middle
if (mouseY < blockTop + (rect.height * 0.5)) {
newIndex = i;
break;
}
}
// Apply transforms to move blocks out of the way
for (let i = 0; i < blocks.length; i++) {
if (i === draggedIndex) continue;
const block = blocks[i];
// Determine if this block needs to move
if (draggedIndex < newIndex) {
// Dragging down: blocks between original and new position move up
if (i > draggedIndex && i < newIndex) {
block.classList.add('move-up');
block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`);
}
} else if (draggedIndex > newIndex) {
// Dragging up: blocks between new and original position move down
if (i >= newIndex && i < draggedIndex) {
block.classList.add('move-down');
block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`);
}
}
}
// Update drop indicator position
this.updateDropIndicator(blocks, newIndex, draggedIndex);
}
/**
* Updates the drop indicator position
*/
private updateDropIndicator(blocks: HTMLElement[], targetIndex: number, draggedIndex: number): void {
if (!this.dropIndicator || !this.draggedBlockElement) return;
this.dropIndicator.style.display = 'block';
const containerRect = this.component.editorContentRef.getBoundingClientRect();
let topPosition = 0;
// Build array of visual block positions (excluding dragged block)
const visualBlocks: { index: number, top: number, bottom: number }[] = [];
for (let i = 0; i < blocks.length; i++) {
if (i === draggedIndex) continue; // Skip the dragged block
const block = blocks[i];
const rect = block.getBoundingClientRect();
let top = rect.top - containerRect.top;
let bottom = rect.bottom - containerRect.top;
// Account for any transforms
const transform = window.getComputedStyle(block).transform;
if (transform && transform !== 'none') {
const matrix = new DOMMatrix(transform);
const yOffset = matrix.m42;
top += yOffset;
bottom += yOffset;
}
visualBlocks.push({ index: i, top, bottom });
}
// Sort by visual position
visualBlocks.sort((a, b) => a.top - b.top);
// Adjust targetIndex to account for excluded dragged block
let adjustedTargetIndex = targetIndex;
if (targetIndex > draggedIndex) {
adjustedTargetIndex--; // Reduce by 1 since dragged block is not in visualBlocks
}
// Calculate drop position
// Get the margin that will be applied based on the dragged block type
let blockMargin = 16; // default margin
if (this.draggedBlockElement) {
const draggedBlock = this.component.blocks.find(b => b.id === this.draggedBlockId);
if (draggedBlock) {
const blockType = draggedBlock.type;
if (blockType === 'heading-1' || blockType === 'heading-2' || blockType === 'heading-3') {
blockMargin = 24;
} else if (blockType === 'code' || blockType === 'quote') {
blockMargin = 20;
}
}
}
if (adjustedTargetIndex === 0) {
// Insert at the very top - no margin needed for first block
topPosition = 0;
} else if (adjustedTargetIndex >= visualBlocks.length) {
// Insert at the end
const lastBlock = visualBlocks[visualBlocks.length - 1];
if (lastBlock) {
topPosition = lastBlock.bottom;
// Add margin that will be applied to the dropped block
topPosition += blockMargin;
}
} else {
// Insert between blocks
const blockBefore = visualBlocks[adjustedTargetIndex - 1];
if (blockBefore) {
topPosition = blockBefore.bottom;
// Add margin that will be applied to the dropped block
topPosition += blockMargin;
}
}
// Set the indicator height to match the dragged block
this.dropIndicator.style.height = `${this.draggedBlockHeight}px`;
// Set position
this.dropIndicator.style.top = `${Math.max(0, topPosition)}px`;
console.log('Drop indicator update:', {
targetIndex,
adjustedTargetIndex,
draggedIndex,
topPosition,
height: this.draggedBlockHeight,
blockMargin,
visualBlocks: visualBlocks.map(b => ({ index: b.index, top: b.top, bottom: b.bottom }))
});
}
/**
* Handles global drag end
*/
private handleGlobalDragEnd = (): void => {
// Clean up event listeners
document.removeEventListener('dragover', this.handleGlobalDragOver);
document.removeEventListener('dragend', this.handleGlobalDragEnd);
// Remove drop indicator
if (this.dropIndicator) {
this.dropIndicator.remove();
this.dropIndicator = null;
}
// Trigger the actual drop if we have a dragged block
if (this.draggedBlockId) {
// Small delay to ensure transforms are applied
requestAnimationFrame(() => {
this.performDrop();
// Call the regular drag end handler after drop
this.handleDragEnd();
});
} else {
// Call the regular drag end handler
this.handleDragEnd();
}
};
/**
* Performs the actual drop operation
*/
private performDrop(): void {
if (!this.draggedBlockId) return;
// Get the visual order of blocks based on their positions
const blockElements = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[];
const draggedElement = blockElements.find(el => el.getAttribute('data-block-id') === this.draggedBlockId);
if (!draggedElement) return;
// Create an array of blocks with their visual positions
const visualOrder = blockElements.map(el => {
const id = el.getAttribute('data-block-id');
const rect = el.getBoundingClientRect();
const centerY = rect.top + rect.height / 2;
return { id, centerY, element: el };
});
// Sort by visual Y position
visualOrder.sort((a, b) => a.centerY - b.centerY);
// Get the new order of block IDs
const newBlockIds = visualOrder.map(item => item.id).filter(id => id !== null);
// Find the original block data
const originalBlocks = [...this.component.blocks];
const draggedBlock = originalBlocks.find(b => b.id === this.draggedBlockId);
if (!draggedBlock) return;
// Check if order actually changed
const oldOrder = originalBlocks.map(b => b.id);
const orderChanged = !newBlockIds.every((id, index) => id === oldOrder[index]);
if (!orderChanged) {
return;
}
// Reorder blocks based on visual positions
const newBlocks = newBlockIds.map(id => originalBlocks.find(b => b.id === id)!).filter(Boolean);
// Update blocks
this.component.blocks = newBlocks;
// Re-render blocks programmatically
this.component.renderBlocksProgrammatically();
// Update value
this.component.updateValue();
// Focus the moved block after a delay
setTimeout(() => {
if (draggedBlock.type !== 'divider') {
this.component.blockOperations.focusBlock(draggedBlock.id);
}
}, 100);
}
}

View File

@@ -0,0 +1,369 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import { WysiwygSelection } from './wysiwyg.selection.js';
export interface IFormatButton {
command: string;
icon: string;
label: string;
shortcut?: string;
action?: () => void;
}
/**
* Handles text formatting with smart toggle behavior:
* - If selection contains ANY instance of a format, removes ALL instances
* - If selection has no formatting, applies the format
* - Works correctly with Shadow DOM using range-based operations
*/
export class WysiwygFormatting {
static readonly formatButtons: IFormatButton[] = [
{ command: 'bold', icon: 'B', label: 'Bold', shortcut: '⌘B' },
{ command: 'italic', icon: 'I', label: 'Italic', shortcut: '⌘I' },
{ command: 'underline', icon: 'U', label: 'Underline', shortcut: '⌘U' },
{ command: 'strikeThrough', icon: 'S̶', label: 'Strikethrough' },
{ command: 'code', icon: '{ }', label: 'Inline Code' },
{ command: 'link', icon: '🔗', label: 'Link', shortcut: '⌘K' },
];
static renderFormattingMenu(
position: { x: number; y: number },
onFormat: (command: string) => void
): TemplateResult {
return html`
<div
class="formatting-menu"
style="top: ${position.y}px; left: ${position.x}px;"
@mousedown="${(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}"
@click="${(e: MouseEvent) => e.stopPropagation()}"
>
${this.formatButtons.map(button => html`
<button
class="format-button ${button.command}"
@click="${() => onFormat(button.command)}"
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
>
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
</button>
`)}
</div>
`;
}
static applyFormat(command: string, value?: string, range?: Range, shadowRoots?: ShadowRoot[]): boolean {
// If range is provided, use it directly (Shadow DOM case)
// Otherwise fall back to window.getSelection()
let workingRange: Range;
if (range) {
workingRange = range;
} else {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
workingRange = selection.getRangeAt(0);
}
// Apply format based on command
switch (command) {
case 'bold':
this.wrapSelection(workingRange, 'strong');
break;
case 'italic':
this.wrapSelection(workingRange, 'em');
break;
case 'underline':
this.wrapSelection(workingRange, 'u');
break;
case 'strikeThrough':
this.wrapSelection(workingRange, 's');
break;
case 'code':
this.wrapSelection(workingRange, 'code');
break;
case 'link':
// Don't use prompt - return false to indicate we need async input
if (!value) {
return false;
}
this.wrapSelectionWithLink(workingRange, value);
break;
}
// If we have shadow roots, use our Shadow DOM selection utility
if (shadowRoots && shadowRoots.length > 0) {
WysiwygSelection.setSelectionFromRange(workingRange);
} else {
// Regular selection restoration
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(workingRange);
}
}
return true;
}
private static wrapSelection(range: Range, tagName: string): void {
const selection = window.getSelection();
if (!selection) return;
// Check if ANY part of the selection contains this formatting
const hasFormatting = this.selectionContainsTag(range, tagName);
if (hasFormatting) {
// Remove all instances of this tag from the selection
this.removeTagFromSelection(range, tagName);
} else {
// Wrap selection with the tag
const wrapper = document.createElement(tagName);
try {
// Extract and wrap contents
const contents = range.extractContents();
wrapper.appendChild(contents);
range.insertNode(wrapper);
// Select the wrapped content
range.selectNodeContents(wrapper);
selection.removeAllRanges();
selection.addRange(range);
} catch (e) {
console.error('Failed to wrap selection:', e);
}
}
}
/**
* Check if the selection contains or is within any instances of a tag
*/
private static selectionContainsTag(range: Range, tagName: string): boolean {
// First check: Are we inside a tag? (even if selection doesn't include the tag)
let node: Node | null = range.startContainer;
while (node && node !== range.commonAncestorContainer.ownerDocument) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (element.tagName.toLowerCase() === tagName) {
return true;
}
}
node = node.parentNode;
}
// Also check the end container
node = range.endContainer;
while (node && node !== range.commonAncestorContainer.ownerDocument) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (element.tagName.toLowerCase() === tagName) {
return true;
}
}
node = node.parentNode;
}
// Second check: Does the selection contain any complete tags?
const tempDiv = document.createElement('div');
const contents = range.cloneContents();
tempDiv.appendChild(contents);
const tags = tempDiv.getElementsByTagName(tagName);
return tags.length > 0;
}
/**
* Remove all instances of a tag from the selection
*/
private static removeTagFromSelection(range: Range, tagName: string): void {
const selection = window.getSelection();
if (!selection) return;
// Special handling: Check if we need to expand the selection to include parent tags
let expandedRange = range.cloneRange();
// Check if start is inside a tag
let startNode: Node | null = range.startContainer;
let startTag: Element | null = null;
while (startNode && startNode !== range.commonAncestorContainer.ownerDocument) {
if (startNode.nodeType === Node.ELEMENT_NODE && (startNode as Element).tagName.toLowerCase() === tagName) {
startTag = startNode as Element;
break;
}
startNode = startNode.parentNode;
}
// Check if end is inside a tag
let endNode: Node | null = range.endContainer;
let endTag: Element | null = null;
while (endNode && endNode !== range.commonAncestorContainer.ownerDocument) {
if (endNode.nodeType === Node.ELEMENT_NODE && (endNode as Element).tagName.toLowerCase() === tagName) {
endTag = endNode as Element;
break;
}
endNode = endNode.parentNode;
}
// Expand range to include the tags if needed
if (startTag) {
expandedRange.setStartBefore(startTag);
}
if (endTag) {
expandedRange.setEndAfter(endTag);
}
// Extract the contents using the expanded range
const fragment = expandedRange.extractContents();
// Process the fragment to remove tags
const processedFragment = this.removeTagsFromFragment(fragment, tagName);
// Insert the processed content back
expandedRange.insertNode(processedFragment);
// Restore selection to match the original selection intent
// Find the text nodes that correspond to the original selection
const textNodes: Node[] = [];
const walker = document.createTreeWalker(
processedFragment,
NodeFilter.SHOW_TEXT,
null
);
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
if (textNodes.length > 0) {
const newRange = document.createRange();
newRange.setStart(textNodes[0], 0);
newRange.setEnd(textNodes[textNodes.length - 1], textNodes[textNodes.length - 1].textContent?.length || 0);
selection.removeAllRanges();
selection.addRange(newRange);
}
}
/**
* Remove all instances of a tag from a document fragment
*/
private static removeTagsFromFragment(fragment: DocumentFragment, tagName: string): DocumentFragment {
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
// Find all instances of the tag
const tags = tempDiv.getElementsByTagName(tagName);
// Convert to array to avoid live collection issues
const tagArray = Array.from(tags);
// Unwrap each tag
tagArray.forEach(tag => {
const parent = tag.parentNode;
if (parent) {
// Move all children out of the tag
while (tag.firstChild) {
parent.insertBefore(tag.firstChild, tag);
}
// Remove the empty tag
parent.removeChild(tag);
}
});
// Create a new fragment from the processed content
const newFragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
newFragment.appendChild(tempDiv.firstChild);
}
return newFragment;
}
private static wrapSelectionWithLink(range: Range, url: string): void {
const selection = window.getSelection();
if (!selection) return;
// First remove any existing links in the selection
if (this.selectionContainsTag(range, 'a')) {
this.removeTagFromSelection(range, 'a');
// Re-get the range after modification
if (selection.rangeCount > 0) {
range = selection.getRangeAt(0);
}
}
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
try {
const contents = range.extractContents();
link.appendChild(contents);
range.insertNode(link);
// Select the link
range.selectNodeContents(link);
selection.removeAllRanges();
selection.addRange(range);
} catch (e) {
console.error('Failed to create link:', e);
}
}
static getSelectionCoordinates(...shadowRoots: ShadowRoot[]): { x: number, y: number } | null {
// Get selection info using the new utility that handles Shadow DOM
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getSelectionCoordinates - selectionInfo:', selectionInfo);
if (!selectionInfo) {
console.log('No selection info available');
return null;
}
// Create a range from the selection info to get bounding rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
console.log('Range rect:', rect);
if (rect.width === 0 && rect.height === 0) {
console.log('Rect width and height are 0, trying different approach');
// Sometimes the rect is collapsed, let's try getting the caret position
if ('caretPositionFromPoint' in document) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const tempSpan = document.createElement('span');
tempSpan.textContent = '\u200B'; // Zero-width space
range.insertNode(tempSpan);
const spanRect = tempSpan.getBoundingClientRect();
tempSpan.remove();
if (spanRect.width > 0 || spanRect.height > 0) {
const coords = {
x: spanRect.left,
y: Math.max(45, spanRect.top - 45)
};
console.log('Used span trick for coords:', coords);
return coords;
}
}
}
return null;
}
const coords = {
x: rect.left + (rect.width / 2),
y: Math.max(45, rect.top - 45) // Position above selection, but ensure it's not negative
};
console.log('Returning coords:', coords);
return coords;
}
}

View File

@@ -0,0 +1,167 @@
import { type IBlock } from './wysiwyg.types.js';
export interface IHistoryState {
blocks: IBlock[];
selectedBlockId: string | null;
cursorPosition?: {
blockId: string;
offset: number;
};
timestamp: number;
}
export class WysiwygHistory {
private history: IHistoryState[] = [];
private currentIndex: number = -1;
private maxHistorySize: number = 50;
private lastSaveTime: number = 0;
private saveDebounceMs: number = 500; // Debounce saves to avoid too many snapshots
constructor() {
// Initialize with empty state
this.history = [];
this.currentIndex = -1;
}
/**
* Save current state to history
*/
saveState(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
const now = Date.now();
// Debounce rapid changes (like typing)
if (now - this.lastSaveTime < this.saveDebounceMs && this.currentIndex >= 0) {
// Update the current state instead of creating a new one
this.history[this.currentIndex] = {
blocks: this.cloneBlocks(blocks),
selectedBlockId,
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
timestamp: now
};
return;
}
// Remove any states after current index (when we save after undoing)
if (this.currentIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.currentIndex + 1);
}
// Add new state
const newState: IHistoryState = {
blocks: this.cloneBlocks(blocks),
selectedBlockId,
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
timestamp: now
};
this.history.push(newState);
this.currentIndex++;
// Limit history size
if (this.history.length > this.maxHistorySize) {
this.history.shift();
this.currentIndex--;
}
this.lastSaveTime = now;
}
/**
* Force save a checkpoint (useful for operations like block deletion)
*/
saveCheckpoint(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
this.lastSaveTime = 0; // Reset debounce
this.saveState(blocks, selectedBlockId, cursorPosition);
}
/**
* Undo to previous state
*/
undo(): IHistoryState | null {
if (!this.canUndo()) {
return null;
}
this.currentIndex--;
return this.cloneState(this.history[this.currentIndex]);
}
/**
* Redo to next state
*/
redo(): IHistoryState | null {
if (!this.canRedo()) {
return null;
}
this.currentIndex++;
return this.cloneState(this.history[this.currentIndex]);
}
/**
* Check if undo is available
*/
canUndo(): boolean {
return this.currentIndex > 0;
}
/**
* Check if redo is available
*/
canRedo(): boolean {
return this.currentIndex < this.history.length - 1;
}
/**
* Get current state
*/
getCurrentState(): IHistoryState | null {
if (this.currentIndex >= 0 && this.currentIndex < this.history.length) {
return this.cloneState(this.history[this.currentIndex]);
}
return null;
}
/**
* Clear history
*/
clear(): void {
this.history = [];
this.currentIndex = -1;
this.lastSaveTime = 0;
}
/**
* Deep clone blocks
*/
private cloneBlocks(blocks: IBlock[]): IBlock[] {
return blocks.map(block => ({
...block,
metadata: block.metadata ? { ...block.metadata } : undefined
}));
}
/**
* Clone a history state
*/
private cloneState(state: IHistoryState): IHistoryState {
return {
blocks: this.cloneBlocks(state.blocks),
selectedBlockId: state.selectedBlockId,
cursorPosition: state.cursorPosition ? { ...state.cursorPosition } : undefined,
timestamp: state.timestamp
};
}
/**
* Get history info for debugging
*/
getHistoryInfo(): { size: number; currentIndex: number; canUndo: boolean; canRedo: boolean } {
return {
size: this.history.length,
currentIndex: this.currentIndex,
canUndo: this.canUndo(),
canRedo: this.canRedo()
};
}
}

View File

@@ -0,0 +1,302 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
import { WysiwygModalManager } from './wysiwyg.modalmanager.js';
export class WysiwygInputHandler {
private component: IWysiwygComponent;
private saveTimeout: any = null;
constructor(component: IWysiwygComponent) {
this.component = component;
}
/**
* Handles input events for blocks
*/
handleBlockInput(e: InputEvent, block: IBlock): void {
if (this.component.isComposing) return;
const target = e.target as HTMLDivElement;
const textContent = target.textContent || '';
// Check for block type transformations BEFORE updating content
const detectedType = this.detectBlockTypeIntent(textContent);
if (detectedType && detectedType.type !== block.type) {
e.preventDefault();
this.handleBlockTransformation(block, detectedType, target);
return;
}
// Handle slash commands
this.handleSlashCommand(textContent, target);
// Don't update block content immediately - let the block handle its own content
// This prevents re-renders during typing
// Schedule auto-save (which will sync content later)
this.scheduleAutoSave();
}
/**
* Updates block content based on its type
*/
private updateBlockContent(block: IBlock, target: HTMLDivElement): void {
// Get the block component for proper content extraction
const wrapperElement = target.closest('.block-wrapper');
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
// Use the block component's getContent method for consistency
const newContent = blockComponent.getContent();
// Only update if content actually changed to avoid unnecessary updates
if (block.content !== newContent) {
block.content = newContent;
}
// Update list metadata if needed
if (block.type === 'list') {
const listElement = target.querySelector('ol, ul');
if (listElement) {
block.metadata = {
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
};
}
}
} else {
// Fallback if block component not found
if (block.type === 'list') {
const listItems = target.querySelectorAll('li');
// Use innerHTML to preserve formatting
block.content = Array.from(listItems).map(li => li.innerHTML || '').join('\n');
const listElement = target.querySelector('ol, ul');
if (listElement) {
block.metadata = {
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
};
}
} else if (block.type === 'code') {
block.content = target.textContent || '';
} else {
block.content = target.innerHTML || '';
}
}
}
/**
* Detects if the user is trying to create a specific block type
*/
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
// Check heading patterns
const headingResult = WysiwygShortcuts.checkHeadingShortcut(content);
if (headingResult) {
return headingResult;
}
// Check list patterns
const listResult = WysiwygShortcuts.checkListShortcut(content);
if (listResult) {
return listResult;
}
// Check quote pattern
if (WysiwygShortcuts.checkQuoteShortcut(content)) {
return { type: 'quote' };
}
// Check code pattern
if (WysiwygShortcuts.checkCodeShortcut(content)) {
return { type: 'code' };
}
// Check divider pattern
if (WysiwygShortcuts.checkDividerShortcut(content)) {
return { type: 'divider' };
}
return null;
}
/**
* Handles block type transformation
*/
private async handleBlockTransformation(
block: IBlock,
detectedType: { type: IBlock['type'], listType?: 'bullet' | 'ordered' },
target: HTMLDivElement
): Promise<void> {
const blockOps = this.component.blockOperations;
if (detectedType.type === 'list') {
block.type = 'list';
block.content = '';
block.metadata = { listType: detectedType.listType };
const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul';
target.innerHTML = `<${listTag}><li></li></${listTag}>`;
this.component.updateValue();
// Update the block element programmatically
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
setTimeout(() => {
WysiwygBlocks.focusListItem(target);
}, 0);
} else if (detectedType.type === 'divider') {
block.type = 'divider';
block.content = ' ';
// Update the block element programmatically
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
const newBlock = blockOps.createBlock();
blockOps.insertBlockAfter(block, newBlock);
this.component.updateValue();
} else if (detectedType.type === 'code') {
const language = await WysiwygModalManager.showLanguageSelectionModal();
if (language) {
block.type = 'code';
block.content = '';
block.metadata = { language };
target.textContent = '';
this.component.updateValue();
// Update the block element programmatically
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
// Focus the code block
setTimeout(async () => {
await blockOps.focusBlock(block.id, 'start');
}, 50);
}
} else {
block.type = detectedType.type;
block.content = '';
target.textContent = '';
this.component.updateValue();
// Update the block element programmatically
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
// Focus the transformed block
setTimeout(async () => {
await blockOps.focusBlock(block.id, 'start');
}, 50);
}
}
/**
* Handles slash command detection and menu display
*/
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
const slashMenu = this.component.slashMenu;
const isSlashMenuVisible = slashMenu && slashMenu.visible;
if (textContent === '/' || (textContent.startsWith('/') && isSlashMenuVisible)) {
if (!isSlashMenuVisible && textContent === '/') {
// Get position for menu based on cursor location
const rect = this.getCaretCoordinates(target);
// Show the slash menu at the cursor position
slashMenu.show(
{ x: rect.left, y: rect.bottom + 4 },
(type: string) => {
this.component.insertBlock(type);
}
);
// Ensure the block maintains focus
requestAnimationFrame(() => {
if (document.activeElement !== target) {
target.focus();
}
});
}
// Update filter
if (slashMenu) {
slashMenu.updateFilter(textContent.slice(1));
}
} else if (!textContent.startsWith('/')) {
this.component.closeSlashMenu();
}
}
/**
* Gets the coordinates of the caret/cursor
*/
private getCaretCoordinates(element: HTMLElement): DOMRect {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) {
return rect;
}
}
// Fallback to element position
return element.getBoundingClientRect();
}
/**
* Schedules auto-save after a delay
*/
private scheduleAutoSave(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
// Don't auto-save if slash menu is open
if (this.component.slashMenu && this.component.slashMenu.visible) {
return;
}
this.saveTimeout = setTimeout(() => {
// Sync all block content from DOM before saving
this.syncAllBlockContent();
// Only update value, don't trigger any re-renders
this.component.updateValue();
// Don't call requestUpdate() as it's not needed
}, 2000); // Increased delay to reduce interference with typing
}
/**
* Syncs content from all block DOMs to the data model
*/
private syncAllBlockContent(): void {
this.component.blocks.forEach((block: IBlock) => {
const wrapperElement = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent && blockComponent.getContent) {
const newContent = blockComponent.getContent();
// Only update if content actually changed
if (block.content !== newContent) {
block.content = newContent;
}
}
});
}
/**
* Cleans up resources
*/
destroy(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
}
}

View File

@@ -0,0 +1,89 @@
import { type TemplateResult } from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { DeesSlashMenu } from './dees-slash-menu.js';
import { DeesFormattingMenu } from './dees-formatting-menu.js';
/**
* Interface for the main wysiwyg component
*/
export interface IWysiwygComponent {
// State
blocks: IBlock[];
selectedBlockId: string | null;
shadowRoot: ShadowRoot | null;
editorContentRef: HTMLDivElement;
draggedBlockId: string | null;
dragOverBlockId: string | null;
dragOverPosition: 'before' | 'after' | null;
isComposing: boolean;
// Menus
slashMenu: DeesSlashMenu;
formattingMenu: DeesFormattingMenu;
// Methods
updateValue(): void;
requestUpdate(): void;
updateComplete: Promise<boolean>;
insertBlock(type: string): Promise<void>;
closeSlashMenu(clearSlash?: boolean): void;
applyFormat(command: string): Promise<void>;
handleSlashMenuKeyboard(e: KeyboardEvent): void;
createBlockElement(block: IBlock): HTMLElement;
updateBlockElement(blockId: string): void;
handleDrop(e: DragEvent, targetBlock: IBlock): void;
renderBlocksProgrammatically(): void;
saveToHistory(debounce?: boolean): void;
// Handlers
blockOperations: IBlockOperations;
}
/**
* Interface for block operations
*/
export interface IBlockOperations {
createBlock(type?: IBlock['type'], content?: string, metadata?: any): IBlock;
insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock?: boolean): Promise<void>;
removeBlock(blockId: string): void;
findBlock(blockId: string): IBlock | undefined;
getBlockIndex(blockId: string): number;
focusBlock(blockId: string, cursorPosition?: 'start' | 'end' | number): Promise<void>;
updateBlockContent(blockId: string, content: string): void;
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void;
moveBlock(blockId: string, targetIndex: number): void;
getPreviousBlock(blockId: string): IBlock | null;
getNextBlock(blockId: string): IBlock | null;
}
/**
* Interface for block component
*/
export interface IWysiwygBlockComponent {
block: IBlock;
isSelected: boolean;
blockElement: HTMLDivElement | null;
focus(): void;
focusWithCursor(position: 'start' | 'end' | number): void;
getContent(): string;
setContent(content: string): void;
setCursorToStart(): void;
setCursorToEnd(): void;
focusListItem(): void;
getSplitContent(splitPosition: number): { before: string; after: string };
}
/**
* Event handler interfaces
*/
export interface IBlockEventHandlers {
onInput: (e: InputEvent) => void;
onKeyDown: (e: KeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void;
onRequestUpdate?: () => void; // Request immediate re-render of the block
}

View File

@@ -0,0 +1,770 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
import { WysiwygSelection } from './wysiwyg.selection.js';
export class WysiwygKeyboardHandler {
private component: IWysiwygComponent;
constructor(component: IWysiwygComponent) {
this.component = component;
}
/**
* Handles keyboard events for blocks
*/
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
// Handle slash menu navigation
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
this.component.handleSlashMenuKeyboard(e);
return;
}
// Handle formatting shortcuts
if (this.handleFormattingShortcuts(e)) {
return;
}
// Handle special keys
switch (e.key) {
case 'Tab':
this.handleTab(e, block);
break;
case 'Enter':
await this.handleEnter(e, block);
break;
case 'Backspace':
await this.handleBackspace(e, block);
break;
case 'Delete':
await this.handleDelete(e, block);
break;
case 'ArrowUp':
await this.handleArrowUp(e, block);
break;
case 'ArrowDown':
await this.handleArrowDown(e, block);
break;
case 'ArrowLeft':
await this.handleArrowLeft(e, block);
break;
case 'ArrowRight':
await this.handleArrowRight(e, block);
break;
}
}
/**
* Checks if key is for slash menu navigation
*/
private isSlashMenuKey(key: string): boolean {
return ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(key);
}
/**
* Handles formatting keyboard shortcuts
*/
private handleFormattingShortcuts(e: KeyboardEvent): boolean {
if (!(e.metaKey || e.ctrlKey)) return false;
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
// Use Promise to ensure focus is maintained
Promise.resolve().then(() => this.component.applyFormat('bold'));
return true;
case 'i':
e.preventDefault();
Promise.resolve().then(() => this.component.applyFormat('italic'));
return true;
case 'u':
e.preventDefault();
Promise.resolve().then(() => this.component.applyFormat('underline'));
return true;
case 'k':
e.preventDefault();
Promise.resolve().then(() => this.component.applyFormat('link'));
return true;
}
return false;
}
/**
* Handles Tab key
*/
private handleTab(e: KeyboardEvent, block: IBlock): void {
if (block.type === 'code') {
// Allow tab in code blocks - handled by CodeBlockHandler
// Let it bubble to the block handler
return;
} else if (block.type === 'list') {
// Future: implement list indentation
e.preventDefault();
}
}
/**
* Handles Enter key
*/
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
const blockOps = this.component.blockOperations;
// For non-editable blocks, create a new paragraph after
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
return;
}
if (block.type === 'code') {
if (e.shiftKey) {
// Shift+Enter in code blocks creates a new block
e.preventDefault();
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
// Normal Enter in code blocks creates new line (let browser handle it)
return;
}
if (!e.shiftKey) {
if (block.type === 'list') {
await this.handleEnterInList(e, block);
} else {
// Split content at cursor position
e.preventDefault();
// Get the block component - need to search in the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent && blockComponent.getSplitContent) {
const splitContent = blockComponent.getSplitContent();
if (splitContent) {
// Update current block with content before cursor
blockComponent.setContent(splitContent.before);
block.content = splitContent.before;
// Create new block with content after cursor
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
// Insert the new block
await blockOps.insertBlockAfter(block, newBlock);
// Update the value after both blocks are set
this.component.updateValue();
} else {
// Fallback - just create empty block
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
} else {
// No block component or method, just create empty block
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
}
}
// Shift+Enter creates line break (let browser handle it)
}
/**
* Handles Enter key in list blocks
*/
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const currentLi = range.startContainer.parentElement?.closest('li');
if (currentLi && currentLi.textContent === '') {
// Empty list item - exit list mode
e.preventDefault();
const blockOps = this.component.blockOperations;
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
// Otherwise, let browser create new list item
}
}
/**
* Handles Backspace key
*/
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
const blockOps = this.component.blockOperations;
// Handle non-editable blocks
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
// If it's the only block, delete it and create a new paragraph
if (this.component.blocks.length === 1) {
// Save state for undo
this.component.saveToHistory(false);
// Remove the block
blockOps.removeBlock(block.id);
// Create a new paragraph block
const newBlock = blockOps.createBlock('paragraph', '');
this.component.blocks = [newBlock];
// Re-render blocks
this.component.renderBlocksProgrammatically();
// Focus the new block
await blockOps.focusBlock(newBlock.id, 'start');
// Update value
this.component.updateValue();
return;
}
// Save state for undo
this.component.saveToHistory(false);
// Find the previous block to focus
const prevBlock = blockOps.getPreviousBlock(block.id);
const nextBlock = blockOps.getNextBlock(block.id);
// Remove the block
blockOps.removeBlock(block.id);
// Focus the appropriate block
if (prevBlock && prevBlock.type !== 'divider' && prevBlock.type !== 'image') {
await blockOps.focusBlock(prevBlock.id, 'end');
} else if (nextBlock && nextBlock.type !== 'divider' && nextBlock.type !== 'image') {
await blockOps.focusBlock(nextBlock.id, 'start');
} else if (prevBlock) {
// If previous block is also non-editable, just select it
await blockOps.focusBlock(prevBlock.id);
} else if (nextBlock) {
// If next block is also non-editable, just select it
await blockOps.focusBlock(nextBlock.id);
}
return;
}
// Get the block component to check cursor position
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get cursor position
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
const actualContent = blockComponent.getContent ? blockComponent.getContent() : target.textContent;
// Check if cursor is at the beginning of the block
if (cursorPos === 0) {
e.preventDefault();
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
// If previous block is non-editable, select it first
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(prevBlock.type)) {
await blockOps.focusBlock(prevBlock.id);
return;
}
// Save checkpoint for undo
this.component.saveToHistory(false);
// Special handling for different block types
if (prevBlock.type === 'code' && block.type !== 'code') {
// Can't merge non-code into code block, just remove empty block
if (block.content === '') {
blockOps.removeBlock(block.id);
await blockOps.focusBlock(prevBlock.id, 'end');
}
return;
}
if (block.type === 'code' && prevBlock.type !== 'code') {
// Can't merge code into non-code block
const actualContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
if (actualContent === '' || actualContent.trim() === '') {
blockOps.removeBlock(block.id);
await blockOps.focusBlock(prevBlock.id, 'end');
}
return;
}
// Get the content of both blocks
const prevBlockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${prevBlock.id}"]`);
const prevBlockComponent = prevBlockWrapper?.querySelector('dees-wysiwyg-block') as any;
const prevContent = prevBlockComponent?.getContent() || prevBlock.content || '';
const currentContent = blockComponent.getContent() || block.content || '';
// Merge content
let mergedContent = '';
if (prevBlock.type === 'code' && block.type === 'code') {
// For code blocks, join with newline
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
} else if (prevBlock.type === 'list' && block.type === 'list') {
// For lists, combine the list items
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
} else {
// For other blocks, join with space if both have content
mergedContent = prevContent + (prevContent && currentContent ? ' ' : '') + currentContent;
}
// Store cursor position (where the merge point is)
const mergePoint = prevContent.length;
// Update previous block with merged content
blockOps.updateBlockContent(prevBlock.id, mergedContent);
if (prevBlockComponent) {
prevBlockComponent.setContent(mergedContent);
}
// Remove current block
blockOps.removeBlock(block.id);
// Focus previous block at merge point
await blockOps.focusBlock(prevBlock.id, mergePoint);
}
} else if (this.component.blocks.length > 1) {
// Check if block is actually empty by getting current content from DOM
const currentContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
if (currentContent === '' || currentContent.trim() === '') {
// Empty block - just remove it
e.preventDefault();
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
blockOps.removeBlock(block.id);
if (prevBlock.type !== 'divider') {
await blockOps.focusBlock(prevBlock.id, 'end');
}
}
}
}
// Otherwise, let browser handle normal backspace
}
/**
* Handles Delete key
*/
private async handleDelete(e: KeyboardEvent, block: IBlock): Promise<void> {
const blockOps = this.component.blockOperations;
// Handle non-editable blocks - same as backspace
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
// If it's the only block, delete it and create a new paragraph
if (this.component.blocks.length === 1) {
// Save state for undo
this.component.saveToHistory(false);
// Remove the block
blockOps.removeBlock(block.id);
// Create a new paragraph block
const newBlock = blockOps.createBlock('paragraph', '');
this.component.blocks = [newBlock];
// Re-render blocks
this.component.renderBlocksProgrammatically();
// Focus the new block
await blockOps.focusBlock(newBlock.id, 'start');
// Update value
this.component.updateValue();
return;
}
// Save state for undo
this.component.saveToHistory(false);
// Find the previous block to focus
const prevBlock = blockOps.getPreviousBlock(block.id);
const nextBlock = blockOps.getNextBlock(block.id);
// Remove the block
blockOps.removeBlock(block.id);
// Focus the appropriate block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nextBlock && !nonEditableTypes.includes(nextBlock.type)) {
await blockOps.focusBlock(nextBlock.id, 'start');
} else if (prevBlock && !nonEditableTypes.includes(prevBlock.type)) {
await blockOps.focusBlock(prevBlock.id, 'end');
} else if (nextBlock) {
// If next block is also non-editable, just select it
await blockOps.focusBlock(nextBlock.id);
} else if (prevBlock) {
// If previous block is also non-editable, just select it
await blockOps.focusBlock(prevBlock.id);
}
return;
}
// For editable blocks, check if we're at the end and next block is non-editable
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get cursor position
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
const textLength = target.textContent?.length || 0;
// Check if cursor is at the end of the block
if (cursorPos === textLength) {
const nextBlock = blockOps.getNextBlock(block.id);
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nextBlock && nonEditableTypes.includes(nextBlock.type)) {
e.preventDefault();
await blockOps.focusBlock(nextBlock.id);
return;
}
}
// Otherwise, let browser handle normal delete
}
/**
* Handles ArrowUp key - navigate to previous block if at beginning or first line
*/
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
// For non-editable blocks, always navigate to previous block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
}
return;
}
// Get the block component from the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element - code blocks now use .code-editor
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get selection info with proper shadow DOM support
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo || !selectionInfo.collapsed) return;
// Check if we're on the first line
if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
}
}
// Otherwise, let browser handle normal navigation
}
/**
* Handles ArrowDown key - navigate to next block if at end or last line
*/
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
// For non-editable blocks, always navigate to next block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
}
return;
}
// Get the block component from the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element - code blocks now use .code-editor
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get selection info with proper shadow DOM support
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo || !selectionInfo.collapsed) return;
// Check if we're on the last line
if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
}
}
// Otherwise, let browser handle normal navigation
}
/**
* 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;
}
/**
* Handles ArrowLeft key - navigate to previous block if at beginning
*/
private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> {
// For non-editable blocks, navigate to previous block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
}
return;
}
// Get the block component from the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element - code blocks now use .code-editor
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get selection info with proper shadow DOM support
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo || !selectionInfo.collapsed) return;
// Check if cursor is at the beginning of the block
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
if (cursorPos === 0) {
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
e.preventDefault();
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
const position = nonEditableTypes.includes(prevBlock.type) ? undefined : 'end';
await blockOps.focusBlock(prevBlock.id, position);
}
}
// Otherwise, let the browser handle normal left arrow navigation
}
/**
* Handles ArrowRight key - navigate to next block if at end
*/
private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> {
// For non-editable blocks, navigate to next block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
}
return;
}
// Get the block component from the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element - code blocks now use .code-editor
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get selection info with proper shadow DOM support
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo || !selectionInfo.collapsed) return;
// Check if cursor is at the end of the block
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
const textLength = target.textContent?.length || 0;
if (cursorPos === textLength) {
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock) {
e.preventDefault();
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
}
}
// Otherwise, let the browser handle normal right arrow navigation
}
/**
* Handles slash menu keyboard navigation
* Note: This is now handled by the component directly
*/
/**
* Check if cursor is on the first line of a block
*/
private isOnFirstLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
try {
// Create a range from the selection info
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Get the container element
let container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentElement;
}
// Get the top position of the container
const containerRect = (container as Element).getBoundingClientRect();
// Check if we're near the top (within 5px tolerance for line height variations)
const isNearTop = rect.top - containerRect.top < 5;
// For single-line content, also check if we're at the beginning
if (container.textContent && !container.textContent.includes('\n')) {
const cursorPos = WysiwygSelection.getCursorPositionInElement(container as Element, ...shadowRoots);
return cursorPos === 0;
}
return isNearTop;
} catch (e) {
console.warn('Error checking first line:', e);
// Fallback to position-based check
const cursorPos = selectionInfo.startOffset;
return cursorPos === 0;
}
}
/**
* Check if cursor is on the last line of a block
*/
private isOnLastLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
try {
// Create a range from the selection info
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Get the container element
let container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentElement;
}
// Get the bottom position of the container
const containerRect = (container as Element).getBoundingClientRect();
// Check if we're near the bottom (within 5px tolerance for line height variations)
const isNearBottom = containerRect.bottom - rect.bottom < 5;
// For single-line content, also check if we're at the end
if (container.textContent && !container.textContent.includes('\n')) {
const textLength = target.textContent?.length || 0;
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
return cursorPos === textLength;
}
return isNearBottom;
} catch (e) {
console.warn('Error checking last line:', e);
// Fallback to position-based check
const textLength = target.textContent?.length || 0;
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
return cursorPos === textLength;
}
}
}

View File

@@ -0,0 +1,295 @@
import { html, type TemplateResult, cssManager } from '@design.estate/dees-element';
import { DeesModal } from '../dees-modal.js';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
import { PROGRAMMING_LANGUAGES } from './wysiwyg.constants.js';
export class WysiwygModalManager {
/**
* Shows language selection modal for code blocks
*/
static async showLanguageSelectionModal(): Promise<string | null> {
return new Promise((resolve) => {
let selectedLanguage: string | null = null;
DeesModal.createAndShow({
heading: 'Select Programming Language',
content: html`
<style>
.language-container {
padding: 16px;
max-height: 400px;
overflow-y: auto;
}
.language-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.language-button {
padding: 12px 8px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
cursor: pointer;
text-align: center;
font-size: 13px;
font-weight: 500;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.language-button:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
}
.language-button.selected {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
</style>
<div class="language-container">
<div class="language-grid">
${this.getLanguages().map(lang => html`
<div
class="language-button ${selectedLanguage === lang.toLowerCase() ? 'selected' : ''}"
@click="${() => {
selectedLanguage = lang.toLowerCase();
// Close modal by finding it in DOM
const modal = document.querySelector('dees-modal');
if (modal && typeof (modal as any).destroy === 'function') {
(modal as any).destroy();
}
resolve(selectedLanguage);
}}">
${lang}
</div>
`)}
</div>
</div>
`,
menuOptions: [
{
name: 'Cancel',
action: async (modal) => {
modal.destroy();
resolve(null);
}
}
]
});
});
}
/**
* Shows block settings modal
*/
static async showBlockSettingsModal(
block: IBlock,
onUpdate: (block: IBlock) => void
): Promise<void> {
const content = html`
<style>
.settings-container {
padding: 16px;
}
.settings-section {
margin-bottom: 24px;
}
.settings-section:last-child {
margin-bottom: 0;
}
.settings-label {
font-weight: 500;
margin-bottom: 8px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.block-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px;
}
.block-type-button {
padding: 12px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.block-type-button:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
}
.block-type-button.selected {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
.block-type-icon {
font-weight: 500;
font-size: 16px;
width: 20px;
text-align: center;
flex-shrink: 0;
opacity: 0.7;
}
</style>
<div class="settings-container">
${this.getBlockTypeSelector(block, onUpdate)}
${block.type === 'code' ? this.getCodeBlockSettings(block, onUpdate) : ''}
</div>
`;
DeesModal.createAndShow({
heading: 'Block Settings',
content,
menuOptions: [
{
name: 'Done',
action: async (modal) => {
modal.destroy();
}
}
]
});
}
/**
* Gets code block settings content
*/
private static getCodeBlockSettings(
block: IBlock,
onUpdate: (block: IBlock) => void
): TemplateResult {
const currentLanguage = block.metadata?.language || 'javascript';
return html`
<style>
.language-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 6px;
}
.language-button {
padding: 8px 4px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
cursor: pointer;
text-align: center;
transition: all 0.15s ease;
font-size: 12px;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.language-button:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
}
.language-button.selected {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
</style>
<div class="settings-section">
<div class="settings-label">Programming Language</div>
<div class="language-grid">
${this.getLanguages().map(lang => html`
<div
class="language-button ${currentLanguage === lang.toLowerCase() ? 'selected' : ''}"
@click="${() => {
if (!block.metadata) block.metadata = {};
block.metadata.language = lang.toLowerCase();
onUpdate(block);
// Close modal immediately
const modal = document.querySelector('dees-modal');
if (modal && typeof (modal as any).destroy === 'function') {
(modal as any).destroy();
}
}}"
data-lang="${lang}"
>${lang}</div>
`)}
</div>
</div>
`;
}
/**
* Gets available programming languages
*/
private static getLanguages(): string[] {
return [...PROGRAMMING_LANGUAGES];
}
/**
* Gets block type selector
*/
private static getBlockTypeSelector(
block: IBlock,
onUpdate: (block: IBlock) => void
): TemplateResult {
const blockTypes = WysiwygShortcuts.getSlashMenuItems().filter(item => item.type !== 'divider');
return html`
<div class="settings-section">
<div class="settings-label">Block Type</div>
<div class="block-type-grid">
${blockTypes.map(item => html`
<div
class="block-type-button ${block.type === item.type ? 'selected' : ''}"
@click="${async (e: MouseEvent) => {
const button = e.currentTarget as HTMLElement;
const oldType = block.type;
block.type = item.type as IBlock['type'];
// Reset metadata for type change
if (oldType === 'code' && block.type !== 'code') {
delete block.metadata?.language;
} else if (oldType === 'list' && block.type !== 'list') {
delete block.metadata?.listType;
} else if (block.type === 'list' && !block.metadata?.listType) {
block.metadata = { listType: 'bullet' };
} else if (block.type === 'code' && !block.metadata?.language) {
// Ask for language if changing to code block
const language = await this.showLanguageSelectionModal();
if (language) {
block.metadata = { language };
} else {
// User cancelled, revert
block.type = oldType;
return;
}
}
onUpdate(block);
// Close modal immediately
const modal = document.querySelector('dees-modal');
if (modal && typeof (modal as any).destroy === 'function') {
(modal as any).destroy();
}
}}"
>
<span class="block-type-icon">${item.icon}</span>
<span>${item.label}</span>
</div>
`)}
</div>
</div>
`;
}
}

View File

@@ -0,0 +1,283 @@
/**
* Utilities for handling selection across Shadow DOM boundaries
*/
export interface SelectionInfo {
startContainer: Node;
startOffset: number;
endContainer: Node;
endOffset: number;
collapsed: boolean;
}
// Type for the extended caretPositionFromPoint with Shadow DOM support
type CaretPositionFromPointExtended = (x: number, y: number, ...shadowRoots: ShadowRoot[]) => CaretPosition | null;
export class WysiwygSelection {
/**
* Gets selection info that works across Shadow DOM boundaries
* @param shadowRoots - Shadow roots to include in the selection search
*/
static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null {
const selection = window.getSelection();
console.log('WysiwygSelection.getSelectionInfo - selection:', selection, 'rangeCount:', selection?.rangeCount);
if (!selection) return null;
// Try using getComposedRanges if available (better Shadow DOM support)
if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
console.log('Using getComposedRanges with', shadowRoots.length, 'shadow roots');
try {
// Pass shadow roots in the correct format as per MDN
const ranges = selection.getComposedRanges({ shadowRoots });
console.log('getComposedRanges returned', ranges.length, 'ranges');
if (ranges.length > 0) {
const range = ranges[0];
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
collapsed: range.collapsed
};
}
} catch (error) {
console.warn('getComposedRanges failed, falling back to getRangeAt:', error);
}
} else {
console.log('getComposedRanges not available, using fallback');
}
// Fallback to traditional selection API
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
collapsed: range.collapsed
};
}
return null;
}
/**
* Checks if a selection is within a specific element (considering Shadow DOM)
*/
static isSelectionInElement(element: Element, shadowRoot?: ShadowRoot): boolean {
const selectionInfo = shadowRoot
? this.getSelectionInfo(shadowRoot)
: this.getSelectionInfo();
if (!selectionInfo) return false;
// Check if the selection's common ancestor is within the element
return element.contains(selectionInfo.startContainer) ||
element.contains(selectionInfo.endContainer);
}
/**
* Gets the selected text across Shadow DOM boundaries
*/
static getSelectedText(): string {
const selection = window.getSelection();
return selection ? selection.toString() : '';
}
/**
* Creates a range from selection info
*/
static createRangeFromInfo(info: SelectionInfo): Range {
const range = document.createRange();
range.setStart(info.startContainer, info.startOffset);
range.setEnd(info.endContainer, info.endOffset);
return range;
}
/**
* Sets selection from a range (works with Shadow DOM)
*/
static setSelectionFromRange(range: Range): void {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
/**
* Gets cursor position relative to a specific element
*/
static getCursorPositionInElement(element: Element, ...shadowRoots: ShadowRoot[]): number | null {
const selectionInfo = shadowRoots.length > 0
? this.getSelectionInfo(...shadowRoots)
: this.getSelectionInfo();
if (!selectionInfo || !selectionInfo.collapsed) return null;
// Create a range from start of element to cursor position
try {
const range = document.createRange();
range.selectNodeContents(element);
// Handle case where selection is in a text node that's a child of the element
// Use our Shadow DOM-aware contains method
const isContained = this.containsAcrossShadowDOM(element, selectionInfo.startContainer);
if (isContained) {
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
const position = range.toString().length;
return position;
} else {
// Selection might be in shadow DOM or different context
// Try to find the equivalent position in the element
const text = element.textContent || '';
const selectionText = selectionInfo.startContainer.textContent || '';
// If the selection is at the beginning or end, handle those cases
if (selectionInfo.startOffset === 0) {
return 0;
} else if (selectionInfo.startOffset === selectionText.length) {
return text.length;
}
// For other cases, try to match based on text content
console.warn('Selection container not within element, using text matching fallback');
return selectionInfo.startOffset;
}
} catch (error) {
console.warn('Failed to get cursor position:', error);
return null;
}
}
/**
* Gets cursor position from mouse coordinates with Shadow DOM support
*/
static getCursorPositionFromPoint(x: number, y: number, container: HTMLElement, ...shadowRoots: ShadowRoot[]): number | null {
// Try modern API with shadow root support
if ('caretPositionFromPoint' in document && document.caretPositionFromPoint) {
let caretPos: CaretPosition | null = null;
// Try with shadow roots first (newer API)
try {
caretPos = (document.caretPositionFromPoint as any)(x, y, ...shadowRoots);
} catch (e) {
// Fallback to standard API without shadow roots
caretPos = document.caretPositionFromPoint(x, y);
}
if (caretPos && container.contains(caretPos.offsetNode)) {
// Calculate total offset within the container
return this.getOffsetInElement(caretPos.offsetNode, caretPos.offset, container);
}
}
// Safari/WebKit fallback
if ('caretRangeFromPoint' in document) {
const range = (document as any).caretRangeFromPoint(x, y);
if (range && container.contains(range.startContainer)) {
return this.getOffsetInElement(range.startContainer, range.startOffset, container);
}
}
return null;
}
/**
* Helper to get the total character offset of a position within an element
*/
private static getOffsetInElement(node: Node, offset: number, container: HTMLElement): number {
let totalOffset = 0;
let found = false;
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null
);
let textNode: Node | null;
while (textNode = walker.nextNode()) {
if (textNode === node) {
totalOffset += offset;
found = true;
break;
} else {
totalOffset += textNode.textContent?.length || 0;
}
}
return found ? totalOffset : 0;
}
/**
* Sets cursor position in an element
*/
static setCursorPosition(element: Element, position: number): void {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null
);
let currentPosition = 0;
let targetNode: Text | null = null;
let targetOffset = 0;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const nodeLength = node.textContent?.length || 0;
if (currentPosition + nodeLength >= position) {
targetNode = node;
targetOffset = position - currentPosition;
break;
}
currentPosition += nodeLength;
}
if (targetNode) {
const range = document.createRange();
range.setStart(targetNode, targetOffset);
range.collapse(true);
this.setSelectionFromRange(range);
}
}
/**
* Check if a node is contained within an element across Shadow DOM boundaries
* This is needed because element.contains() doesn't work across Shadow DOM
*/
static containsAcrossShadowDOM(container: Node, node: Node): boolean {
if (!container || !node) return false;
// Start with the node and traverse up
let current: Node | null = node;
while (current) {
// Direct match
if (current === container) {
return true;
}
// If we're at a shadow root, check its host
if (current.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (current as any).host) {
const shadowRoot = current as ShadowRoot;
// Check if the container is within this shadow root
if (shadowRoot.contains(container)) {
return false; // Container is in a child shadow DOM
}
// Move to the host element
current = shadowRoot.host;
} else {
// Regular DOM traversal
current = current.parentNode;
}
}
return false;
}
}

View File

@@ -0,0 +1,74 @@
import { type IBlock, type IShortcutPattern, type ISlashMenuItem } from './wysiwyg.types.js';
export class WysiwygShortcuts {
static readonly HEADING_PATTERNS: IShortcutPattern[] = [
{ pattern: /^#[\s\u00A0]$/, type: 'heading-1' },
{ pattern: /^##[\s\u00A0]$/, type: 'heading-2' },
{ pattern: /^###[\s\u00A0]$/, type: 'heading-3' }
];
static readonly LIST_PATTERNS: IShortcutPattern[] = [
{ pattern: /^[*-][\s\u00A0]$/, type: 'bullet' },
{ pattern: /^(\d+)\.[\s\u00A0]$/, type: 'ordered' },
{ pattern: /^(\d+)\)[\s\u00A0]$/, type: 'ordered' }
];
static readonly QUOTE_PATTERN = /^>[\s\u00A0]$/;
static readonly CODE_PATTERN = /^```$/;
static readonly DIVIDER_PATTERNS = ['---', '***', '___'];
static checkHeadingShortcut(content: string): { type: IBlock['type'] } | null {
for (const { pattern, type } of this.HEADING_PATTERNS) {
if (pattern.test(content)) {
return { type: type as IBlock['type'] };
}
}
return null;
}
static checkListShortcut(content: string): { type: 'list', listType: 'bullet' | 'ordered' } | null {
for (const { pattern, type } of this.LIST_PATTERNS) {
if (pattern.test(content)) {
return { type: 'list', listType: type as 'bullet' | 'ordered' };
}
}
return null;
}
static checkQuoteShortcut(content: string): boolean {
return this.QUOTE_PATTERN.test(content);
}
static checkCodeShortcut(content: string): boolean {
return this.CODE_PATTERN.test(content);
}
static checkDividerShortcut(content: string): boolean {
return this.DIVIDER_PATTERNS.includes(content);
}
static getSlashMenuItems(): ISlashMenuItem[] {
return [
{ type: 'paragraph', label: 'Paragraph', icon: 'lucide:pilcrow' },
{ type: 'heading-1', label: 'Heading 1', icon: 'lucide:heading1' },
{ type: 'heading-2', label: 'Heading 2', icon: 'lucide:heading2' },
{ type: 'heading-3', label: 'Heading 3', icon: 'lucide:heading3' },
{ type: 'quote', label: 'Quote', icon: 'lucide:quote' },
{ type: 'code', label: 'Code Block', icon: 'lucide:fileCode' },
{ type: 'list', label: 'Bullet List', icon: 'lucide:list' },
{ type: 'image', label: 'Image', icon: 'lucide:image' },
{ type: 'divider', label: 'Divider', icon: 'lucide:minus' },
{ type: 'youtube', label: 'YouTube', icon: 'lucide:youtube' },
{ type: 'markdown', label: 'Markdown', icon: 'lucide:fileText' },
{ type: 'html', label: 'HTML', icon: 'lucide:code' },
{ type: 'attachment', label: 'File Attachment', icon: 'lucide:paperclip' },
];
}
static generateBlockId(): string {
return `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
}
// Re-export the type that is used in this module
export type { ISlashMenuItem } from './wysiwyg.types.js';

View File

@@ -0,0 +1,549 @@
import { css, cssManager } from '@design.estate/dees-element';
export const wysiwygStyles = css`
:host {
display: block;
position: relative;
}
.wysiwyg-container {
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 6px;
min-height: 200px;
padding: 24px;
position: relative;
transition: all 0.2s ease;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.wysiwyg-container:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.wysiwyg-container:focus-within {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#f4f4f5', '#18181b')}, 0 0 0 4px ${cssManager.bdTheme('rgba(59, 130, 246, 0.5)', 'rgba(59, 130, 246, 0.5)')};
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
}
/* Visual hint for text selection */
.editor-content:hover {
cursor: text;
}
.editor-content {
outline: none;
min-height: 160px;
margin: 0 -8px;
padding: 0 8px;
}
.block {
margin: 0;
padding: 4px 0;
position: relative;
transition: all 0.15s ease;
min-height: 1.6em;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
/* First and last blocks don't need extra spacing */
.block-wrapper:first-child .block {
margin-top: 0 !important;
}
.block-wrapper:last-child .block {
margin-bottom: 0;
}
.block.selected {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.05)', 'rgba(59, 130, 246, 0.05)')};
outline: 2px solid ${cssManager.bdTheme('rgba(59, 130, 246, 0.2)', 'rgba(59, 130, 246, 0.2)')};
outline-offset: -2px;
border-radius: 4px;
margin-left: -8px;
margin-right: -8px;
padding-left: 8px;
padding-right: 8px;
}
.block[contenteditable] {
outline: none;
}
.block.paragraph {
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
.block.paragraph:empty::before {
content: "Type '/' for commands...";
color: ${cssManager.bdTheme('#71717a', '#71717a')};
pointer-events: none;
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
.block.heading-1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.block.heading-1:empty::before {
content: "Heading 1";
color: ${cssManager.bdTheme('#71717a', '#71717a')};
pointer-events: none;
font-size: 32px;
line-height: 1.2;
font-weight: 700;
}
.block.heading-2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.block.heading-2:empty::before {
content: "Heading 2";
color: ${cssManager.bdTheme('#71717a', '#71717a')};
pointer-events: none;
font-size: 24px;
line-height: 1.3;
font-weight: 600;
}
.block.heading-3 {
font-size: 20px;
font-weight: 600;
line-height: 1.4;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.block.heading-3:empty::before {
content: "Heading 3";
color: ${cssManager.bdTheme('#71717a', '#71717a')};
pointer-events: none;
font-size: 20px;
line-height: 1.4;
font-weight: 600;
}
.block.quote {
border-left: 2px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
padding-left: 20px;
font-style: italic;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-left: 0;
margin-right: 0;
line-height: 1.6;
}
.block.quote:empty::before {
content: "Quote";
color: ${cssManager.bdTheme('#71717a', '#71717a')};
pointer-events: none;
font-size: 16px;
line-height: 1.6;
font-weight: 400;
font-style: italic;
}
.code-block-container {
position: relative;
margin: 20px 0;
}
.code-language {
position: absolute;
top: 0;
right: 0;
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
padding: 4px 12px;
font-size: 12px;
border-radius: 0 4px 0 4px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-transform: lowercase;
z-index: 1;
}
.block.code {
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 4px;
padding: 16px;
padding-top: 32px; /* Make room for language indicator */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
overflow-x: auto;
}
.block.code:empty::before {
content: "// Code block";
color: ${cssManager.bdTheme('#71717a', '#71717a')};
pointer-events: none;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
font-weight: 400;
}
.block.list {
padding-left: 0;
}
.block.list ul,
.block.list ol {
margin: 0;
padding: 0 0 0 24px;
list-style-position: outside;
}
.block.list ul {
list-style: disc;
}
.block.list ol {
list-style: decimal;
}
.block.list li {
margin-bottom: 8px;
line-height: 1.6;
}
.block.list li:last-child {
margin-bottom: 0;
}
.block.divider {
text-align: center;
padding: 20px 0;
cursor: default;
pointer-events: none;
}
.block.divider hr {
border: none;
border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
margin: 0;
}
.slash-menu {
position: absolute;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
padding: 4px;
z-index: 1000;
min-width: 220px;
max-height: 300px;
overflow-y: auto;
pointer-events: auto;
user-select: none;
}
.slash-menu-item {
padding: 8px 10px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 12px;
border-radius: 3px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
font-size: 14px;
}
.slash-menu-item:hover,
.slash-menu-item.selected {
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.slash-menu-item .icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
font-weight: 600;
}
.slash-menu-item:hover .icon,
.slash-menu-item.selected .icon {
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
}
.toolbar {
position: absolute;
top: -40px;
left: 0;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
padding: 4px;
display: none;
gap: 4px;
z-index: 1000;
}
.toolbar.visible {
display: flex;
}
.toolbar-button {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 3px;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.toolbar-button:hover {
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
}
/* Drag and Drop Styles */
.block-wrapper {
position: relative;
transition: transform 0.3s ease, opacity 0.2s ease;
}
/* Ensure proper spacing context for blocks */
.block-wrapper + .block-wrapper .block {
margin-top: 16px;
}
/* Override for headings following other blocks */
.block-wrapper + .block-wrapper .block.heading-1,
.block-wrapper + .block-wrapper .block.heading-2,
.block-wrapper + .block-wrapper .block.heading-3 {
margin-top: 24px;
}
/* Code and quote blocks need consistent spacing */
.block-wrapper + .block-wrapper .block.code,
.block-wrapper + .block-wrapper .block.quote {
margin-top: 20px;
}
.drag-handle {
position: absolute;
left: -28px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
cursor: grab;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#71717a', '#71717a')};
border-radius: 4px;
}
.drag-handle::before {
content: "⋮⋮";
font-size: 12px;
letter-spacing: -2px;
}
.block-wrapper:hover .drag-handle {
opacity: 1;
}
.drag-handle:hover {
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
}
.drag-handle:active {
cursor: grabbing;
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.block-wrapper.dragging {
opacity: 0.8;
pointer-events: none;
position: relative;
z-index: 2001;
transition: none !important;
}
/* Blocks that should move out of the way */
.block-wrapper.move-down {
transform: translateY(var(--drag-offset, 0px));
}
.block-wrapper.move-up {
transform: translateY(calc(-1 * var(--drag-offset, 0px)));
}
/* Drop indicator */
.drop-indicator {
position: absolute;
left: 0;
right: 0;
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.05)', 'rgba(59, 130, 246, 0.05)')};
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
border-radius: 4px;
transition: top 0.2s ease, height 0.2s ease;
pointer-events: none;
z-index: 1999;
box-sizing: border-box;
}
/* Remove old drag-over styles */
.block-wrapper.drag-over-before,
.block-wrapper.drag-over-after {
/* No longer needed, using drop indicator instead */
}
.editor-content.dragging * {
user-select: none;
}
/* Block Settings Button - Removed in favor of context menu */
/* Text Selection Styles */
.block ::selection {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.2)', 'rgba(59, 130, 246, 0.2)')};
color: inherit;
}
/* Formatting Menu */
.formatting-menu {
position: absolute;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
padding: 4px;
display: flex;
gap: 2px;
z-index: 1001;
animation: fadeInScale 0.15s ease-out;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.98) translateY(2px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.format-button {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 3px;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
font-weight: 600;
font-size: 14px;
position: relative;
}
.format-button:hover {
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
}
.format-button:active {
transform: scale(0.95);
}
.format-button.bold {
font-weight: 700;
}
.format-button.italic {
font-style: italic;
}
.format-button.underline {
text-decoration: underline;
}
.format-button .code-icon {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 12px;
}
/* Applied format styles in content */
.block strong,
.block b {
font-weight: 600;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.block em,
.block i {
font-style: italic;
}
.block u {
text-decoration: underline;
}
.block strike,
.block s {
text-decoration: line-through;
opacity: 0.7;
}
.block code {
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
padding: 2px 6px;
border-radius: 3px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 0.9em;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.block a {
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.15s ease;
}
.block a:hover {
border-bottom-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
}
`;

View File

@@ -0,0 +1,20 @@
export interface IBlock {
id: string;
type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider' | 'youtube' | 'markdown' | 'html' | 'attachment';
content: string;
metadata?: any;
}
export interface ISlashMenuItem {
type: string;
label: string;
icon: string;
action?: () => void;
}
export interface IShortcutPattern {
pattern: RegExp;
type: string;
}
export type OutputFormat = 'html' | 'markdown';