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:
294
ts_web/elements/dees-input-wysiwyg/blocks/MIGRATION-KNOWLEDGE.md
Normal file
294
ts_web/elements/dees-input-wysiwyg/blocks/MIGRATION-KNOWLEDGE.md
Normal 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.
|
||||
44
ts_web/elements/dees-input-wysiwyg/blocks/block.base.ts
Normal file
44
ts_web/elements/dees-input-wysiwyg/blocks/block.base.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
17
ts_web/elements/dees-input-wysiwyg/blocks/block.registry.ts
Normal file
17
ts_web/elements/dees-input-wysiwyg/blocks/block.registry.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
64
ts_web/elements/dees-input-wysiwyg/blocks/block.styles.ts
Normal file
64
ts_web/elements/dees-input-wysiwyg/blocks/block.styles.ts
Normal 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(' ');
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
519
ts_web/elements/dees-input-wysiwyg/blocks/content/html.block.ts
Normal file
519
ts_web/elements/dees-input-wysiwyg/blocks/content/html.block.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* HTMLBlockHandler - Handles raw HTML content with preview/edit toggle
|
||||
*
|
||||
* Features:
|
||||
* - Live HTML preview (sandboxed)
|
||||
* - Edit/preview mode toggle
|
||||
* - Syntax highlighting in edit mode
|
||||
* - HTML validation hints
|
||||
* - Auto-save on mode switch
|
||||
*/
|
||||
export class HtmlBlockHandler extends BaseBlockHandler {
|
||||
type = 'html';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const isEditMode = block.metadata?.isEditMode ?? true;
|
||||
const content = block.content || '';
|
||||
|
||||
return `
|
||||
<div class="html-block-container${isSelected ? ' selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
data-edit-mode="${isEditMode}">
|
||||
<div class="html-header">
|
||||
<div class="html-icon"></></div>
|
||||
<div class="html-title">HTML</div>
|
||||
<button class="html-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
|
||||
${isEditMode ? '👁️' : '✏️'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="html-content">
|
||||
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEditor(content: string): string {
|
||||
return `
|
||||
<textarea class="html-editor"
|
||||
placeholder="Enter HTML content..."
|
||||
spellcheck="false">${this.escapeHtml(content)}</textarea>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPreview(content: string): string {
|
||||
return `
|
||||
<div class="html-preview">
|
||||
${content || '<div class="preview-empty">No content to preview</div>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.html-block-container') as HTMLElement;
|
||||
const toggleBtn = element.querySelector('.html-toggle-mode') as HTMLButtonElement;
|
||||
|
||||
if (!container || !toggleBtn) {
|
||||
console.error('HtmlBlockHandler: Could not find required elements');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize metadata
|
||||
if (!block.metadata) block.metadata = {};
|
||||
if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true;
|
||||
|
||||
// Toggle mode button
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Save current content if in edit mode
|
||||
if (block.metadata.isEditMode) {
|
||||
const editor = container.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
block.content = editor.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle mode
|
||||
block.metadata.isEditMode = !block.metadata.isEditMode;
|
||||
|
||||
// Request UI update
|
||||
handlers.onRequestUpdate?.();
|
||||
});
|
||||
|
||||
// Setup based on mode
|
||||
if (block.metadata.isEditMode) {
|
||||
this.setupEditor(element, block, handlers);
|
||||
} else {
|
||||
this.setupPreview(element, block, handlers);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (!editor) return;
|
||||
|
||||
// Focus handling
|
||||
editor.addEventListener('focus', () => handlers.onFocus());
|
||||
editor.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Content changes
|
||||
editor.addEventListener('input', () => {
|
||||
block.content = editor.value;
|
||||
this.validateHtml(editor.value);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
editor.addEventListener('keydown', (e) => {
|
||||
// Tab handling for indentation
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const value = editor.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Unindent
|
||||
const beforeCursor = value.substring(0, start);
|
||||
const lastNewline = beforeCursor.lastIndexOf('\n');
|
||||
const lineStart = lastNewline + 1;
|
||||
const lineContent = value.substring(lineStart, start);
|
||||
|
||||
if (lineContent.startsWith(' ')) {
|
||||
editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start);
|
||||
editor.selectionStart = editor.selectionEnd = start - 2;
|
||||
}
|
||||
} else {
|
||||
// Indent
|
||||
editor.value = value.substring(0, start) + ' ' + value.substring(end);
|
||||
editor.selectionStart = editor.selectionEnd = start + 2;
|
||||
}
|
||||
|
||||
block.content = editor.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-close tags (Ctrl/Cmd + /)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||
e.preventDefault();
|
||||
this.autoCloseTag(editor);
|
||||
block.content = editor.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass other key events to handlers
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Auto-resize
|
||||
this.autoResize(editor);
|
||||
editor.addEventListener('input', () => this.autoResize(editor));
|
||||
}
|
||||
|
||||
private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.html-block-container') as HTMLElement;
|
||||
const preview = element.querySelector('.html-preview') as HTMLElement;
|
||||
|
||||
if (!container || !preview) return;
|
||||
|
||||
// Make preview focusable
|
||||
preview.setAttribute('tabindex', '0');
|
||||
|
||||
// Focus handling
|
||||
preview.addEventListener('focus', () => handlers.onFocus());
|
||||
preview.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Keyboard navigation
|
||||
preview.addEventListener('keydown', (e) => {
|
||||
// Switch to edit mode on Enter
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
block.metadata.isEditMode = true;
|
||||
handlers.onRequestUpdate?.();
|
||||
return;
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Sandbox styles and scripts in preview
|
||||
this.sandboxContent(preview);
|
||||
}
|
||||
|
||||
private autoCloseTag(editor: HTMLTextAreaElement): void {
|
||||
const cursorPos = editor.selectionStart;
|
||||
const text = editor.value;
|
||||
|
||||
// Find the opening tag
|
||||
let tagStart = cursorPos;
|
||||
while (tagStart > 0 && text[tagStart - 1] !== '<') {
|
||||
tagStart--;
|
||||
}
|
||||
|
||||
if (tagStart > 0) {
|
||||
const tagContent = text.substring(tagStart, cursorPos);
|
||||
const tagMatch = tagContent.match(/^(\w+)/);
|
||||
|
||||
if (tagMatch) {
|
||||
const tagName = tagMatch[1];
|
||||
const closingTag = `</${tagName}>`;
|
||||
|
||||
// Insert closing tag
|
||||
editor.value = text.substring(0, cursorPos) + '>' + closingTag + text.substring(cursorPos);
|
||||
editor.selectionStart = editor.selectionEnd = cursorPos + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private autoResize(editor: HTMLTextAreaElement): void {
|
||||
editor.style.height = 'auto';
|
||||
editor.style.height = editor.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
private validateHtml(html: string): boolean {
|
||||
// Basic HTML validation
|
||||
const openTags: string[] = [];
|
||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g;
|
||||
let match;
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const isClosing = match[0].startsWith('</');
|
||||
const tagName = match[1].toLowerCase();
|
||||
|
||||
if (isClosing) {
|
||||
if (openTags.length === 0 || openTags[openTags.length - 1] !== tagName) {
|
||||
console.warn(`Mismatched closing tag: ${tagName}`);
|
||||
return false;
|
||||
}
|
||||
openTags.pop();
|
||||
} else if (!match[0].endsWith('/>')) {
|
||||
// Not a self-closing tag
|
||||
openTags.push(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
if (openTags.length > 0) {
|
||||
console.warn(`Unclosed tags: ${openTags.join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sandboxContent(preview: HTMLElement): void {
|
||||
// Remove any script tags
|
||||
const scripts = preview.querySelectorAll('script');
|
||||
scripts.forEach(script => script.remove());
|
||||
|
||||
// Remove event handlers
|
||||
const allElements = preview.querySelectorAll('*');
|
||||
allElements.forEach(el => {
|
||||
// Remove all on* attributes
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
if (attr.name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent forms from submitting
|
||||
const forms = preview.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
return editor.value;
|
||||
}
|
||||
|
||||
// If in preview mode, return the stored content
|
||||
const container = element.querySelector('.html-block-container');
|
||||
const blockId = container?.getAttribute('data-block-id');
|
||||
// In real implementation, would need access to block data
|
||||
return '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
editor.value = content;
|
||||
this.autoResize(editor);
|
||||
}
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
return editor ? editor.selectionStart : null;
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
editor.selectionStart = editor.selectionEnd = 0;
|
||||
editor.focus();
|
||||
} else {
|
||||
this.focus(element);
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
const length = editor.value.length;
|
||||
editor.selectionStart = editor.selectionEnd = length;
|
||||
editor.focus();
|
||||
} else {
|
||||
this.focus(element);
|
||||
}
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
editor.focus();
|
||||
} else {
|
||||
const preview = element.querySelector('.html-preview') as HTMLElement;
|
||||
preview?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element);
|
||||
} else if (position === 'end') {
|
||||
this.setCursorToEnd(element);
|
||||
} else if (typeof position === 'number') {
|
||||
editor.selectionStart = editor.selectionEnd = position;
|
||||
editor.focus();
|
||||
}
|
||||
} else {
|
||||
this.focus(element);
|
||||
}
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (!editor) return null;
|
||||
|
||||
const cursorPos = editor.selectionStart;
|
||||
return {
|
||||
before: editor.value.substring(0, cursorPos),
|
||||
after: editor.value.substring(cursorPos)
|
||||
};
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* HTML Block Container */
|
||||
.html-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#111827')};
|
||||
}
|
||||
|
||||
.html-block-container.selected {
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.html-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.html-icon {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.html-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.html-toggle-mode {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.html-toggle-mode:hover {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.html-content {
|
||||
position: relative;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Editor */
|
||||
.html-editor {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.html-editor::placeholder {
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.html-preview {
|
||||
padding: 12px;
|
||||
min-height: 96px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Sandboxed HTML preview styles */
|
||||
.html-preview * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.html-preview img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.html-preview a {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.html-preview a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.html-preview table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.html-preview th,
|
||||
.html-preview td {
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.html-preview th {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.html-preview pre {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.html-preview code {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.html-preview pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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')};
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
43
ts_web/elements/dees-input-wysiwyg/blocks/index.ts
Normal file
43
ts_web/elements/dees-input-wysiwyg/blocks/index.ts
Normal 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';
|
||||
@@ -0,0 +1,477 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* AttachmentBlockHandler - Handles file attachments
|
||||
*
|
||||
* Features:
|
||||
* - Multiple file upload support
|
||||
* - Click to upload or drag and drop
|
||||
* - File type icons
|
||||
* - Remove individual files
|
||||
* - Base64 encoding (TODO: server upload in production)
|
||||
*/
|
||||
export class AttachmentBlockHandler extends BaseBlockHandler {
|
||||
type = 'attachment';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const files = block.metadata?.files || [];
|
||||
|
||||
return `
|
||||
<div class="attachment-block-container${isSelected ? ' selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
tabindex="0">
|
||||
<div class="attachment-header">
|
||||
<div class="attachment-icon">📎</div>
|
||||
<div class="attachment-title">File Attachments</div>
|
||||
</div>
|
||||
<div class="attachment-list">
|
||||
${files.length > 0 ? this.renderFiles(files) : this.renderPlaceholder()}
|
||||
</div>
|
||||
<input type="file"
|
||||
class="attachment-file-input"
|
||||
multiple
|
||||
style="display: none;" />
|
||||
${files.length > 0 ? '<button class="add-more-files">Add More Files</button>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(): string {
|
||||
return `
|
||||
<div class="attachment-placeholder">
|
||||
<div class="placeholder-text">Click to add files</div>
|
||||
<div class="placeholder-hint">or drag and drop</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFiles(files: any[]): string {
|
||||
return files.map((file: any) => `
|
||||
<div class="attachment-item" data-file-id="${file.id}">
|
||||
<div class="file-icon">${this.getFileIcon(file.type)}</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${this.escapeHtml(file.name)}</div>
|
||||
<div class="file-size">${this.formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
<button class="remove-file" data-file-id="${file.id}">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.attachment-block-container') as HTMLElement;
|
||||
const fileInput = element.querySelector('.attachment-file-input') as HTMLInputElement;
|
||||
|
||||
if (!container || !fileInput) {
|
||||
console.error('AttachmentBlockHandler: Could not find required elements');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize files array if needed
|
||||
if (!block.metadata) block.metadata = {};
|
||||
if (!block.metadata.files) block.metadata.files = [];
|
||||
|
||||
// Click to upload on placeholder
|
||||
const placeholder = container.querySelector('.attachment-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Add more files button
|
||||
const addMoreBtn = container.querySelector('.add-more-files') as HTMLButtonElement;
|
||||
if (addMoreBtn) {
|
||||
addMoreBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = input.files;
|
||||
if (files && files.length > 0) {
|
||||
await this.handleFileAttachments(files, block, handlers);
|
||||
input.value = ''; // Clear input for next selection
|
||||
}
|
||||
});
|
||||
|
||||
// Remove file buttons
|
||||
container.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('remove-file')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const fileId = target.getAttribute('data-file-id');
|
||||
if (fileId) {
|
||||
this.removeFile(fileId, block, handlers);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
container.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.add('drag-over');
|
||||
});
|
||||
|
||||
container.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
container.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
await this.handleFileAttachments(files, block, handlers);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus/blur
|
||||
container.addEventListener('focus', () => handlers.onFocus());
|
||||
container.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Keyboard navigation
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
// Only remove all files if container is focused, not when removing individual files
|
||||
if (document.activeElement === container && block.metadata?.files?.length > 0) {
|
||||
e.preventDefault();
|
||||
block.metadata.files = [];
|
||||
handlers.onRequestUpdate?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleFileAttachments(
|
||||
files: FileList,
|
||||
block: IBlock,
|
||||
handlers: IBlockEventHandlers
|
||||
): Promise<void> {
|
||||
if (!block.metadata) block.metadata = {};
|
||||
if (!block.metadata.files) block.metadata.files = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const dataUrl = await this.fileToDataUrl(file);
|
||||
const fileData = {
|
||||
id: this.generateId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
data: dataUrl
|
||||
};
|
||||
|
||||
block.metadata.files.push(fileData);
|
||||
} catch (error) {
|
||||
console.error('Failed to attach file:', file.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update block content with file count
|
||||
block.content = `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`;
|
||||
|
||||
// Request UI update
|
||||
handlers.onRequestUpdate?.();
|
||||
}
|
||||
|
||||
private removeFile(fileId: string, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
if (!block.metadata?.files) return;
|
||||
|
||||
block.metadata.files = block.metadata.files.filter((f: any) => f.id !== fileId);
|
||||
|
||||
// Update content
|
||||
block.content = block.metadata.files.length > 0
|
||||
? `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`
|
||||
: '';
|
||||
|
||||
// Request UI update
|
||||
handlers.onRequestUpdate?.();
|
||||
}
|
||||
|
||||
private fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === 'string') {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
private getFileIcon(mimeType: string): string {
|
||||
if (mimeType.startsWith('image/')) return '🖼️';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('tar')) return '🗄️';
|
||||
if (mimeType.includes('sheet')) return '📊';
|
||||
if (mimeType.includes('document') || mimeType.includes('msword')) return '📝';
|
||||
if (mimeType.includes('presentation')) return '📋';
|
||||
if (mimeType.includes('text')) return '📃';
|
||||
return '📁';
|
||||
}
|
||||
|
||||
private formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
// Content is the description of attached files
|
||||
const block = this.getBlockFromElement(element);
|
||||
return block?.content || '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
// Content is the description of attached files
|
||||
const block = this.getBlockFromElement(element);
|
||||
if (block) {
|
||||
block.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
private getBlockFromElement(element: HTMLElement): IBlock | null {
|
||||
const container = element.querySelector('.attachment-block-container');
|
||||
const blockId = container?.getAttribute('data-block-id');
|
||||
if (!blockId) return null;
|
||||
|
||||
// Simplified version - in real implementation would need access to block data
|
||||
return {
|
||||
id: blockId,
|
||||
type: 'attachment',
|
||||
content: '',
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
return null; // Attachment blocks don't have cursor position
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const container = element.querySelector('.attachment-block-container') as HTMLElement;
|
||||
container?.focus();
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
return null; // Attachment blocks can't be split
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* Attachment Block Container */
|
||||
.attachment-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#111827')};
|
||||
}
|
||||
|
||||
.attachment-block-container.selected {
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
.attachment-block-container.drag-over {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.attachment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
font-size: 18px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.attachment-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
/* File List */
|
||||
.attachment-list {
|
||||
padding: 8px;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.attachment-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.attachment-placeholder:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.placeholder-hint {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* File Items */
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.attachment-item:hover {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.remove-file {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.remove-file:hover {
|
||||
background: ${cssManager.bdTheme('#fee2e2', '#991b1b')};
|
||||
border-color: ${cssManager.bdTheme('#fca5a5', '#dc2626')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#fca5a5')};
|
||||
}
|
||||
|
||||
/* Add More Files Button */
|
||||
.add-more-files {
|
||||
margin: 8px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.add-more-files:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
|
||||
/* Hidden file input */
|
||||
.attachment-file-input {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
406
ts_web/elements/dees-input-wysiwyg/blocks/media/image.block.ts
Normal file
406
ts_web/elements/dees-input-wysiwyg/blocks/media/image.block.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* ImageBlockHandler - Handles image upload, display, and interactions
|
||||
*
|
||||
* Features:
|
||||
* - Click to upload
|
||||
* - Drag and drop support
|
||||
* - Base64 encoding (TODO: server upload in production)
|
||||
* - Loading states
|
||||
* - Alt text from filename
|
||||
*/
|
||||
export class ImageBlockHandler extends BaseBlockHandler {
|
||||
type = 'image';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const imageUrl = block.metadata?.url;
|
||||
const altText = block.content || 'Image';
|
||||
const isLoading = block.metadata?.loading;
|
||||
|
||||
return `
|
||||
<div class="image-block-container${isSelected ? ' selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
data-has-image="${!!imageUrl}"
|
||||
tabindex="0">
|
||||
${isLoading ? this.renderLoading() :
|
||||
imageUrl ? this.renderImage(imageUrl, altText) :
|
||||
this.renderPlaceholder()}
|
||||
<input type="file"
|
||||
class="image-file-input"
|
||||
accept="image/*"
|
||||
style="display: none;" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(): string {
|
||||
return `
|
||||
<div class="image-upload-placeholder" style="cursor: pointer;">
|
||||
<div class="upload-icon" style="pointer-events: none;">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="upload-text" style="pointer-events: none;">Click to upload an image</div>
|
||||
<div class="upload-hint" style="pointer-events: none;">or drag and drop</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderImage(url: string, altText: string): string {
|
||||
return `
|
||||
<div class="image-container">
|
||||
<img src="${url}" alt="${this.escapeHtml(altText)}" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLoading(): string {
|
||||
return `
|
||||
<div class="image-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Uploading image...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.image-block-container') as HTMLElement;
|
||||
const fileInput = element.querySelector('.image-file-input') as HTMLInputElement;
|
||||
|
||||
if (!container) {
|
||||
console.error('ImageBlockHandler: Could not find container');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileInput) {
|
||||
console.error('ImageBlockHandler: Could not find file input');
|
||||
return;
|
||||
}
|
||||
|
||||
// Click to upload (only on placeholder)
|
||||
const placeholder = container.querySelector('.image-upload-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('ImageBlockHandler: Placeholder clicked, opening file selector');
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Container click for focus
|
||||
container.addEventListener('click', () => {
|
||||
handlers.onFocus();
|
||||
});
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
console.log('ImageBlockHandler: File selected:', file.name);
|
||||
await this.handleFileUpload(file, block, handlers);
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
container.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!block.metadata?.url) {
|
||||
container.classList.add('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
container.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.remove('drag-over');
|
||||
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file && file.type.startsWith('image/') && !block.metadata?.url) {
|
||||
await this.handleFileUpload(file, block, handlers);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus/blur
|
||||
container.addEventListener('focus', () => handlers.onFocus());
|
||||
container.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Keyboard navigation
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (block.metadata?.url) {
|
||||
// Clear the image
|
||||
block.metadata.url = undefined;
|
||||
block.metadata.loading = false;
|
||||
block.content = '';
|
||||
handlers.onInput(new InputEvent('input'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleFileUpload(
|
||||
file: File,
|
||||
block: IBlock,
|
||||
handlers: IBlockEventHandlers
|
||||
): Promise<void> {
|
||||
console.log('ImageBlockHandler: Starting file upload', {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
blockId: block.id
|
||||
});
|
||||
|
||||
// Validate file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.error('Invalid file type:', file.type);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file size (10MB limit)
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
console.error('File too large. Maximum size is 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.loading = true;
|
||||
block.metadata.fileName = file.name;
|
||||
block.metadata.fileSize = file.size;
|
||||
block.metadata.mimeType = file.type;
|
||||
|
||||
console.log('ImageBlockHandler: Set loading state, requesting update');
|
||||
// Request immediate UI update for loading state
|
||||
handlers.onRequestUpdate?.();
|
||||
|
||||
try {
|
||||
// Convert to base64
|
||||
const dataUrl = await this.fileToDataUrl(file);
|
||||
|
||||
// Update block
|
||||
block.metadata.url = dataUrl;
|
||||
block.metadata.loading = false;
|
||||
|
||||
// Set default alt text from filename
|
||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||
block.content = nameWithoutExt;
|
||||
|
||||
console.log('ImageBlockHandler: Upload complete, requesting update', {
|
||||
hasUrl: !!block.metadata.url,
|
||||
urlLength: dataUrl.length,
|
||||
altText: block.content
|
||||
});
|
||||
|
||||
// Request immediate UI update to show uploaded image
|
||||
handlers.onRequestUpdate?.();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
block.metadata.loading = false;
|
||||
// Request UI update to clear loading state
|
||||
handlers.onRequestUpdate?.();
|
||||
}
|
||||
}
|
||||
|
||||
private fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === 'string') {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
// Content is the alt text
|
||||
const block = this.getBlockFromElement(element);
|
||||
return block?.content || '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
// Content is the alt text
|
||||
const block = this.getBlockFromElement(element);
|
||||
if (block) {
|
||||
block.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
private getBlockFromElement(element: HTMLElement): IBlock | null {
|
||||
const container = element.querySelector('.image-block-container');
|
||||
const blockId = container?.getAttribute('data-block-id');
|
||||
if (!blockId) return null;
|
||||
|
||||
// This is a simplified version - in real implementation,
|
||||
// we'd need access to the block data
|
||||
return {
|
||||
id: blockId,
|
||||
type: 'image',
|
||||
content: '',
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
return null; // Images don't have cursor position
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const container = element.querySelector('.image-block-container') as HTMLElement;
|
||||
container?.focus();
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
return null; // Images can't be split
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* Image Block Container */
|
||||
.image-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-block-container.selected {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
}
|
||||
|
||||
/* Upload Placeholder */
|
||||
.image-upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
border: 2px dashed ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.image-block-container:hover .image-upload-placeholder {
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
}
|
||||
|
||||
.image-block-container.drag-over .image-upload-placeholder {
|
||||
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
background: ${cssManager.bdTheme('#eff6ff', '#1e1b4b')};
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
margin-bottom: 12px;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* Image Container */
|
||||
.image-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
}
|
||||
|
||||
.image-container img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.image-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-top-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
/* File input hidden */
|
||||
.image-file-input {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
337
ts_web/elements/dees-input-wysiwyg/blocks/media/youtube.block.ts
Normal file
337
ts_web/elements/dees-input-wysiwyg/blocks/media/youtube.block.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* YouTubeBlockHandler - Handles YouTube video embedding
|
||||
*
|
||||
* Features:
|
||||
* - YouTube URL parsing and validation
|
||||
* - Video ID extraction from various YouTube URL formats
|
||||
* - Embedded iframe player
|
||||
* - Clean minimalist design
|
||||
*/
|
||||
export class YouTubeBlockHandler extends BaseBlockHandler {
|
||||
type = 'youtube';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const videoId = block.metadata?.videoId;
|
||||
const url = block.metadata?.url || '';
|
||||
|
||||
return `
|
||||
<div class="youtube-block-container${isSelected ? ' selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
data-has-video="${!!videoId}">
|
||||
${videoId ? this.renderVideo(videoId) : this.renderPlaceholder(url)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(url: string): string {
|
||||
return `
|
||||
<div class="youtube-placeholder">
|
||||
<div class="placeholder-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="placeholder-text">Enter YouTube URL</div>
|
||||
<input type="url"
|
||||
class="youtube-url-input"
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
value="${this.escapeHtml(url)}" />
|
||||
<button class="youtube-embed-btn">Embed Video</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderVideo(videoId: string): string {
|
||||
return `
|
||||
<div class="youtube-container">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/${videoId}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.youtube-block-container') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
// If video is already embedded, just handle focus/blur
|
||||
if (block.metadata?.videoId) {
|
||||
container.setAttribute('tabindex', '0');
|
||||
container.addEventListener('focus', () => handlers.onFocus());
|
||||
container.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Handle deletion
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
handlers.onKeyDown(e);
|
||||
} else {
|
||||
handlers.onKeyDown(e);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup placeholder interactions
|
||||
const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement;
|
||||
const embedBtn = element.querySelector('.youtube-embed-btn') as HTMLButtonElement;
|
||||
|
||||
if (!urlInput || !embedBtn) return;
|
||||
|
||||
// Focus management
|
||||
urlInput.addEventListener('focus', () => handlers.onFocus());
|
||||
urlInput.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Handle embed button click
|
||||
embedBtn.addEventListener('click', () => {
|
||||
this.embedVideo(urlInput.value, block, handlers);
|
||||
});
|
||||
|
||||
// Handle Enter key in input
|
||||
urlInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.embedVideo(urlInput.value, block, handlers);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
urlInput.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle paste event
|
||||
urlInput.addEventListener('paste', (e) => {
|
||||
// Allow paste to complete first
|
||||
setTimeout(() => {
|
||||
const pastedUrl = urlInput.value;
|
||||
if (this.extractYouTubeVideoId(pastedUrl)) {
|
||||
// Auto-embed if valid YouTube URL was pasted
|
||||
this.embedVideo(pastedUrl, block, handlers);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Update URL in metadata as user types
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.url = urlInput.value;
|
||||
});
|
||||
}
|
||||
|
||||
private embedVideo(url: string, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const videoId = this.extractYouTubeVideoId(url);
|
||||
|
||||
if (!videoId) {
|
||||
// Could show an error message here
|
||||
console.error('Invalid YouTube URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update block metadata
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.videoId = videoId;
|
||||
block.metadata.url = url;
|
||||
|
||||
// Set content as video title (could be fetched from API in the future)
|
||||
block.content = `YouTube Video: ${videoId}`;
|
||||
|
||||
// Request immediate UI update to show embedded video
|
||||
handlers.onRequestUpdate?.();
|
||||
}
|
||||
|
||||
private extractYouTubeVideoId(url: string): string | null {
|
||||
// Handle various YouTube URL formats
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/,
|
||||
/youtube\.com\/embed\/([^"&?\/ ]{11})/,
|
||||
/youtube\.com\/watch\?v=([^"&?\/ ]{11})/,
|
||||
/youtu\.be\/([^"&?\/ ]{11})/
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
// Content is the video description/title
|
||||
const block = this.getBlockFromElement(element);
|
||||
return block?.content || '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
// Content is the video description/title
|
||||
const block = this.getBlockFromElement(element);
|
||||
if (block) {
|
||||
block.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
private getBlockFromElement(element: HTMLElement): IBlock | null {
|
||||
const container = element.querySelector('.youtube-block-container');
|
||||
const blockId = container?.getAttribute('data-block-id');
|
||||
if (!blockId) return null;
|
||||
|
||||
// Simplified version - in real implementation would need access to block data
|
||||
return {
|
||||
id: blockId,
|
||||
type: 'youtube',
|
||||
content: '',
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
return null; // YouTube blocks don't have cursor position
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const container = element.querySelector('.youtube-block-container') as HTMLElement;
|
||||
const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement;
|
||||
|
||||
if (urlInput) {
|
||||
urlInput.focus();
|
||||
} else if (container) {
|
||||
container.focus();
|
||||
}
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
return null; // YouTube blocks can't be split
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* YouTube Block Container */
|
||||
.youtube-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.youtube-block-container.selected {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
}
|
||||
|
||||
/* YouTube Placeholder */
|
||||
.youtube-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.youtube-url-input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#111827')};
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.youtube-url-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#1f2937')};
|
||||
}
|
||||
|
||||
.youtube-url-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
|
||||
}
|
||||
|
||||
.youtube-embed-btn {
|
||||
padding: 6px 16px;
|
||||
background: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
color: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.youtube-embed-btn:hover {
|
||||
background: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.youtube-embed-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* YouTube Container */
|
||||
.youtube-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||
background: ${cssManager.bdTheme('#000000', '#000000')};
|
||||
}
|
||||
|
||||
.youtube-container iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
716
ts_web/elements/dees-input-wysiwyg/blocks/text/code.block.ts
Normal file
716
ts_web/elements/dees-input-wysiwyg/blocks/text/code.block.ts
Normal 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')};
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
533
ts_web/elements/dees-input-wysiwyg/blocks/text/heading.block.ts
Normal file
533
ts_web/elements/dees-input-wysiwyg/blocks/text/heading.block.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
448
ts_web/elements/dees-input-wysiwyg/blocks/text/list.block.ts
Normal file
448
ts_web/elements/dees-input-wysiwyg/blocks/text/list.block.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
457
ts_web/elements/dees-input-wysiwyg/blocks/text/quote.block.ts
Normal file
457
ts_web/elements/dees-input-wysiwyg/blocks/text/quote.block.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
221
ts_web/elements/dees-input-wysiwyg/dees-formatting-menu.ts
Normal file
221
ts_web/elements/dees-input-wysiwyg/dees-formatting-menu.ts
Normal 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
|
||||
}
|
||||
}
|
||||
991
ts_web/elements/dees-input-wysiwyg/dees-input-wysiwyg.ts
Normal file
991
ts_web/elements/dees-input-wysiwyg/dees-input-wysiwyg.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
259
ts_web/elements/dees-input-wysiwyg/dees-slash-menu.ts
Normal file
259
ts_web/elements/dees-input-wysiwyg/dees-slash-menu.ts
Normal 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
|
||||
}
|
||||
}
|
||||
965
ts_web/elements/dees-input-wysiwyg/dees-wysiwyg-block.ts
Normal file
965
ts_web/elements/dees-input-wysiwyg/dees-wysiwyg-block.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
18
ts_web/elements/dees-input-wysiwyg/index.ts
Normal file
18
ts_web/elements/dees-input-wysiwyg/index.ts
Normal 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';
|
||||
61
ts_web/elements/dees-input-wysiwyg/phase2-summary.md
Normal file
61
ts_web/elements/dees-input-wysiwyg/phase2-summary.md
Normal 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
|
||||
75
ts_web/elements/dees-input-wysiwyg/phase4-summary.md
Normal file
75
ts_web/elements/dees-input-wysiwyg/phase4-summary.md
Normal 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.
|
||||
177
ts_web/elements/dees-input-wysiwyg/wysiwyg.blockoperations.ts
Normal file
177
ts_web/elements/dees-input-wysiwyg/wysiwyg.blockoperations.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
202
ts_web/elements/dees-input-wysiwyg/wysiwyg.blocks.ts
Normal file
202
ts_web/elements/dees-input-wysiwyg/wysiwyg.blocks.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
ts_web/elements/dees-input-wysiwyg/wysiwyg.constants.ts
Normal file
27
ts_web/elements/dees-input-wysiwyg/wysiwyg.constants.ts
Normal 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];
|
||||
329
ts_web/elements/dees-input-wysiwyg/wysiwyg.converters.ts
Normal file
329
ts_web/elements/dees-input-wysiwyg/wysiwyg.converters.ts
Normal 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 ? `` : '';
|
||||
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 
|
||||
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;
|
||||
}
|
||||
}
|
||||
490
ts_web/elements/dees-input-wysiwyg/wysiwyg.dragdrophandler.ts
Normal file
490
ts_web/elements/dees-input-wysiwyg/wysiwyg.dragdrophandler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
369
ts_web/elements/dees-input-wysiwyg/wysiwyg.formatting.ts
Normal file
369
ts_web/elements/dees-input-wysiwyg/wysiwyg.formatting.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
167
ts_web/elements/dees-input-wysiwyg/wysiwyg.history.ts
Normal file
167
ts_web/elements/dees-input-wysiwyg/wysiwyg.history.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
302
ts_web/elements/dees-input-wysiwyg/wysiwyg.inputhandler.ts
Normal file
302
ts_web/elements/dees-input-wysiwyg/wysiwyg.inputhandler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
ts_web/elements/dees-input-wysiwyg/wysiwyg.interfaces.ts
Normal file
89
ts_web/elements/dees-input-wysiwyg/wysiwyg.interfaces.ts
Normal 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
|
||||
}
|
||||
770
ts_web/elements/dees-input-wysiwyg/wysiwyg.keyboardhandler.ts
Normal file
770
ts_web/elements/dees-input-wysiwyg/wysiwyg.keyboardhandler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
295
ts_web/elements/dees-input-wysiwyg/wysiwyg.modalmanager.ts
Normal file
295
ts_web/elements/dees-input-wysiwyg/wysiwyg.modalmanager.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
283
ts_web/elements/dees-input-wysiwyg/wysiwyg.selection.ts
Normal file
283
ts_web/elements/dees-input-wysiwyg/wysiwyg.selection.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
74
ts_web/elements/dees-input-wysiwyg/wysiwyg.shortcuts.ts
Normal file
74
ts_web/elements/dees-input-wysiwyg/wysiwyg.shortcuts.ts
Normal 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';
|
||||
549
ts_web/elements/dees-input-wysiwyg/wysiwyg.styles.ts
Normal file
549
ts_web/elements/dees-input-wysiwyg/wysiwyg.styles.ts
Normal 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')};
|
||||
}
|
||||
`;
|
||||
20
ts_web/elements/dees-input-wysiwyg/wysiwyg.types.ts
Normal file
20
ts_web/elements/dees-input-wysiwyg/wysiwyg.types.ts
Normal 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';
|
||||
Reference in New Issue
Block a user