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:
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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user