fix(dees-modal): theming

This commit is contained in:
Juergen Kunz
2025-06-24 10:45:06 +00:00
parent c82c407350
commit 8b02c5aea3
18 changed files with 2283 additions and 600 deletions

View File

@ -0,0 +1,178 @@
import {
customElement,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
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 };
private callback: ((command: string) => void | Promise<void>) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
z-index: 10000;
pointer-events: none;
}
.formatting-menu {
position: absolute;
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``;
return html`
<div
class="formatting-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
@mousedown="${(e: MouseEvent) => {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}}"
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}"
@focus="${(e: FocusEvent) => {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}}"
>
${WysiwygFormatting.formatButtons.map(button => html`
<button
class="format-button ${button.command}"
@click="${() => this.applyFormat(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 {
this.position = position;
this.callback = callback;
this.visible = true;
}
public hide(): void {
this.visible = false;
this.callback = null;
}
public updatePosition(position: { x: number; y: number }): void {
this.position = position;
}
}

View File

@ -24,7 +24,9 @@ import {
WysiwygInputHandler,
WysiwygKeyboardHandler,
WysiwygDragDropHandler,
WysiwygModalManager
WysiwygModalManager,
DeesSlashMenu,
DeesFormattingMenu
} from './index.js';
declare global {
@ -55,17 +57,8 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
@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;
// Slash menu is now globally rendered
private slashMenu = DeesSlashMenu.getInstance();
@state()
private draggedBlockId: string | null = null;
@ -76,11 +69,8 @@ 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 };
// Formatting menu is now globally rendered
private formattingMenu = DeesFormattingMenu.getInstance();
@state()
private selectedText: string = '';
@ -129,8 +119,17 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
// Add global selection listener
console.log('Adding selectionchange listener');
document.addEventListener('selectionchange', this.selectionChangeHandler);
// Listen for custom selection events from blocks
this.addEventListener('block-text-selected', (e: CustomEvent) => {
if (!this.slashMenu.visible) {
this.selectedText = e.detail.text;
this.updateFormattingMenuPosition();
}
});
}
render(): TemplateResult {
return html`
<dees-label
@ -145,8 +144,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
>
${this.blocks.map(block => this.renderBlock(block))}
</div>
${this.showSlashMenu ? this.renderSlashMenu() : ''}
${this.showFormattingMenu ? this.renderFormattingMenu() : ''}
</div>
`;
}
@ -173,15 +170,19 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
@dragend="${() => this.dragDropHandler.handleDragEnd()}"
></div>
` : ''}
${WysiwygBlocks.renderBlock(block, isSelected, {
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),
})}
<dees-wysiwyg-block
.block="${block}"
.isSelected="${isSelected}"
.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),
}}"
></dees-wysiwyg-block>
${block.type !== 'divider' ? html`
<div
class="block-settings"
@ -205,311 +206,56 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
`;
}
public 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
WysiwygModalManager.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
// Removed - now handled by inputHandler
}
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;
this.slashMenu.navigate('down');
break;
case 'ArrowUp':
e.preventDefault();
this.slashMenuSelectedIndex = this.slashMenuSelectedIndex === 0
? menuItems.length - 1
: this.slashMenuSelectedIndex - 1;
this.slashMenu.navigate('up');
break;
case 'Enter':
e.preventDefault();
if (menuItems[this.slashMenuSelectedIndex]) {
this.insertBlock(menuItems[this.slashMenuSelectedIndex].type as IBlock['type']);
}
this.slashMenu.selectCurrent();
break;
case 'Escape':
e.preventDefault();
this.closeSlashMenu();
this.closeSlashMenu(true);
break;
}
}
public closeSlashMenu() {
if (this.showSlashMenu && this.selectedBlockId) {
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}"]`);
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();
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.showSlashMenu = false;
this.slashMenuFilter = '';
this.slashMenuSelectedIndex = 0;
this.slashMenu.hide();
}
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
@ -552,33 +298,31 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}
private handleBlockBlur(block: IBlock) {
// Don't update value if slash menu is visible
if (this.slashMenu.visible) {
return;
}
// 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);
// 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];
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);
}
if (lastBlock.type !== 'divider') {
this.blockOperations.focusBlock(lastBlock.id, 'end');
}
}
}
@ -592,79 +336,91 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
};
}
private insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): void {
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)];
this.updateValue();
this.requestUpdate();
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);
if (focusNewBlock && newBlock.type !== 'divider') {
await this.blockOperations.focusBlock(newBlock.id, 'start');
}
}
public async insertBlock(type: IBlock['type']) {
const currentBlockIndex = this.blocks.findIndex(b => b.id === this.selectedBlockId);
const currentBlock = this.blocks[currentBlockIndex];
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
if (currentBlock) {
// If it's a code block, ask for language
if (type === 'code') {
const language = await WysiwygModalManager.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();
}
}
});
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;
}
}
this.closeSlashMenu();
// Close menu
this.closeSlashMenu(false);
// If it's a code block, ask for language
if (type === 'code') {
const language = await WysiwygModalManager.showLanguageSelectionModal();
if (!language) {
return; // User cancelled
}
currentBlock.metadata = { language };
}
// 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 {
// For all other block types, ensure content is clean
currentBlock.content = currentBlock.content || '';
}
// Update and refocus
this.updateValue();
this.requestUpdate();
// Wait for update to complete before focusing
await this.updateComplete;
// 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') {
this.blockOperations.focusBlock(currentBlock.id, 'start');
}
}
private updateValue() {
@ -833,35 +589,10 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
private handleTextSelection(e: MouseEvent): void {
// Stop event to prevent it from bubbling up
e.stopPropagation();
// Don't interfere with slash menu
if (this.slashMenu.visible) return;
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);
// Let the block component handle selection via custom event
}
private handleSelectionChange(): void {
@ -902,7 +633,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
this.selectedText = selectedText;
this.updateFormattingMenuPosition();
}
} else if (this.showFormattingMenu) {
} else if (this.formattingMenu.visible) {
console.log('No text selected, hiding menu');
this.hideFormattingMenu();
}
@ -922,42 +653,33 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}
const containerRect = container.getBoundingClientRect();
this.formattingMenuPosition = {
const 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);
console.log('Setting menu position:', formattingMenuPosition);
// Show the global formatting menu
this.formattingMenu.show(
{ x: coords.x, y: coords.y }, // Use absolute coordinates
async (command: string) => await this.applyFormat(command)
);
} else {
console.log('No coordinates found');
}
}
private hideFormattingMenu(): void {
this.showFormattingMenu = false;
this.formattingMenu.hide();
this.selectedText = '';
}
public applyFormat(command: string): void {
public async applyFormat(command: string): Promise<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
// Get the current block
const anchorNode = selection.anchorNode;
const blockElement = anchorNode?.nodeType === Node.TEXT_NODE
? anchorNode.parentElement?.closest('.block')
@ -965,31 +687,120 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
if (!blockElement) return;
const blockId = blockElement.closest('.block-wrapper')?.getAttribute('data-block-id');
const blockWrapper = blockElement.closest('.block-wrapper');
const blockId = blockWrapper?.getAttribute('data-block-id');
const block = this.blocks.find(b => b.id === blockId);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (!block) return;
if (!block || !blockComponent) return;
// Apply the format
WysiwygFormatting.applyFormat(command);
// Handle link command specially
if (command === 'link') {
const url = await this.showLinkDialog();
if (!url) {
// User cancelled - restore focus to block
blockComponent.focus();
return;
}
WysiwygFormatting.applyFormat(command, url);
} else {
// 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;
}
// Update content after a microtask to ensure DOM is updated
await new Promise(resolve => setTimeout(resolve, 0));
// Force content update
block.content = blockComponent.getContent();
// Update value to persist changes
this.updateValue();
// For link command, close the formatting menu
if (command === 'link') {
this.hideFormattingMenu();
} else if (this.formattingMenu.visible) {
// Update menu position if still showing
this.updateFormattingMenuPosition();
}
// Ensure block still has focus
if (document.activeElement !== blockElement) {
blockComponent.focus();
}
}
private async showLinkDialog(): Promise<string | null> {
return new Promise((resolve) => {
let linkUrl: string | null = null;
this.updateValue();
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);
}
}
]
});
// Keep selection active
if (command !== 'link') {
this.updateFormattingMenuPosition();
}
}, 10);
// Focus the input after modal is shown
setTimeout(() => {
const input = document.querySelector('dees-modal .link-input') as HTMLInputElement;
if (input) {
input.focus();
}
}, 100);
});
}
private async showLanguageSelectionModal(): Promise<string | null> {

View File

@ -0,0 +1,209 @@
import {
customElement,
property,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
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;
private callback: ((type: string) => void) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
z-index: 10000;
pointer-events: none;
}
.slash-menu {
position: absolute;
background: ${cssManager.bdTheme('#ffffff', '#262626')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
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.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.slash-menu-item {
padding: 10px 12px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 12px;
border-radius: 4px;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
font-size: 14px;
}
.slash-menu-item:hover,
.slash-menu-item.selected {
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.slash-menu-item .icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: ${cssManager.bdTheme('#666', '#999')};
font-weight: 600;
}
.slash-menu-item:hover .icon,
.slash-menu-item.selected .icon {
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
`,
];
render(): TemplateResult {
if (!this.visible) return html``;
const menuItems = this.getFilteredMenuItems();
return html`
<div
class="slash-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
@mousedown="${(e: MouseEvent) => {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}}"
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}"
@focus="${(e: FocusEvent) => {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}}"
>
${menuItems.map((item, index) => html`
<div
class="slash-menu-item ${index === this.selectedIndex ? 'selected' : ''}"
@click="${() => this.selectItem(item.type)}"
@mouseenter="${() => this.selectedIndex = index}"
>
<span class="icon">${item.icon}</span>
<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;
this.visible = true;
}
public hide(): void {
this.visible = false;
this.callback = null;
this.filter = '';
this.selectedIndex = 0;
}
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);
}
}
}

View File

@ -0,0 +1,551 @@
import {
customElement,
property,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
query,
unsafeStatic,
static as staticHtml,
} from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
declare global {
interface HTMLElementTagNameMap {
'dees-wysiwyg-block': DeesWysiwygBlock;
}
}
@customElement('dees-wysiwyg-block')
export class DeesWysiwygBlock extends DeesElement {
@property({ type: Object })
public block: IBlock;
@property({ type: Boolean })
public isSelected: boolean = false;
@property({ type: Object })
public handlers: {
onInput: (e: InputEvent) => void;
onKeyDown: (e: KeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void;
};
@query('.block')
private blockElement: HTMLDivElement;
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.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')};
}
.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;
}
.block.code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
padding: 16px 20px;
padding-top: 32px;
border-radius: 6px;
white-space: pre-wrap;
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
line-height: 1.5;
overflow-x: auto;
margin: 20px 0;
}
.block.list {
padding: 0;
}
.block.list ul,
.block.list ol {
margin: 0;
padding-left: 24px;
}
.block.list li {
margin: 4px 0;
}
.block.divider {
padding: 0;
margin: 16px 0;
pointer-events: none;
}
.block.divider hr {
border: none;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
margin: 0;
}
/* 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-language {
position: absolute;
top: 0;
right: 0;
background: ${cssManager.bdTheme('#e1e4e8', '#333333')};
color: ${cssManager.bdTheme('#586069', '#8b949e')};
padding: 4px 12px;
font-size: 12px;
border-radius: 0 6px 0 6px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-transform: lowercase;
z-index: 1;
}
.code-block-container {
position: relative;
margin: 20px 0;
}
/* Selection styles */
.block ::selection {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
color: inherit;
}
/* Paragraph specific styles */
.block.paragraph {
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
/* Strike through */
.block :is(s, strike) {
text-decoration: line-through;
opacity: 0.7;
}
/* List specific margin adjustments */
.block.list li {
margin-bottom: 8px;
line-height: 1.6;
}
.block.list li:last-child {
margin-bottom: 0;
}
/* 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(): boolean {
// Only update if the block type or id changes
// Content changes are handled directly in the DOM
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
}
render(): TemplateResult {
if (!this.block) return html``;
if (this.block.type === 'divider') {
return html`
<div class="block divider" data-block-id="${this.block.id}" data-block-type="${this.block.type}">
<hr>
</div>
`;
}
if (this.block.type === 'code') {
const language = this.block.metadata?.language || 'plain text';
return html`
<div class="code-block-container">
<div class="code-language">${language}</div>
<div
class="block code ${this.isSelected ? 'selected' : ''}"
contenteditable="true"
data-block-type="${this.block.type}"
@input="${this.handlers?.onInput}"
@keydown="${this.handlers?.onKeyDown}"
@focus="${this.handlers?.onFocus}"
@blur="${this.handlers?.onBlur}"
@compositionstart="${this.handlers?.onCompositionStart}"
@compositionend="${this.handlers?.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
this.handleMouseUp(e);
this.handlers?.onMouseUp?.(e);
}}"
.textContent="${this.block.content || ''}"
></div>
</div>
`;
}
const placeholder = this.getPlaceholder();
const initialContent = this.getInitialContent();
return staticHtml`
<div
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
contenteditable="true"
data-placeholder="${placeholder}"
@input="${this.handlers?.onInput}"
@keydown="${this.handlers?.onKeyDown}"
@focus="${this.handlers?.onFocus}"
@blur="${this.handlers?.onBlur}"
@compositionstart="${this.handlers?.onCompositionStart}"
@compositionend="${this.handlers?.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
this.handleMouseUp(e);
this.handlers?.onMouseUp?.(e);
}}"
>${unsafeStatic(initialContent)}</div>
`;
}
private getPlaceholder(): string {
switch (this.block.type) {
case 'paragraph':
return "Type '/' for commands...";
case 'heading-1':
return 'Heading 1';
case 'heading-2':
return 'Heading 2';
case 'heading-3':
return 'Heading 3';
case 'quote':
return 'Quote';
default:
return '';
}
}
private getInitialContent(): string {
if (this.block.type === 'list') {
return WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
}
return this.block.content || '';
}
public focus(): void {
if (!this.blockElement) return;
// Ensure the element is focusable
if (!this.blockElement.hasAttribute('contenteditable')) {
this.blockElement.setAttribute('contenteditable', 'true');
}
this.blockElement.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== this.blockElement) {
Promise.resolve().then(() => {
this.blockElement.focus();
});
}
}
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
if (!this.blockElement) return;
// Ensure element is focusable first
if (!this.blockElement.hasAttribute('contenteditable')) {
this.blockElement.setAttribute('contenteditable', 'true');
}
// Focus the element
this.blockElement.focus();
// Set cursor position after focus is established
const setCursor = () => {
const selection = window.getSelection();
if (!selection) return;
if (position === 'start') {
this.setCursorToStart();
} else if (position === 'end') {
this.setCursorToEnd();
} else if (typeof position === 'number') {
// Set cursor at specific position
const range = document.createRange();
const textNode = this.getFirstTextNode(this.blockElement);
if (textNode) {
const length = textNode.textContent?.length || 0;
const safePosition = Math.min(position, length);
range.setStart(textNode, safePosition);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} else if (this.blockElement.childNodes.length === 0) {
// Empty block - create a text node
const emptyText = document.createTextNode('');
this.blockElement.appendChild(emptyText);
range.setStart(emptyText, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
};
// Ensure cursor is set after focus
if (document.activeElement === this.blockElement) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === this.blockElement) {
setCursor();
}
});
}
}
private getFirstTextNode(node: Node): Text | null {
if (node.nodeType === Node.TEXT_NODE) {
return node as Text;
}
for (let i = 0; i < node.childNodes.length; i++) {
const textNode = this.getFirstTextNode(node.childNodes[i]);
if (textNode) return textNode;
}
return null;
}
public getContent(): string {
if (!this.blockElement) return '';
if (this.block.type === 'list') {
const listItems = this.blockElement.querySelectorAll('li');
return Array.from(listItems).map(li => li.innerHTML || '').join('\n');
} else if (this.block.type === 'code') {
return this.blockElement.textContent || '';
} else {
return this.blockElement.innerHTML || '';
}
}
public setContent(content: string): void {
if (!this.blockElement) return;
// Store if we have focus
const hadFocus = document.activeElement === this.blockElement;
if (this.block.type === 'list') {
this.blockElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata);
} else if (this.block.type === 'code') {
this.blockElement.textContent = content;
} else {
this.blockElement.innerHTML = content;
}
// Restore focus if we had it
if (hadFocus) {
this.blockElement.focus();
}
}
public setCursorToStart(): void {
WysiwygBlocks.setCursorToStart(this.blockElement);
}
public setCursorToEnd(): void {
WysiwygBlocks.setCursorToEnd(this.blockElement);
}
public focusListItem(): void {
if (this.block.type === 'list') {
WysiwygBlocks.focusListItem(this.blockElement);
}
}
/**
* Gets content split at cursor position
*/
public getSplitContent(): { before: string; after: string } | null {
if (!this.blockElement) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return {
before: this.getContent(),
after: ''
};
}
const range = selection.getRangeAt(0);
// Check if selection is within this block
if (!this.blockElement.contains(range.commonAncestorContainer)) {
return null;
}
// Clone the range to extract content before and after cursor
const beforeRange = range.cloneRange();
beforeRange.selectNodeContents(this.blockElement);
beforeRange.setEnd(range.startContainer, range.startOffset);
const afterRange = range.cloneRange();
afterRange.selectNodeContents(this.blockElement);
afterRange.setStart(range.endContainer, range.endOffset);
// Extract content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML
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
};
}
private handleMouseUp(_e: MouseEvent): void {
// Check if we have a selection within this block
setTimeout(() => {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
// Check if selection is within this block
if (this.blockElement && this.blockElement.contains(range.commonAncestorContainer)) {
const selectedText = selection.toString();
if (selectedText.length > 0) {
// Dispatch a custom event that can cross shadow DOM boundaries
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: selectedText,
blockId: this.block.id,
range: range
},
bubbles: true,
composed: true
}));
}
}
}
}, 10);
}
}

View File

@ -1,4 +1,5 @@
export * from './wysiwyg.types.js';
export * from './wysiwyg.interfaces.js';
export * from './wysiwyg.styles.js';
export * from './wysiwyg.converters.js';
export * from './wysiwyg.shortcuts.js';
@ -8,4 +9,7 @@ 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.modalmanager.js';
export * from './dees-wysiwyg-block.js';
export * from './dees-slash-menu.js';
export * from './dees-formatting-menu.js';

View File

@ -24,7 +24,7 @@ export class WysiwygBlockOperations {
/**
* Inserts a block after the specified block
*/
insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): void {
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);
@ -35,11 +35,14 @@ export class WysiwygBlockOperations {
];
this.component.updateValue();
this.component.requestUpdate();
if (focusNewBlock && newBlock.type !== 'divider') {
setTimeout(() => {
this.focusBlock(newBlock.id);
}, 50);
// Wait for the component to update
await this.component.updateComplete;
// Focus the new block
await this.focusBlock(newBlock.id, 'start');
}
}
@ -68,17 +71,19 @@ export class WysiwygBlockOperations {
/**
* Focuses a specific block
*/
focusBlock(blockId: string, cursorPosition: 'start' | 'end' = 'start'): void {
async focusBlock(blockId: string, cursorPosition: 'start' | 'end' | number = 'start'): Promise<void> {
// First ensure the component is updated
await this.component.updateComplete;
const wrapperElement = this.component.shadowRoot!.querySelector(`[data-block-id="${blockId}"]`);
if (wrapperElement) {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.focus();
if (cursorPosition === 'start') {
WysiwygBlocks.setCursorToStart(blockElement);
} else {
WysiwygBlocks.setCursorToEnd(blockElement);
}
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);
}
}
}

View File

@ -7,7 +7,8 @@ export class WysiwygBlocks {
const items = content.split('\n').filter(item => item.trim());
if (items.length === 0) return '';
const listTag = metadata?.listType === 'ordered' ? 'ol' : 'ul';
return `<${listTag}>${items.map(item => `<li>${WysiwygConverters.escapeHtml(item)}</li>`).join('')}</${listTag}>`;
// Don't escape HTML to preserve formatting
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
}
static renderBlock(
@ -102,21 +103,88 @@ export class WysiwygBlocks {
}
static setCursorToEnd(element: HTMLElement): void {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(element);
range.collapse(false);
sel!.removeAllRanges();
sel!.addRange(range);
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 range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(element);
range.collapse(true);
sel!.removeAllRanges();
sel!.addRange(range);
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 {

View File

@ -31,7 +31,8 @@ export class WysiwygConverters {
const items = block.content.split('\n').filter(item => item.trim());
if (items.length > 0) {
const listTag = block.metadata?.listType === 'ordered' ? 'ol' : 'ul';
return `<${listTag}>${items.map(item => `<li>${this.escapeHtml(item)}</li>`).join('')}</${listTag}>`;
// Don't escape HTML in list items to preserve formatting
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
}
return '';
case 'divider':
@ -135,7 +136,8 @@ export class WysiwygConverters {
case 'ul':
case 'ol':
const listItems = Array.from(element.querySelectorAll('li'));
const content = listItems.map(li => li.textContent || '').join('\n');
// 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',

View File

@ -42,46 +42,104 @@ export class WysiwygFormatting {
`;
}
static applyFormat(command: string, value?: string): void {
static applyFormat(command: string, value?: string): boolean {
// Save current selection
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
// Apply format based on command
switch (command) {
case 'bold':
this.wrapSelection(range, 'strong');
break;
case 'italic':
this.wrapSelection(range, 'em');
break;
case 'underline':
this.wrapSelection(range, 'u');
break;
case 'strikeThrough':
document.execCommand(command, false);
this.wrapSelection(range, 's');
break;
case 'code':
// For inline code, wrap selection in <code> tags
const codeElement = document.createElement('code');
try {
codeElement.appendChild(range.extractContents());
range.insertNode(codeElement);
// Select the newly created code element
range.selectNodeContents(codeElement);
selection.removeAllRanges();
selection.addRange(range);
} catch (e) {
// Fallback to execCommand if range manipulation fails
document.execCommand('fontName', false, 'monospace');
}
this.wrapSelection(range, 'code');
break;
case 'link':
const url = value || prompt('Enter URL:');
if (url) {
document.execCommand('createLink', false, url);
// Don't use prompt - return false to indicate we need async input
if (!value) {
return false;
}
this.wrapSelectionWithLink(range, value);
break;
}
return true;
}
private static wrapSelection(range: Range, tagName: string): void {
const selection = window.getSelection();
if (!selection) return;
// Check if we're already wrapped in this tag
const parentElement = range.commonAncestorContainer.parentElement;
if (parentElement && parentElement.tagName.toLowerCase() === tagName) {
// Unwrap
const parent = parentElement.parentNode;
while (parentElement.firstChild) {
parent?.insertBefore(parentElement.firstChild, parentElement);
}
parent?.removeChild(parentElement);
// Restore selection
selection.removeAllRanges();
selection.addRange(range);
} else {
// Wrap selection
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);
}
}
}
private static wrapSelectionWithLink(range: Range, url: string): void {
const selection = window.getSelection();
if (!selection) return;
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(shadowRoot?: ShadowRoot): { x: number, y: number } | null {
@ -118,10 +176,33 @@ export class WysiwygFormatting {
}
static isFormattingApplied(command: string): boolean {
try {
return document.queryCommandState(command);
} catch {
return false;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
const container = range.commonAncestorContainer;
const element = container.nodeType === Node.TEXT_NODE
? container.parentElement
: container as Element;
if (!element) return false;
// Check if formatting is applied by looking at parent elements
switch (command) {
case 'bold':
return !!element.closest('b, strong');
case 'italic':
return !!element.closest('i, em');
case 'underline':
return !!element.closest('u');
case 'strikeThrough':
return !!element.closest('s, strike');
case 'code':
return !!element.closest('code');
case 'link':
return !!element.closest('a');
default:
return false;
}
}

View File

@ -42,20 +42,41 @@ export class WysiwygInputHandler {
* Updates block content based on its type
*/
private updateBlockContent(block: IBlock, target: HTMLDivElement): void {
if (block.type === 'list') {
const listItems = target.querySelectorAll('li');
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
// 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
block.content = blockComponent.getContent();
const listElement = target.querySelector('ol, ul');
if (listElement) {
block.metadata = {
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
};
// 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 if (block.type === 'code') {
block.content = target.textContent || '';
} else {
block.content = target.innerHTML || '';
// 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 || '';
}
}
}
@ -151,24 +172,54 @@ export class WysiwygInputHandler {
* Handles slash command detection and menu display
*/
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
if (textContent === '/' || (textContent.startsWith('/') && this.component.showSlashMenu)) {
if (!this.component.showSlashMenu && textContent === '/') {
this.component.showSlashMenu = true;
this.component.slashMenuSelectedIndex = 0;
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);
const rect = target.getBoundingClientRect();
const containerRect = this.component.shadowRoot!.querySelector('.wysiwyg-container')!.getBoundingClientRect();
// Show the slash menu at the cursor position
slashMenu.show(
{ x: rect.left, y: rect.bottom + 4 },
(type: string) => {
this.component.insertBlock(type);
}
);
this.component.slashMenuPosition = {
x: rect.left - containerRect.left,
y: rect.bottom - containerRect.top + 4
};
// Ensure the block maintains focus
requestAnimationFrame(() => {
if (document.activeElement !== target) {
target.focus();
}
});
}
// Update filter
if (slashMenu) {
slashMenu.updateFilter(textContent.slice(1));
}
this.component.slashMenuFilter = 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
@ -177,6 +228,10 @@ export class WysiwygInputHandler {
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(() => {
this.component.updateValue();
}, 1000);

View File

@ -0,0 +1,79 @@
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;
// Menus
slashMenu: DeesSlashMenu;
formattingMenu: DeesFormattingMenu;
// Methods
updateValue(): void;
requestUpdate(): Promise<void>;
updateComplete: Promise<boolean>;
insertBlock(type: string): Promise<void>;
closeSlashMenu(clearSlash?: boolean): void;
applyFormat(command: string): Promise<void>;
handleSlashMenuKeyboard(e: KeyboardEvent): 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;
splitBlock(blockId: string, splitPosition: number): Promise<IBlock>;
}
/**
* 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;
}

View File

@ -1,6 +1,4 @@
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
export class WysiwygKeyboardHandler {
private component: any;
@ -12,10 +10,10 @@ export class WysiwygKeyboardHandler {
/**
* Handles keyboard events for blocks
*/
handleBlockKeyDown(e: KeyboardEvent, block: IBlock): void {
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
// Handle slash menu navigation
if (this.component.showSlashMenu && this.isSlashMenuKey(e.key)) {
this.handleSlashMenuKeyboard(e);
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
this.component.handleSlashMenuKeyboard(e);
return;
}
@ -30,10 +28,22 @@ export class WysiwygKeyboardHandler {
this.handleTab(e, block);
break;
case 'Enter':
this.handleEnter(e, block);
await this.handleEnter(e, block);
break;
case 'Backspace':
this.handleBackspace(e, block);
await this.handleBackspace(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;
}
}
@ -54,19 +64,20 @@ export class WysiwygKeyboardHandler {
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
this.component.applyFormat('bold');
// Use Promise to ensure focus is maintained
Promise.resolve().then(() => this.component.applyFormat('bold'));
return true;
case 'i':
e.preventDefault();
this.component.applyFormat('italic');
Promise.resolve().then(() => this.component.applyFormat('italic'));
return true;
case 'u':
e.preventDefault();
this.component.applyFormat('underline');
Promise.resolve().then(() => this.component.applyFormat('underline'));
return true;
case 'k':
e.preventDefault();
this.component.applyFormat('link');
Promise.resolve().then(() => this.component.applyFormat('link'));
return true;
}
return false;
@ -79,7 +90,18 @@ export class WysiwygKeyboardHandler {
if (block.type === 'code') {
// Allow tab in code blocks
e.preventDefault();
document.execCommand('insertText', false, ' ');
// Insert two spaces for tab
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(' ');
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
}
} else if (block.type === 'list') {
// Future: implement list indentation
e.preventDefault();
@ -89,7 +111,7 @@ export class WysiwygKeyboardHandler {
/**
* Handles Enter key
*/
private handleEnter(e: KeyboardEvent, block: IBlock): void {
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
const blockOps = this.component.blockOperations;
if (block.type === 'code') {
@ -97,7 +119,7 @@ export class WysiwygKeyboardHandler {
// Shift+Enter in code blocks creates a new block
e.preventDefault();
const newBlock = blockOps.createBlock();
blockOps.insertBlockAfter(block, newBlock);
await blockOps.insertBlockAfter(block, newBlock);
}
// Normal Enter in code blocks creates new line (let browser handle it)
return;
@ -105,12 +127,42 @@ export class WysiwygKeyboardHandler {
if (!e.shiftKey) {
if (block.type === 'list') {
this.handleEnterInList(e, block);
await this.handleEnterInList(e, block);
} else {
// Create new paragraph block
// Split content at cursor position
e.preventDefault();
const newBlock = blockOps.createBlock();
blockOps.insertBlockAfter(block, newBlock);
// Get the block component
const target = e.target as HTMLElement;
const blockWrapper = target.closest('.block-wrapper');
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)
@ -119,8 +171,7 @@ export class WysiwygKeyboardHandler {
/**
* Handles Enter key in list blocks
*/
private handleEnterInList(e: KeyboardEvent, block: IBlock): void {
const target = e.target as HTMLDivElement;
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
@ -132,7 +183,7 @@ export class WysiwygKeyboardHandler {
e.preventDefault();
const blockOps = this.component.blockOperations;
const newBlock = blockOps.createBlock();
blockOps.insertBlockAfter(block, newBlock);
await blockOps.insertBlockAfter(block, newBlock);
}
// Otherwise, let browser create new list item
}
@ -141,7 +192,7 @@ export class WysiwygKeyboardHandler {
/**
* Handles Backspace key
*/
private handleBackspace(e: KeyboardEvent, block: IBlock): void {
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
if (block.content === '' && this.component.blocks.length > 1) {
e.preventDefault();
const blockOps = this.component.blockOperations;
@ -150,49 +201,184 @@ export class WysiwygKeyboardHandler {
if (prevBlock) {
blockOps.removeBlock(block.id);
setTimeout(() => {
if (prevBlock.type !== 'divider') {
blockOps.focusBlock(prevBlock.id, 'end');
}
});
if (prevBlock.type !== 'divider') {
await blockOps.focusBlock(prevBlock.id, 'end');
}
}
}
}
/**
* Handles slash menu keyboard navigation
* Handles ArrowUp key - navigate to previous block if at beginning
*/
private handleSlashMenuKeyboard(e: KeyboardEvent): void {
const menuItems = this.component.getFilteredMenuItems();
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const target = e.target as HTMLElement;
switch(e.key) {
case 'ArrowDown':
// Check if cursor is at the beginning of the block
const isAtStart = range.startOffset === 0 && range.endOffset === 0;
if (isAtStart) {
const firstNode = target.firstChild;
const isReallyAtStart = !firstNode ||
(range.startContainer === firstNode && range.startOffset === 0) ||
(range.startContainer === target && range.startOffset === 0);
if (isReallyAtStart) {
e.preventDefault();
this.component.slashMenuSelectedIndex =
(this.component.slashMenuSelectedIndex + 1) % menuItems.length;
break;
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
case 'ArrowUp':
e.preventDefault();
this.component.slashMenuSelectedIndex =
this.component.slashMenuSelectedIndex === 0
? menuItems.length - 1
: this.component.slashMenuSelectedIndex - 1;
break;
case 'Enter':
e.preventDefault();
if (menuItems[this.component.slashMenuSelectedIndex]) {
this.component.insertBlock(
menuItems[this.component.slashMenuSelectedIndex].type as IBlock['type']
);
if (prevBlock && prevBlock.type !== 'divider') {
await blockOps.focusBlock(prevBlock.id, 'end');
}
break;
case 'Escape':
e.preventDefault();
this.component.closeSlashMenu();
break;
}
}
}
/**
* Handles ArrowDown key - navigate to next block if at end
*/
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const target = e.target as HTMLElement;
// Check if cursor is at the end of the block
const lastNode = target.lastChild;
// For different block types, check if we're at the end
let isAtEnd = false;
if (!lastNode) {
// Empty block
isAtEnd = true;
} else if (lastNode.nodeType === Node.TEXT_NODE) {
isAtEnd = range.endContainer === lastNode && range.endOffset === lastNode.textContent?.length;
} else if (block.type === 'list') {
// For lists, check if we're in the last item at the end
const lastLi = target.querySelector('li:last-child');
if (lastLi) {
const lastTextNode = this.getLastTextNode(lastLi);
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
range.endOffset === lastTextNode.textContent?.length;
}
} else {
// For other HTML content
const lastTextNode = this.getLastTextNode(target);
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
range.endOffset === lastTextNode.textContent?.length;
}
if (isAtEnd) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock && nextBlock.type !== 'divider') {
await blockOps.focusBlock(nextBlock.id, 'start');
}
}
}
/**
* 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> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
// Check if cursor is at the very beginning (collapsed and at offset 0)
if (range.collapsed && range.startOffset === 0) {
const target = e.target as HTMLElement;
const firstNode = target.firstChild;
// Verify we're really at the start
const isAtStart = !firstNode ||
(range.startContainer === firstNode) ||
(range.startContainer === target);
if (isAtStart) {
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock && prevBlock.type !== 'divider') {
e.preventDefault();
await blockOps.focusBlock(prevBlock.id, 'end');
}
}
}
// 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> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const target = e.target as HTMLElement;
// Check if cursor is at the very end
if (range.collapsed) {
const textLength = target.textContent?.length || 0;
let isAtEnd = false;
if (textLength === 0) {
// Empty block
isAtEnd = true;
} else if (range.endContainer.nodeType === Node.TEXT_NODE) {
const textNode = range.endContainer as Text;
isAtEnd = range.endOffset === textNode.textContent?.length;
} else {
// Check if we're at the end of the last text node
const lastTextNode = this.getLastTextNode(target);
if (lastTextNode) {
isAtEnd = range.endContainer === lastTextNode &&
range.endOffset === lastTextNode.textContent?.length;
}
}
if (isAtEnd) {
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock && nextBlock.type !== 'divider') {
e.preventDefault();
await blockOps.focusBlock(nextBlock.id, 'start');
}
}
}
// Otherwise, let the browser handle normal right arrow navigation
}
/**
* Handles slash menu keyboard navigation
* Note: This is now handled by the component directly
*/
}

View File

@ -1,6 +1,7 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import { DeesModal } from '../dees-modal.js';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
export class WysiwygModalManager {
/**
@ -74,13 +75,55 @@ export class WysiwygModalManager {
block: IBlock,
onUpdate: (block: IBlock) => void
): Promise<void> {
let content: TemplateResult;
if (block.type === 'code') {
content = this.getCodeBlockSettings(block, onUpdate);
} else {
content = html`<div style="padding: 16px;">No settings available for this block type.</div>`;
}
const content = html`
<style>
.settings-container {
padding: 16px;
}
.settings-section {
margin-bottom: 20px;
}
.settings-label {
font-weight: 500;
margin-bottom: 8px;
color: var(--dees-color-text);
}
.block-type-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.block-type-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;
display: flex;
align-items: center;
gap: 8px;
}
.block-type-button:hover {
background: var(--dees-color-box-highlight);
border-color: var(--dees-color-primary);
}
.block-type-button.selected {
background: var(--dees-color-primary);
color: white;
}
.block-type-icon {
font-weight: 600;
font-size: 16px;
}
</style>
<div class="settings-container">
${this.getBlockTypeSelector(block, onUpdate)}
${block.type === 'code' ? this.getCodeBlockSettings(block, onUpdate) : ''}
</div>
`;
DeesModal.createAndShow({
heading: 'Block Settings',
@ -170,4 +213,62 @@ export class WysiwygModalManager {
'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'
];
}
/**
* 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 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 after selection
const modal = (e.target as HTMLElement).closest('dees-modal');
if (modal) {
const closeButton = modal.shadowRoot?.querySelector('.bottomButton') as HTMLElement;
if (closeButton) closeButton.click();
}
}}"
>
<span class="block-type-icon">${item.icon}</span>
<span>${item.label}</span>
</div>
`)}
</div>
</div>
`;
}
}

View File

@ -248,6 +248,8 @@ export const wysiwygStyles = css`
min-width: 220px;
max-height: 300px;
overflow-y: auto;
pointer-events: auto;
user-select: none;
}
.slash-menu-item {