Files
dees-catalog/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts
2025-06-24 07:21:09 +00:00

1112 lines
35 KiB
TypeScript

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,
html,
cssManager,
state,
} from '@design.estate/dees-element';
import {
type IBlock,
type OutputFormat,
wysiwygStyles,
WysiwygConverters,
WysiwygShortcuts,
WysiwygBlocks,
type ISlashMenuItem,
WysiwygFormatting
} 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()
private blocks: IBlock[] = [
{
id: WysiwygShortcuts.generateBlockId(),
type: 'paragraph',
content: '',
}
];
@state()
private selectedBlockId: string | null = null;
@state()
private showSlashMenu: boolean = false;
@state()
private slashMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
@state()
private slashMenuFilter: string = '';
@state()
private slashMenuSelectedIndex: number = 0;
@state()
private draggedBlockId: string | null = null;
@state()
private dragOverBlockId: string | null = null;
@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,
cssManager.defaultStyles,
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);
}
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' : ''}"
@click="${this.handleEditorClick}"
>
${this.blocks.map(block => this.renderBlock(block))}
</div>
${this.showSlashMenu ? this.renderSlashMenu() : ''}
${this.showFormattingMenu ? this.renderFormattingMenu() : ''}
</div>
`;
}
private renderBlock(block: IBlock): TemplateResult {
const isSelected = this.selectedBlockId === block.id;
const isDragging = this.draggedBlockId === block.id;
const isDragOver = this.dragOverBlockId === block.id;
return html`
<div
class="block-wrapper ${isDragging ? 'dragging' : ''} ${isDragOver && this.dragOverPosition === 'before' ? 'drag-over-before' : ''} ${isDragOver && this.dragOverPosition === 'after' ? 'drag-over-after' : ''}"
data-block-id="${block.id}"
@dragover="${(e: DragEvent) => this.handleDragOver(e, block)}"
@drop="${(e: DragEvent) => this.handleDrop(e, block)}"
@dragleave="${() => this.handleDragLeave(block)}"
>
${block.type !== 'divider' ? html`
<div
class="drag-handle"
draggable="true"
@dragstart="${(e: DragEvent) => this.handleDragStart(e, block)}"
@dragend="${() => this.handleDragEnd()}"
></div>
` : ''}
${WysiwygBlocks.renderBlock(block, isSelected, {
onInput: (e: InputEvent) => this.handleBlockInput(e, block),
onKeyDown: (e: KeyboardEvent) => this.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),
})}
${block.type !== 'divider' ? html`
<div
class="block-settings"
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.showBlockSettingsModal(block);
}}"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"></circle>
<circle cx="12" cy="12" r="2"></circle>
<circle cx="12" cy="19" r="2"></circle>
</svg>
</div>
` : ''}
</div>
`;
}
private getFilteredMenuItems(): ISlashMenuItem[] {
const allItems = WysiwygShortcuts.getSlashMenuItems();
return allItems.filter(item =>
this.slashMenuFilter === '' ||
item.label.toLowerCase().includes(this.slashMenuFilter.toLowerCase())
);
}
private renderSlashMenu(): TemplateResult {
const menuItems = this.getFilteredMenuItems();
return html`
<div
class="slash-menu"
style="top: ${this.slashMenuPosition.y}px; left: ${this.slashMenuPosition.x}px;"
>
${menuItems.map((item, index) => html`
<div
class="slash-menu-item ${index === this.slashMenuSelectedIndex ? 'selected' : ''}"
@click="${() => this.insertBlock(item.type as IBlock['type'])}"
@mouseenter="${() => this.slashMenuSelectedIndex = index}"
>
<span class="icon">${item.icon}</span>
<span>${item.label}</span>
</div>
`)}
</div>
`;
}
private renderFormattingMenu(): TemplateResult {
return WysiwygFormatting.renderFormattingMenu(
this.formattingMenuPosition,
(command) => this.applyFormat(command)
);
}
private handleBlockInput(e: InputEvent, block: IBlock) {
if (this.isComposing) return;
const target = e.target as HTMLDivElement;
if (block.type === 'list') {
// Extract text from list items
const listItems = target.querySelectorAll('li');
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
// Preserve list type
const listElement = target.querySelector('ol, ul');
if (listElement) {
block.metadata = { listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet' };
}
} else if (block.type === 'code') {
// For code blocks, preserve the exact text content
block.content = target.textContent || '';
} else {
// For other blocks, preserve HTML formatting
block.content = target.innerHTML || '';
}
// Check for block type change intents (use text content for detection, not HTML)
const textContent = target.textContent || '';
const detectedType = this.detectBlockTypeIntent(textContent);
// Only process if the detected type is different from current type
if (detectedType && detectedType.type !== block.type) {
e.preventDefault();
// Handle special cases
if (detectedType.type === 'list') {
block.type = 'list';
block.content = '';
block.metadata = { listType: detectedType.listType };
// Update list structure immediately
const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul';
target.innerHTML = `<${listTag}><li></li></${listTag}>`;
// Force update and focus
this.updateValue();
this.requestUpdate();
setTimeout(() => {
WysiwygBlocks.focusListItem(target);
}, 0);
return;
} else if (detectedType.type === 'divider') {
block.type = 'divider';
block.content = ' ';
// Create a new paragraph block after the divider
const newBlock = this.createNewBlock();
this.insertBlockAfter(block, newBlock);
this.updateValue();
this.requestUpdate();
return;
} else if (detectedType.type === 'code') {
// For code blocks, ask for language
this.showLanguageSelectionModal().then(language => {
if (language) {
block.type = 'code';
block.content = '';
block.metadata = { language };
// Clear the DOM element immediately
target.textContent = '';
// Force update
this.updateValue();
this.requestUpdate();
}
});
return;
} else {
// For all other block types
block.type = detectedType.type;
block.content = '';
// Clear the DOM element immediately
target.textContent = '';
// Force update
this.updateValue();
this.requestUpdate();
return;
}
}
// Check for slash commands at the beginning of any block
if (textContent === '/' || (textContent.startsWith('/') && this.showSlashMenu)) {
// Only show menu on initial '/', or update filter if already showing
if (!this.showSlashMenu && textContent === '/') {
this.showSlashMenu = true;
this.slashMenuSelectedIndex = 0;
const rect = target.getBoundingClientRect();
const containerRect = this.shadowRoot!.querySelector('.wysiwyg-container')!.getBoundingClientRect();
this.slashMenuPosition = {
x: rect.left - containerRect.left,
y: rect.bottom - containerRect.top + 4
};
}
this.slashMenuFilter = textContent.slice(1);
} else if (!textContent.startsWith('/')) {
this.closeSlashMenu();
}
// Don't update value on every input - let the browser handle typing normally
// But schedule a save after a delay
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => {
this.updateValue();
}, 1000); // Save after 1 second of inactivity
}
private handleBlockKeyDown(e: KeyboardEvent, block: IBlock) {
if (this.showSlashMenu && ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
this.handleSlashMenuKeyboard(e);
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') {
// Allow tab in code blocks
e.preventDefault();
document.execCommand('insertText', false, ' ');
return;
} else if (block.type === 'list') {
// Future: implement list indentation
e.preventDefault();
return;
}
}
if (e.key === 'Enter') {
// Handle code blocks specially
if (block.type === 'code') {
if (e.shiftKey) {
// Shift+Enter in code blocks creates a new block
e.preventDefault();
const newBlock = this.createNewBlock();
this.insertBlockAfter(block, newBlock);
}
// For normal Enter in code blocks, let the browser handle it (creates new line)
return;
}
// For other block types, handle Enter normally (without shift)
if (!e.shiftKey) {
if (block.type === 'list') {
// Handle Enter in lists differently
const target = e.target as HTMLDivElement;
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 newBlock = this.createNewBlock();
this.insertBlockAfter(block, newBlock);
}
// Otherwise, let the browser handle creating new list items
}
return;
}
e.preventDefault();
const newBlock = this.createNewBlock();
this.insertBlockAfter(block, newBlock);
}
} else if (e.key === 'Backspace' && block.content === '' && this.blocks.length > 1) {
e.preventDefault();
const blockIndex = this.blocks.findIndex(b => b.id === block.id);
if (blockIndex > 0) {
const prevBlock = this.blocks[blockIndex - 1];
this.blocks = this.blocks.filter(b => b.id !== block.id);
this.updateValue();
setTimeout(() => {
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${prevBlock.id}"]`);
if (wrapperElement && prevBlock.type !== 'divider') {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.focus();
WysiwygBlocks.setCursorToEnd(blockElement);
}
}
});
}
}
}
private handleSlashMenuKeyboard(e: KeyboardEvent) {
const menuItems = this.getFilteredMenuItems();
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
this.slashMenuSelectedIndex = (this.slashMenuSelectedIndex + 1) % menuItems.length;
break;
case 'ArrowUp':
e.preventDefault();
this.slashMenuSelectedIndex = this.slashMenuSelectedIndex === 0
? menuItems.length - 1
: this.slashMenuSelectedIndex - 1;
break;
case 'Enter':
e.preventDefault();
if (menuItems[this.slashMenuSelectedIndex]) {
this.insertBlock(menuItems[this.slashMenuSelectedIndex].type as IBlock['type']);
}
break;
case 'Escape':
e.preventDefault();
this.closeSlashMenu();
break;
}
}
private closeSlashMenu() {
if (this.showSlashMenu && 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}"]`);
if (wrapperElement) {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement && (blockElement.textContent || '').startsWith('/')) {
// Clear the slash command text
blockElement.textContent = '';
currentBlock.content = '';
// Ensure cursor stays in the block
blockElement.focus();
}
}
}
}
this.showSlashMenu = false;
this.slashMenuFilter = '';
this.slashMenuSelectedIndex = 0;
}
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' };
}
// Don't automatically revert to paragraph - blocks should keep their type
// unless explicitly changed by the user
return null;
}
private handleBlockFocus(block: IBlock) {
if (block.type !== 'divider') {
this.selectedBlockId = block.id;
}
}
private handleBlockBlur(block: IBlock) {
// Update value on blur to ensure it's saved
this.updateValue();
setTimeout(() => {
if (this.selectedBlockId === block.id) {
this.selectedBlockId = null;
}
// Don't close slash menu on blur if clicking on menu item
const activeElement = document.activeElement;
const slashMenu = this.shadowRoot?.querySelector('.slash-menu');
if (!slashMenu?.contains(activeElement as Node)) {
this.closeSlashMenu();
}
}, 200);
}
private handleEditorClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.classList.contains('editor-content')) {
const lastBlock = this.blocks[this.blocks.length - 1];
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${lastBlock.id}"]`);
if (wrapperElement && lastBlock.type !== 'divider') {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.focus();
WysiwygBlocks.setCursorToEnd(blockElement);
}
}
}
}
private createNewBlock(type: IBlock['type'] = 'paragraph', content: string = '', metadata?: any): IBlock {
return {
id: WysiwygShortcuts.generateBlockId(),
type,
content,
...(metadata && { metadata })
};
}
private insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): 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)];
this.updateValue();
if (focusNewBlock) {
setTimeout(() => {
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`);
if (wrapperElement && newBlock.type !== 'divider') {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.focus();
WysiwygBlocks.setCursorToStart(blockElement);
}
}
}, 50);
}
}
private async insertBlock(type: IBlock['type']) {
const currentBlockIndex = this.blocks.findIndex(b => b.id === this.selectedBlockId);
const currentBlock = this.blocks[currentBlockIndex];
if (currentBlock) {
// If it's a code block, ask for language
if (type === 'code') {
const language = await this.showLanguageSelectionModal();
if (!language) {
// User cancelled
this.closeSlashMenu();
return;
}
currentBlock.metadata = { language };
}
currentBlock.type = type;
currentBlock.content = '';
if (type === 'divider') {
currentBlock.content = ' ';
const newBlock = this.createNewBlock();
this.insertBlockAfter(currentBlock, newBlock);
} else if (type === 'list') {
// Handle list type specially
currentBlock.metadata = { listType: 'bullet' }; // Default to bullet list
setTimeout(() => {
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
if (wrapperElement) {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.innerHTML = '<ul><li></li></ul>';
WysiwygBlocks.focusListItem(blockElement);
}
}
});
} else {
// Force update the contenteditable element
setTimeout(() => {
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
if (wrapperElement) {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.textContent = '';
blockElement.focus();
}
}
});
}
}
this.closeSlashMenu();
this.updateValue();
}
private updateValue() {
if (this.outputFormat === 'html') {
this.value = WysiwygConverters.getHtmlOutput(this.blocks);
} else {
this.value = WysiwygConverters.getMarkdownOutput(this.blocks);
}
this.changeSubject.next(this.value);
}
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);
this.requestUpdate();
}
/**
* 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();
this.requestUpdate();
}
/**
* 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);
}
// Drag and Drop Handlers
private 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);
// Add a slight delay to show the dragging state
setTimeout(() => {
this.requestUpdate();
}, 10);
}
private handleDragEnd(): void {
this.draggedBlockId = null;
this.dragOverBlockId = null;
this.dragOverPosition = null;
this.requestUpdate();
}
private 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';
this.requestUpdate();
}
private handleDragLeave(block: IBlock): void {
if (this.dragOverBlockId === block.id) {
this.dragOverBlockId = null;
this.dragOverPosition = null;
this.requestUpdate();
}
}
private 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);
// 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 {
// 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 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);
}
private async showLanguageSelectionModal(): Promise<string | null> {
return new Promise((resolve) => {
let selectedLanguage: string | null = null;
DeesModal.createAndShow({
heading: 'Select Programming Language',
content: html`
<style>
.language-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 16px;
}
.language-button {
padding: 12px;
background: var(--dees-color-box);
border: 1px solid var(--dees-color-line-bright);
border-radius: 4px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.language-button:hover {
background: var(--dees-color-box-highlight);
border-color: var(--dees-color-primary);
}
</style>
<div class="language-grid">
${['JavaScript', 'TypeScript', 'Python', 'Java', 'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS', 'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'].map(lang => html`
<div class="language-button" @click="${(e: MouseEvent) => {
selectedLanguage = lang.toLowerCase();
// Find and click the hidden OK button to close the modal
const modal = (e.target as HTMLElement).closest('dees-modal');
if (modal) {
const okButton = modal.shadowRoot?.querySelector('.bottomButton.ok') as HTMLElement;
if (okButton) okButton.click();
}
}}">${lang}</div>
`)}
</div>
`,
menuOptions: [
{
name: 'Cancel',
action: async (modal) => {
modal.destroy();
resolve(null);
}
},
{
name: 'OK',
action: async (modal) => {
modal.destroy();
resolve(selectedLanguage);
}
}
]
});
});
}
private async showBlockSettingsModal(block: IBlock): Promise<void> {
let content: TemplateResult;
if (block.type === 'code') {
const currentLanguage = block.metadata?.language || 'plain text';
content = html`
<style>
.settings-section {
margin-bottom: 16px;
}
.settings-label {
font-weight: 500;
margin-bottom: 8px;
}
.language-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.language-button {
padding: 8px;
background: var(--dees-color-box);
border: 1px solid var(--dees-color-line-bright);
border-radius: 4px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.language-button:hover {
background: var(--dees-color-box-highlight);
border-color: var(--dees-color-primary);
}
.language-button.selected {
background: var(--dees-color-primary);
color: white;
}
</style>
<div class="settings-section">
<div class="settings-label">Programming Language</div>
<div class="language-grid">
${['JavaScript', 'TypeScript', 'Python', 'Java', 'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS', 'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'].map(lang => html`
<div class="language-button ${currentLanguage === lang.toLowerCase() ? 'selected' : ''}"
@click="${(e: MouseEvent) => {
if (!block.metadata) block.metadata = {};
block.metadata.language = lang.toLowerCase();
this.updateValue();
this.requestUpdate();
// Find and click the close button
const modal = (e.target as HTMLElement).closest('dees-modal');
if (modal) {
const closeButton = modal.shadowRoot?.querySelector('.bottomButton') as HTMLElement;
if (closeButton) closeButton.click();
}
}}">${lang}</div>
`)}
</div>
</div>
`;
} else {
content = html`<div style="padding: 16px;">No settings available for this block type.</div>`;
}
DeesModal.createAndShow({
heading: 'Block Settings',
content,
menuOptions: [
{
name: 'Close',
action: async (modal) => {
modal.destroy();
}
}
]
});
}
}