fix(wysiwyg):Improve Wysiwyg editor
This commit is contained in:
@ -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
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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
|
@ -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;
|
||||
}
|
||||
|
||||
|
27
ts_web/elements/wysiwyg/wysiwyg.constants.ts
Normal file
27
ts_web/elements/wysiwyg/wysiwyg.constants.ts
Normal 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];
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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() : '';
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
157
ts_web/elements/wysiwyg/wysiwyg.selection.ts
Normal file
157
ts_web/elements/wysiwyg/wysiwyg.selection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user