feat(editor): Add wysiwyg editor

This commit is contained in:
2025-06-23 21:15:04 +00:00
parent f2e6342a61
commit cdcd4f79c8
6 changed files with 526 additions and 13 deletions

View File

@@ -17,7 +17,8 @@ import {
WysiwygConverters,
WysiwygShortcuts,
WysiwygBlocks,
type ISlashMenuItem
type ISlashMenuItem,
WysiwygFormatting
} from './index.js';
declare global {
@@ -69,9 +70,19 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
@state()
private dragOverPosition: 'before' | 'after' | null = null;
@state()
private showFormattingMenu: boolean = false;
@state()
private formattingMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
@state()
private selectedText: string = '';
private editorContentRef: HTMLDivElement;
private isComposing: boolean = false;
private saveTimeout: any = null;
private selectionChangeHandler = () => this.handleSelectionChange();
public static styles = [
...DeesInputBase.baseStyles,
@@ -79,9 +90,24 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
wysiwygStyles
];
async connectedCallback() {
await super.connectedCallback();
}
async disconnectedCallback() {
await super.disconnectedCallback();
// Remove selection listener
document.removeEventListener('selectionchange', this.selectionChangeHandler);
}
async firstUpdated() {
this.updateValue();
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
// Add global selection listener
console.log('Adding selectionchange listener');
document.addEventListener('selectionchange', this.selectionChangeHandler);
// Set initial content for blocks after a brief delay to ensure DOM is ready
await this.updateComplete;
setTimeout(() => {
@@ -116,6 +142,9 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
if (isEmpty && block.content) {
if (block.type === 'list') {
blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata);
} else if (block.content.includes('<') && block.content.includes('>')) {
// Content contains HTML formatting
blockElement.innerHTML = block.content;
} else {
blockElement.textContent = block.content;
}
@@ -140,6 +169,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
${this.blocks.map(block => this.renderBlock(block))}
</div>
${this.showSlashMenu ? this.renderSlashMenu() : ''}
${this.showFormattingMenu ? this.renderFormattingMenu() : ''}
</div>
`;
}
@@ -172,6 +202,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
onBlur: () => this.handleBlockBlur(block),
onCompositionStart: () => this.isComposing = true,
onCompositionEnd: () => this.isComposing = false,
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
})}
</div>
`;
@@ -207,6 +238,13 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
`;
}
private renderFormattingMenu(): TemplateResult {
return WysiwygFormatting.renderFormattingMenu(
this.formattingMenuPosition,
(command) => this.applyFormat(command)
);
}
private handleBlockInput(e: InputEvent, block: IBlock) {
if (this.isComposing) return;
@@ -327,6 +365,28 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
return;
}
// Handle formatting shortcuts
if (e.metaKey || e.ctrlKey) {
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
this.applyFormat('bold');
return;
case 'i':
e.preventDefault();
this.applyFormat('italic');
return;
case 'u':
e.preventDefault();
this.applyFormat('underline');
return;
case 'k':
e.preventDefault();
this.applyFormat('link');
return;
}
}
// Handle Tab key for indentation
if (e.key === 'Tab') {
if (block.type === 'code') {
@@ -756,4 +816,172 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}
}, 100);
}
private handleTextSelection(e: MouseEvent): void {
// Stop event to prevent it from bubbling up
e.stopPropagation();
console.log('handleTextSelection called from mouseup on contenteditable');
// Small delay to ensure selection is complete
setTimeout(() => {
// Alternative approach: check selection directly within the target element
const target = e.target as HTMLElement;
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const selectedText = selection.toString();
console.log('Direct selection check in handleTextSelection:', {
selectedText: selectedText.substring(0, 50),
hasText: selectedText.length > 0,
target: target.tagName + '.' + target.className
});
if (selectedText.length > 0) {
// We know this came from a mouseup on our contenteditable, so it's definitely our selection
console.log('✅ Text selected via mouseup:', selectedText);
this.selectedText = selectedText;
this.updateFormattingMenuPosition();
} else if (this.showFormattingMenu) {
this.hideFormattingMenu();
}
}
}, 50);
}
private handleSelectionChange(): void {
// Try to get selection from shadow root first, then fall back to window
const shadowSelection = (this.shadowRoot as any).getSelection ? (this.shadowRoot as any).getSelection() : null;
const windowSelection = window.getSelection();
const editorContent = this.shadowRoot?.querySelector('.editor-content') as HTMLElement;
// Check both shadow and window selections
let selection = shadowSelection;
let selectedText = shadowSelection?.toString() || '';
// If no shadow selection, check window selection
if (!selectedText && windowSelection) {
selection = windowSelection;
selectedText = windowSelection.toString() || '';
}
console.log('Selection change:', {
hasText: selectedText.length > 0,
selectedText: selectedText.substring(0, 50),
shadowSelection: !!shadowSelection,
windowSelection: !!windowSelection,
rangeCount: selection?.rangeCount,
editorContent: !!editorContent
});
if (!selection || selection.rangeCount === 0 || !editorContent) {
console.log('No selection or editor content');
return;
}
// If we have selected text, show the formatting menu
if (selectedText.length > 0) {
console.log('✅ Text selected:', selectedText);
if (selectedText !== this.selectedText) {
this.selectedText = selectedText;
this.updateFormattingMenuPosition();
}
} else if (this.showFormattingMenu) {
console.log('No text selected, hiding menu');
this.hideFormattingMenu();
}
}
private getRootNodeOfNode(node: Node): Node {
let current: Node = node;
while (current.parentNode) {
current = current.parentNode;
}
return current;
}
private updateFormattingMenuPosition(): void {
console.log('updateFormattingMenuPosition called');
const coords = WysiwygFormatting.getSelectionCoordinates(this.shadowRoot as ShadowRoot);
console.log('Selection coordinates:', coords);
if (coords) {
const container = this.shadowRoot!.querySelector('.wysiwyg-container');
if (!container) {
console.error('Container not found!');
return;
}
const containerRect = container.getBoundingClientRect();
this.formattingMenuPosition = {
x: coords.x - containerRect.left,
y: coords.y - containerRect.top
};
console.log('Setting menu position:', this.formattingMenuPosition);
this.showFormattingMenu = true;
console.log('showFormattingMenu set to:', this.showFormattingMenu);
// Force update
this.requestUpdate();
// Check if menu exists in DOM after update
setTimeout(() => {
const menu = this.shadowRoot?.querySelector('.formatting-menu');
console.log('Menu in DOM after update:', menu);
if (menu) {
console.log('Menu style:', menu.getAttribute('style'));
}
}, 100);
} else {
console.log('No coordinates found');
}
}
private hideFormattingMenu(): void {
this.showFormattingMenu = false;
this.selectedText = '';
}
private applyFormat(command: string): void {
// Save current selection before applying format
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
// Get the current block to update its content
const anchorNode = selection.anchorNode;
const blockElement = anchorNode?.nodeType === Node.TEXT_NODE
? anchorNode.parentElement?.closest('.block')
: (anchorNode as Element)?.closest('.block');
if (!blockElement) return;
const blockId = blockElement.closest('.block-wrapper')?.getAttribute('data-block-id');
const block = this.blocks.find(b => b.id === blockId);
if (!block) return;
// Apply the format
WysiwygFormatting.applyFormat(command);
// Update block content after format is applied
setTimeout(() => {
if (block.type === 'list') {
const listItems = blockElement.querySelectorAll('li');
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
} else {
// For other blocks, preserve HTML formatting
block.content = blockElement.innerHTML;
}
this.updateValue();
// Keep selection active
if (command !== 'link') {
this.updateFormattingMenuPosition();
}
}, 10);
}
}