fix(wysiwyg):Improve Wysiwyg editor

This commit is contained in:
Juergen Kunz
2025-06-24 13:41:12 +00:00
parent 08a4c361fa
commit 4b2178cedd
13 changed files with 581 additions and 349 deletions

View File

@ -6,7 +6,7 @@ import {
customElement,
type TemplateResult,
property,
html,
static as html,
cssManager,
state,
} from '@design.estate/dees-element';
@ -44,7 +44,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
public outputFormat: OutputFormat = 'html';
@state()
private blocks: IBlock[] = [
public blocks: IBlock[] = [
{
id: WysiwygShortcuts.generateBlockId(),
type: 'paragraph',
@ -53,38 +53,42 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
];
// Not using @state to avoid re-renders when selection changes
private selectedBlockId: string | null = null;
public selectedBlockId: string | null = null;
// Slash menu is now globally rendered
private slashMenu = DeesSlashMenu.getInstance();
public slashMenu = DeesSlashMenu.getInstance();
@state()
private draggedBlockId: string | null = null;
public draggedBlockId: string | null = null;
@state()
private dragOverBlockId: string | null = null;
public dragOverBlockId: string | null = null;
@state()
private dragOverPosition: 'before' | 'after' | null = null;
public dragOverPosition: 'before' | 'after' | null = null;
// Formatting menu is now globally rendered
private formattingMenu = DeesFormattingMenu.getInstance();
public formattingMenu = DeesFormattingMenu.getInstance();
@state()
private selectedText: string = '';
private editorContentRef: HTMLDivElement;
private isComposing: boolean = false;
private selectionChangeHandler = () => this.handleSelectionChange();
public editorContentRef: HTMLDivElement;
public isComposing: boolean = false;
private selectionChangeTimeout: any;
private selectionChangeHandler = () => {
// Throttle selection change events
if (this.selectionChangeTimeout) {
clearTimeout(this.selectionChangeTimeout);
}
this.selectionChangeTimeout = setTimeout(() => this.handleSelectionChange(), 50);
};
// Handler instances
private blockOperations: WysiwygBlockOperations;
public blockOperations: WysiwygBlockOperations;
private inputHandler: WysiwygInputHandler;
private keyboardHandler: WysiwygKeyboardHandler;
private dragDropHandler: WysiwygDragDropHandler;
// Content cache to avoid triggering re-renders during typing
private contentCache: Map<string, string> = new Map();
public static styles = [
...DeesInputBase.baseStyles,
@ -116,6 +120,11 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
clearTimeout(this.blurTimeout);
this.blurTimeout = null;
}
// Clean up selection change timeout
if (this.selectionChangeTimeout) {
clearTimeout(this.selectionChangeTimeout);
this.selectionChangeTimeout = null;
}
}
async firstUpdated() {
@ -141,7 +150,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
/**
* Renders all blocks programmatically without triggering re-renders
*/
private renderBlocksProgrammatically() {
public renderBlocksProgrammatically() {
if (!this.editorContentRef) return;
// Clear existing blocks
@ -157,7 +166,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
/**
* Creates a block element programmatically
*/
private createBlockElement(block: IBlock): HTMLElement {
public createBlockElement(block: IBlock): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'block-wrapper';
wrapper.setAttribute('data-block-id', block.id);
@ -200,7 +209,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
settings.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
WysiwygModalManager.showBlockSettingsModal(block, (updatedBlock) => {
WysiwygModalManager.showBlockSettingsModal(block, () => {
this.updateValue();
// Re-render only the updated block
this.updateBlockElement(block.id);
@ -220,7 +229,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
/**
* Updates a specific block element
*/
private updateBlockElement(blockId: string) {
public updateBlockElement(blockId: string) {
const block = this.blocks.find(b => b.id === blockId);
if (!block) return;
@ -257,7 +266,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
private handleSlashMenuKeyboard(e: KeyboardEvent) {
public handleSlashMenuKeyboard(e: KeyboardEvent) {
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
@ -306,39 +315,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
this.slashMenu.hide();
}
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) {
// Clear any pending blur timeout when focusing
if (this.blurTimeout) {
@ -533,7 +509,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}
}
private updateValue() {
public updateValue() {
if (this.outputFormat === 'html') {
this.value = WysiwygConverters.getHtmlOutput(this.blocks);
} else {
@ -625,26 +601,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
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 dragging class to the wrapper
setTimeout(() => {
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
if (wrapper) {
wrapper.classList.add('dragging');
}
// Add dragging class to editor content
this.editorContentRef.classList.add('dragging');
}, 10);
}
private handleDragEnd(): void {
// Remove all drag-related classes
if (this.draggedBlockId) {
@ -668,44 +624,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
this.dragOverPosition = null;
}
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;
// Remove previous drag-over classes
if (this.dragOverBlockId) {
const prevWrapper = this.editorContentRef.querySelector(`[data-block-id="${this.dragOverBlockId}"]`);
if (prevWrapper) {
prevWrapper.classList.remove('drag-over-before', 'drag-over-after');
}
}
this.dragOverBlockId = block.id;
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
// Add new drag-over class
const wrapper = e.currentTarget as HTMLElement;
wrapper.classList.add(`drag-over-${this.dragOverPosition}`);
}
private handleDragLeave(block: IBlock): void {
if (this.dragOverBlockId === block.id) {
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
if (wrapper) {
wrapper.classList.remove('drag-over-before', 'drag-over-after');
}
this.dragOverBlockId = null;
this.dragOverPosition = null;
}
}
private handleDrop(e: DragEvent, targetBlock: IBlock): void {
public handleDrop(e: DragEvent, targetBlock: IBlock): void {
e.preventDefault();
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
@ -746,7 +665,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}
private handleTextSelection(e: MouseEvent): void {
private handleTextSelection(_e: MouseEvent): void {
// Don't interfere with slash menu
if (this.slashMenu.visible) return;
@ -960,69 +879,4 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}, 100);
});
}
// Modal methods moved to WysiwygModalManager
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);
}
}
]
});
});
}
// Modal methods have been moved to WysiwygModalManager
}

View File

@ -1,18 +1,17 @@
import {
customElement,
property,
html,
static as 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';
import { WysiwygSelection } from './wysiwyg.selection.js';
declare global {
interface HTMLElementTagNameMap {
@ -271,10 +270,71 @@ export class DeesWysiwygBlock extends DeesElement {
// Mark that content has been initialized
this.contentInitialized = true;
// For code blocks, the actual contenteditable block is nested
const editableBlock = this.block.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
// Ensure the block element maintains its content
if (this.blockElement) {
this.blockElement.setAttribute('data-block-id', this.block.id);
this.blockElement.setAttribute('data-block-type', this.block.type);
if (editableBlock) {
editableBlock.setAttribute('data-block-id', this.block.id);
editableBlock.setAttribute('data-block-type', this.block.type);
// Set up all event handlers manually to avoid Lit re-renders
editableBlock.addEventListener('input', (e) => {
this.logCursorPosition('input');
this.handlers?.onInput?.(e as InputEvent);
});
editableBlock.addEventListener('keydown', (e) => {
this.handlers?.onKeyDown?.(e);
});
editableBlock.addEventListener('keyup', (e) => {
this.logCursorPosition('keyup', e);
});
editableBlock.addEventListener('focus', () => {
this.handlers?.onFocus?.();
});
editableBlock.addEventListener('blur', () => {
this.handlers?.onBlur?.();
});
editableBlock.addEventListener('compositionstart', () => {
this.handlers?.onCompositionStart?.();
});
editableBlock.addEventListener('compositionend', () => {
this.handlers?.onCompositionEnd?.();
});
editableBlock.addEventListener('mouseup', (e) => {
this.logCursorPosition('mouseup');
this.handleMouseUp(e);
this.handlers?.onMouseUp?.(e);
});
editableBlock.addEventListener('click', () => {
this.logCursorPosition('click');
});
// Set initial content if needed
if (this.block.content) {
if (this.block.type === 'code') {
editableBlock.textContent = this.block.content;
} else if (this.block.type === 'list') {
editableBlock.innerHTML = WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
} else {
editableBlock.innerHTML = this.block.content;
}
}
}
// Update blockElement reference for code blocks
if (this.block.type === 'code') {
this.blockElement = editableBlock;
}
}
@ -298,41 +358,20 @@ export class DeesWysiwygBlock extends DeesElement {
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`
// Return static HTML without event bindings
return html`
<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>
></div>
`;
}
@ -353,12 +392,6 @@ export class DeesWysiwygBlock extends DeesElement {
}
}
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;
@ -391,34 +424,13 @@ export class DeesWysiwygBlock extends DeesElement {
// 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);
}
// Use the new selection utility to set cursor position
WysiwygSelection.setCursorPosition(this.blockElement, position);
}
};
@ -501,47 +513,121 @@ export class DeesWysiwygBlock extends DeesElement {
public getSplitContent(): { before: string; after: string } | null {
if (!this.blockElement) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
// Get the full content first
const fullContent = this.getContent();
console.log('getSplitContent: Full content:', {
content: fullContent,
length: fullContent.length,
blockType: this.block.type
});
// Get selection info using the new utility that handles Shadow DOM
const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!);
if (!selectionInfo) {
console.log('getSplitContent: No selection, returning all content as before');
return {
before: this.getContent(),
before: fullContent,
after: ''
};
}
const range = selection.getRangeAt(0);
// Check if selection is within this block
if (!this.blockElement.contains(range.commonAncestorContainer)) {
if (!WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!)) {
console.log('getSplitContent: Selection not in this block');
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);
// Get cursor position as a number
const cursorPosition = WysiwygSelection.getCursorPositionInElement(this.blockElement, this.shadowRoot!);
console.log('getSplitContent: Cursor position:', {
cursorPosition,
contentLength: fullContent.length,
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
collapsed: selectionInfo.collapsed
});
const afterRange = range.cloneRange();
afterRange.selectNodeContents(this.blockElement);
afterRange.setStart(range.endContainer, range.endOffset);
// Handle special cases for different block types
if (this.block.type === 'code') {
// For code blocks, split text content
const fullText = this.blockElement.textContent || '';
const textNode = this.getFirstTextNode(this.blockElement);
if (textNode && selectionInfo.startContainer === textNode) {
const before = fullText.substring(0, selectionInfo.startOffset);
const after = fullText.substring(selectionInfo.startOffset);
console.log('getSplitContent: Code block split result:', {
cursorPosition,
contentLength: fullText.length,
beforeContent: before,
beforeLength: before.length,
afterContent: after,
afterLength: after.length,
startOffset: selectionInfo.startOffset
});
return { before, after };
}
}
// 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
};
// For other block types, extract HTML content
try {
// Create a temporary range to get content before cursor
const beforeRange = document.createRange();
beforeRange.selectNodeContents(this.blockElement);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Create a temporary range to get content after cursor
const afterRange = document.createRange();
afterRange.selectNodeContents(this.blockElement);
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
// Clone HTML content (not extract, to avoid modifying the DOM)
const beforeContents = beforeRange.cloneContents();
const afterContents = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeContents);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterContents);
const afterHtml = tempDiv.innerHTML;
const result = {
before: beforeHtml,
after: afterHtml
};
console.log('getSplitContent: Split result:', {
cursorPosition,
contentLength: fullContent.length,
beforeContent: result.before,
beforeLength: result.before.length,
afterContent: result.after,
afterLength: result.after.length
});
return result;
} catch (error) {
console.error('Error splitting content:', error);
// Fallback: return all content as "before"
const fallbackResult = {
before: this.getContent(),
after: ''
};
console.log('getSplitContent: Fallback result:', {
beforeContent: fallbackResult.before,
beforeLength: fallbackResult.before.length,
afterContent: fallbackResult.after,
afterLength: fallbackResult.after.length
});
return fallbackResult;
}
}
private handleMouseUp(_e: MouseEvent): void {
@ -570,4 +656,117 @@ export class DeesWysiwygBlock extends DeesElement {
}
}, 10);
}
/**
* Logs cursor position for debugging
*/
private logCursorPosition(eventType: string, event?: KeyboardEvent): void {
console.log(`[CursorLog] Event triggered: ${eventType} in block ${this.block.id}`);
// Get the actual active element considering shadow DOM
const activeElement = this.shadowRoot?.activeElement;
console.log(`[CursorLog] Active element:`, activeElement, 'Block element:', this.blockElement);
// Only log if this block is focused
if (activeElement !== this.blockElement) {
console.log(`[CursorLog] Block not focused, skipping detailed logging`);
return;
}
// Get selection info using the new utility that handles Shadow DOM
const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!);
if (!selectionInfo) {
console.log(`[${eventType}] No selection available`);
return;
}
const isInThisBlock = WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!);
if (!isInThisBlock) {
return;
}
// Get cursor position details
const details: any = {
event: eventType,
blockId: this.block.id,
blockType: this.block.type,
collapsed: selectionInfo.collapsed,
startContainer: {
nodeType: selectionInfo.startContainer.nodeType,
nodeName: selectionInfo.startContainer.nodeName,
textContent: selectionInfo.startContainer.textContent?.substring(0, 50) + '...',
},
startOffset: selectionInfo.startOffset,
};
// Add key info if it's a keyboard event
if (event) {
details.key = event.key;
details.shiftKey = event.shiftKey;
details.ctrlKey = event.ctrlKey;
details.metaKey = event.metaKey;
}
// Try to get the actual cursor position in the text
if (selectionInfo.startContainer.nodeType === Node.TEXT_NODE) {
const textNode = selectionInfo.startContainer as Text;
const textBefore = textNode.textContent?.substring(0, selectionInfo.startOffset) || '';
const textAfter = textNode.textContent?.substring(selectionInfo.startOffset) || '';
details.cursorPosition = {
textBefore: textBefore.slice(-20), // Last 20 chars before cursor
textAfter: textAfter.slice(0, 20), // First 20 chars after cursor
totalLength: textNode.textContent?.length || 0,
offset: selectionInfo.startOffset
};
}
// Check if we're at boundaries
details.boundaries = {
atStart: this.isCursorAtStart(selectionInfo),
atEnd: this.isCursorAtEnd(selectionInfo)
};
console.log('Cursor Position:', details);
}
/**
* Check if cursor is at the start of the block
*/
private isCursorAtStart(selectionInfo: { startContainer: Node; startOffset: number; collapsed: boolean }): boolean {
if (!selectionInfo.collapsed || selectionInfo.startOffset !== 0) return false;
const firstNode = this.getFirstTextNode(this.blockElement);
return !firstNode || selectionInfo.startContainer === firstNode || selectionInfo.startContainer === this.blockElement;
}
/**
* Check if cursor is at the end of the block
*/
private isCursorAtEnd(selectionInfo: { endContainer: Node; endOffset: number; collapsed: boolean }): boolean {
if (!selectionInfo.collapsed) return false;
const lastNode = this.getLastTextNode(this.blockElement);
if (!lastNode) return true;
return selectionInfo.endContainer === lastNode &&
selectionInfo.endOffset === (lastNode.textContent?.length || 0);
}
/**
* Get the last text node in the element
*/
private getLastTextNode(node: Node): Text | null {
if (node.nodeType === Node.TEXT_NODE) {
return node as Text;
}
for (let i = node.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(node.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
}

View File

@ -1,10 +1,12 @@
export * from './wysiwyg.types.js';
export * from './wysiwyg.interfaces.js';
export * from './wysiwyg.constants.js';
export * from './wysiwyg.styles.js';
export * from './wysiwyg.converters.js';
export * from './wysiwyg.shortcuts.js';
export * from './wysiwyg.blocks.js';
export * from './wysiwyg.formatting.js';
export * from './wysiwyg.selection.js';
export * from './wysiwyg.blockoperations.js';
export * from './wysiwyg.inputhandler.js';
export * from './wysiwyg.keyboardhandler.js';

View File

@ -1,4 +1,5 @@
* We don't use lit template logic, but use static`` here to handle dom operations ourselves
* We don't use lit html logic, no event binding, no nothing, but only use static`` here to handle dom operations ourselves
* We try to have separated concerns in different classes
* We try to have clean concise and managable code
* lets log whats happening, so if something goes wrong, we understand whats happening.
* lets log whats happening, so if something goes wrong, we understand whats happening.
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges

View File

@ -1,11 +1,12 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
export class WysiwygBlockOperations {
private component: any; // Will be typed properly when imported
private component: IWysiwygComponent;
constructor(component: any) {
constructor(component: IWysiwygComponent) {
this.component = component;
}

View File

@ -0,0 +1,27 @@
/**
* Shared constants for the WYSIWYG editor
*/
/**
* Available programming languages for code blocks
*/
export const PROGRAMMING_LANGUAGES = [
'JavaScript',
'TypeScript',
'Python',
'Java',
'C++',
'C#',
'Go',
'Rust',
'HTML',
'CSS',
'SQL',
'Shell',
'JSON',
'YAML',
'Markdown',
'Plain Text'
] as const;
export type ProgrammingLanguage = typeof PROGRAMMING_LANGUAGES[number];

View File

@ -1,7 +1,8 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
export class WysiwygDragDropHandler {
private component: any;
private component: IWysiwygComponent;
private draggedBlockId: string | null = null;
private dragOverBlockId: string | null = null;
private dragOverPosition: 'before' | 'after' | null = null;
@ -13,7 +14,7 @@ export class WysiwygDragDropHandler {
private lastUpdateTime: number = 0;
private updateThrottle: number = 80; // milliseconds
constructor(component: any) {
constructor(component: IWysiwygComponent) {
this.component = component;
}

View File

@ -1,4 +1,5 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import { WysiwygSelection } from './wysiwyg.selection.js';
export interface IFormatButton {
command: string;
@ -143,20 +144,20 @@ export class WysiwygFormatting {
}
static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null {
// Try shadow root selection first, then window
let selection = shadowRoot && (shadowRoot as any).getSelection ? (shadowRoot as any).getSelection() : null;
if (!selection || selection.rangeCount === 0) {
selection = window.getSelection();
}
// Get selection info using the new utility that handles Shadow DOM
const selectionInfo = shadowRoot
? WysiwygSelection.getSelectionInfo(shadowRoot)
: WysiwygSelection.getSelectionInfo();
console.log('getSelectionCoordinates - selection:', selection);
console.log('getSelectionCoordinates - selectionInfo:', selectionInfo);
if (!selection || selection.rangeCount === 0) {
console.log('No selection or no ranges');
if (!selectionInfo) {
console.log('No selection info available');
return null;
}
const range = selection.getRangeAt(0);
// Create a range from the selection info to get bounding rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
console.log('Range rect:', rect);
@ -174,57 +175,4 @@ export class WysiwygFormatting {
console.log('Returning coords:', coords);
return coords;
}
static isFormattingApplied(command: string): boolean {
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;
}
}
static hasSelection(): boolean {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
console.log('No selection or no ranges');
return false;
}
// Check if we actually have selected text (not just collapsed cursor)
const selectedText = selection.toString();
if (!selectedText || selectedText.length === 0) {
console.log('No text selected');
return false;
}
return true;
}
static getSelectedText(): string {
const selection = window.getSelection();
return selection ? selection.toString() : '';
}
}

View File

@ -1,14 +1,15 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
import { WysiwygModalManager } from './wysiwyg.modalmanager.js';
export class WysiwygInputHandler {
private component: any;
private component: IWysiwygComponent;
private saveTimeout: any = null;
constructor(component: any) {
constructor(component: IWysiwygComponent) {
this.component = component;
}
@ -174,6 +175,11 @@ export class WysiwygInputHandler {
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
// Focus the code block
setTimeout(async () => {
await blockOps.focusBlock(block.id, 'start');
}, 50);
}
} else {
block.type = detectedType.type;
@ -186,6 +192,11 @@ export class WysiwygInputHandler {
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
// Focus the transformed block
setTimeout(async () => {
await blockOps.focusBlock(block.id, 'start');
}, 50);
}
}

View File

@ -11,6 +11,11 @@ export interface IWysiwygComponent {
blocks: IBlock[];
selectedBlockId: string | null;
shadowRoot: ShadowRoot | null;
editorContentRef: HTMLDivElement;
draggedBlockId: string | null;
dragOverBlockId: string | null;
dragOverPosition: 'before' | 'after' | null;
isComposing: boolean;
// Menus
slashMenu: DeesSlashMenu;
@ -18,12 +23,16 @@ export interface IWysiwygComponent {
// Methods
updateValue(): void;
requestUpdate(): Promise<void>;
requestUpdate(): void;
updateComplete: Promise<boolean>;
insertBlock(type: string): Promise<void>;
closeSlashMenu(clearSlash?: boolean): void;
applyFormat(command: string): Promise<void>;
handleSlashMenuKeyboard(e: KeyboardEvent): void;
createBlockElement(block: IBlock): HTMLElement;
updateBlockElement(blockId: string): void;
handleDrop(e: DragEvent, targetBlock: IBlock): void;
renderBlocksProgrammatically(): void;
// Handlers
blockOperations: IBlockOperations;
@ -44,7 +53,6 @@ export interface IBlockOperations {
moveBlock(blockId: string, targetIndex: number): void;
getPreviousBlock(blockId: string): IBlock | null;
getNextBlock(blockId: string): IBlock | null;
splitBlock(blockId: string, splitPosition: number): Promise<IBlock>;
}
/**

View File

@ -1,9 +1,10 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
export class WysiwygKeyboardHandler {
private component: any;
private component: IWysiwygComponent;
constructor(component: any) {
constructor(component: IWysiwygComponent) {
this.component = component;
}
@ -132,34 +133,59 @@ export class WysiwygKeyboardHandler {
// Split content at cursor position
e.preventDefault();
// Get the block component
const target = e.target as HTMLElement;
const blockWrapper = target.closest('.block-wrapper');
console.log('Enter key pressed in block:', {
blockId: block.id,
blockType: block.type,
blockContent: block.content,
blockContentLength: block.content?.length || 0,
eventTarget: e.target,
eventTargetTagName: (e.target as HTMLElement).tagName
});
// Get the block component - need to search in the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
console.log('Found block wrapper:', blockWrapper);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
console.log('Found block component:', blockComponent, 'has getSplitContent:', !!blockComponent?.getSplitContent);
if (blockComponent && blockComponent.getSplitContent) {
console.log('Calling getSplitContent...');
const splitContent = blockComponent.getSplitContent();
console.log('Enter key split content result:', {
hasSplitContent: !!splitContent,
beforeLength: splitContent?.before?.length || 0,
afterLength: splitContent?.after?.length || 0,
splitContent
});
if (splitContent) {
console.log('Updating current block with before content...');
// Update current block with content before cursor
blockComponent.setContent(splitContent.before);
block.content = splitContent.before;
console.log('Creating new block with after content...');
// Create new block with content after cursor
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
console.log('Inserting new block...');
// Insert the new block
await blockOps.insertBlockAfter(block, newBlock);
// Update the value after both blocks are set
this.component.updateValue();
console.log('Enter key handling complete');
} else {
// Fallback - just create empty block
console.log('No split content returned, creating empty block');
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
} else {
// No block component or method, just create empty block
console.log('No getSplitContent method, creating empty block');
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}

View File

@ -2,6 +2,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';
import { PROGRAMMING_LANGUAGES } from './wysiwyg.constants.js';
export class WysiwygModalManager {
/**
@ -207,11 +208,7 @@ export class WysiwygModalManager {
* Gets available programming languages
*/
private static getLanguages(): string[] {
return [
'JavaScript', 'TypeScript', 'Python', 'Java',
'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS',
'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'
];
return [...PROGRAMMING_LANGUAGES];
}
/**

View File

@ -0,0 +1,157 @@
/**
* Utilities for handling selection across Shadow DOM boundaries
*/
export interface SelectionInfo {
startContainer: Node;
startOffset: number;
endContainer: Node;
endOffset: number;
collapsed: boolean;
}
export class WysiwygSelection {
/**
* Gets selection info that works across Shadow DOM boundaries
* @param shadowRoots - Shadow roots to include in the selection search
*/
static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null {
const selection = window.getSelection();
if (!selection) return null;
// Try using getComposedRanges if available (better Shadow DOM support)
if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
try {
const ranges = selection.getComposedRanges(...shadowRoots);
if (ranges.length > 0) {
const range = ranges[0];
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
collapsed: range.collapsed
};
}
} catch (error) {
console.warn('getComposedRanges failed, falling back to getRangeAt:', error);
}
}
// Fallback to traditional selection API
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
collapsed: range.collapsed
};
}
return null;
}
/**
* Checks if a selection is within a specific element (considering Shadow DOM)
*/
static isSelectionInElement(element: Element, shadowRoot?: ShadowRoot): boolean {
const selectionInfo = shadowRoot
? this.getSelectionInfo(shadowRoot)
: this.getSelectionInfo();
if (!selectionInfo) return false;
// Check if the selection's common ancestor is within the element
return element.contains(selectionInfo.startContainer) ||
element.contains(selectionInfo.endContainer);
}
/**
* Gets the selected text across Shadow DOM boundaries
*/
static getSelectedText(): string {
const selection = window.getSelection();
return selection ? selection.toString() : '';
}
/**
* Creates a range from selection info
*/
static createRangeFromInfo(info: SelectionInfo): Range {
const range = document.createRange();
range.setStart(info.startContainer, info.startOffset);
range.setEnd(info.endContainer, info.endOffset);
return range;
}
/**
* Sets selection from a range (works with Shadow DOM)
*/
static setSelectionFromRange(range: Range): void {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
/**
* Gets cursor position relative to a specific element
*/
static getCursorPositionInElement(element: Element, shadowRoot?: ShadowRoot): number | null {
const selectionInfo = shadowRoot
? this.getSelectionInfo(shadowRoot)
: this.getSelectionInfo();
if (!selectionInfo || !selectionInfo.collapsed) return null;
// Create a range from start of element to cursor position
try {
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
return range.toString().length;
} catch (error) {
console.warn('Failed to get cursor position:', error);
return null;
}
}
/**
* Sets cursor position in an element
*/
static setCursorPosition(element: Element, position: number): void {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null
);
let currentPosition = 0;
let targetNode: Text | null = null;
let targetOffset = 0;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const nodeLength = node.textContent?.length || 0;
if (currentPosition + nodeLength >= position) {
targetNode = node;
targetOffset = position - currentPosition;
break;
}
currentPosition += nodeLength;
}
if (targetNode) {
const range = document.createRange();
range.setStart(targetNode, targetOffset);
range.collapse(true);
this.setSelectionFromRange(range);
}
}
}