Files
dees-catalog/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts

856 lines
26 KiB
TypeScript
Raw Normal View History

2025-06-24 10:45:06 +00:00
import {
customElement,
property,
2025-06-24 13:41:12 +00:00
static as html,
2025-06-24 10:45:06 +00:00
DeesElement,
type TemplateResult,
cssManager,
css,
query,
} from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
2025-06-24 13:41:12 +00:00
import { WysiwygSelection } from './wysiwyg.selection.js';
2025-06-24 10:45:06 +00:00
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;
// Track if we've initialized the content
private contentInitialized: boolean = false;
2025-06-24 10:45:06 +00:00
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(changedProperties: Map<string, any>): boolean {
// Never update if only the block content changed
if (changedProperties.has('block') && this.block) {
const oldBlock = changedProperties.get('block');
if (oldBlock && oldBlock.id === this.block.id && oldBlock.type === this.block.type) {
// Only content or metadata changed, don't re-render
return false;
}
}
2025-06-24 10:45:06 +00:00
// Only update if the block type or id changes
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
}
public firstUpdated(): void {
// Mark that content has been initialized
this.contentInitialized = true;
2025-06-24 13:41:12 +00:00
// 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
2025-06-24 13:41:12 +00:00
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;
}
}
}
2025-06-24 13:53:47 +00:00
// For code blocks, we use the nested editableBlock
// The blockElement getter will automatically find the right element
}
2025-06-24 10:45:06 +00:00
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}"
></div>
</div>
`;
}
const placeholder = this.getPlaceholder();
2025-06-24 13:41:12 +00:00
// Return static HTML without event bindings
return html`
2025-06-24 10:45:06 +00:00
<div
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
contenteditable="true"
data-placeholder="${placeholder}"
2025-06-24 13:41:12 +00:00
></div>
2025-06-24 10:45:06 +00:00
`;
}
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 '';
}
}
public focus(): void {
2025-06-24 13:53:47 +00:00
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (!editableElement) return;
2025-06-24 10:45:06 +00:00
// Ensure the element is focusable
2025-06-24 13:53:47 +00:00
if (!editableElement.hasAttribute('contenteditable')) {
editableElement.setAttribute('contenteditable', 'true');
2025-06-24 10:45:06 +00:00
}
2025-06-24 13:53:47 +00:00
editableElement.focus();
2025-06-24 10:45:06 +00:00
// If focus failed, try again after a microtask
2025-06-24 13:53:47 +00:00
if (document.activeElement !== editableElement && this.shadowRoot?.activeElement !== editableElement) {
2025-06-24 10:45:06 +00:00
Promise.resolve().then(() => {
2025-06-24 13:53:47 +00:00
editableElement.focus();
2025-06-24 10:45:06 +00:00
});
}
}
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
2025-06-24 13:53:47 +00:00
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (!editableElement) return;
2025-06-24 10:45:06 +00:00
// Ensure element is focusable first
2025-06-24 13:53:47 +00:00
if (!editableElement.hasAttribute('contenteditable')) {
editableElement.setAttribute('contenteditable', 'true');
2025-06-24 10:45:06 +00:00
}
// Focus the element
2025-06-24 13:53:47 +00:00
editableElement.focus();
2025-06-24 10:45:06 +00:00
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart();
} else if (position === 'end') {
this.setCursorToEnd();
} else if (typeof position === 'number') {
2025-06-24 13:41:12 +00:00
// Use the new selection utility to set cursor position
2025-06-24 13:53:47 +00:00
WysiwygSelection.setCursorPosition(editableElement, position);
2025-06-24 10:45:06 +00:00
}
};
// Ensure cursor is set after focus
2025-06-24 13:53:47 +00:00
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
2025-06-24 10:45:06 +00:00
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
2025-06-24 13:53:47 +00:00
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
2025-06-24 10:45:06 +00:00
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 {
2025-06-24 13:53:47 +00:00
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (!editableElement) return '';
2025-06-24 10:45:06 +00:00
if (this.block.type === 'list') {
2025-06-24 13:53:47 +00:00
const listItems = editableElement.querySelectorAll('li');
2025-06-24 10:45:06 +00:00
return Array.from(listItems).map(li => li.innerHTML || '').join('\n');
} else if (this.block.type === 'code') {
2025-06-24 13:53:47 +00:00
return editableElement.textContent || '';
2025-06-24 10:45:06 +00:00
} else {
2025-06-24 13:53:47 +00:00
return editableElement.innerHTML || '';
2025-06-24 10:45:06 +00:00
}
}
public setContent(content: string): void {
2025-06-24 13:53:47 +00:00
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (!editableElement) return;
2025-06-24 10:45:06 +00:00
// Store if we have focus
2025-06-24 13:53:47 +00:00
const hadFocus = document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement;
2025-06-24 10:45:06 +00:00
if (this.block.type === 'list') {
2025-06-24 13:53:47 +00:00
editableElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata);
2025-06-24 10:45:06 +00:00
} else if (this.block.type === 'code') {
2025-06-24 13:53:47 +00:00
editableElement.textContent = content;
2025-06-24 10:45:06 +00:00
} else {
2025-06-24 13:53:47 +00:00
editableElement.innerHTML = content;
2025-06-24 10:45:06 +00:00
}
// Restore focus if we had it
if (hadFocus) {
2025-06-24 13:53:47 +00:00
editableElement.focus();
2025-06-24 10:45:06 +00:00
}
}
public setCursorToStart(): void {
2025-06-24 13:53:47 +00:00
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (editableElement) {
WysiwygBlocks.setCursorToStart(editableElement);
}
2025-06-24 10:45:06 +00:00
}
public setCursorToEnd(): void {
2025-06-24 13:53:47 +00:00
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (editableElement) {
WysiwygBlocks.setCursorToEnd(editableElement);
}
2025-06-24 10:45:06 +00:00
}
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;
2025-06-24 13:41:12 +00:00
// Get the full content first
const fullContent = this.getContent();
console.log('getSplitContent: Full content:', {
content: fullContent,
length: fullContent.length,
blockType: this.block.type
});
2025-06-24 13:53:47 +00:00
// Direct approach: Get selection from window
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
console.log('getSplitContent: No selection found');
2025-06-24 10:45:06 +00:00
return {
2025-06-24 13:41:12 +00:00
before: fullContent,
2025-06-24 10:45:06 +00:00
after: ''
};
}
2025-06-24 13:53:47 +00:00
const range = selection.getRangeAt(0);
console.log('getSplitContent: Range info:', {
startContainer: range.startContainer,
startOffset: range.startOffset,
collapsed: range.collapsed,
startContainerType: range.startContainer.nodeType,
startContainerText: range.startContainer.textContent?.substring(0, 50)
});
// Check if this block element has focus or contains the selection
const activeElement = this.shadowRoot?.activeElement || document.activeElement;
const hasFocus = this.blockElement === activeElement || this.blockElement?.contains(activeElement as Node);
// For contenteditable, check if selection is in our shadow DOM
let selectionInThisBlock = false;
try {
// Walk up from the selection to see if we reach our block element
let node: Node | null = range.startContainer;
while (node) {
if (node === this.blockElement || node === this.shadowRoot) {
selectionInThisBlock = true;
break;
}
node = node.parentNode || (node as any).host; // Check shadow host too
}
} catch (e) {
console.log('Error checking selection ancestry:', e);
2025-06-24 10:45:06 +00:00
}
2025-06-24 13:53:47 +00:00
console.log('getSplitContent: Focus check:', {
hasFocus,
selectionInThisBlock,
activeElement,
blockElement: this.blockElement
2025-06-24 13:41:12 +00:00
});
2025-06-24 10:45:06 +00:00
2025-06-24 13:53:47 +00:00
if (!hasFocus && !selectionInThisBlock) {
console.log('getSplitContent: Block does not have focus/selection');
return null;
}
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (!editableElement) {
console.log('getSplitContent: No editable element found');
return null;
}
2025-06-24 13:41:12 +00:00
// Handle special cases for different block types
if (this.block.type === 'code') {
// For code blocks, split text content
2025-06-24 13:53:47 +00:00
const fullText = editableElement.textContent || '';
const textNode = this.getFirstTextNode(editableElement);
2025-06-24 13:41:12 +00:00
2025-06-24 13:53:47 +00:00
if (textNode && range.startContainer === textNode) {
const before = fullText.substring(0, range.startOffset);
const after = fullText.substring(range.startOffset);
2025-06-24 13:41:12 +00:00
console.log('getSplitContent: Code block split result:', {
contentLength: fullText.length,
beforeContent: before,
beforeLength: before.length,
afterContent: after,
afterLength: after.length,
2025-06-24 13:53:47 +00:00
startOffset: range.startOffset
2025-06-24 13:41:12 +00:00
});
return { before, after };
}
}
2025-06-24 10:45:06 +00:00
2025-06-24 13:41:12 +00:00
// For other block types, extract HTML content
try {
2025-06-24 13:53:47 +00:00
// If selection is not directly in our element, try to find cursor position by text
if (!editableElement.contains(range.startContainer)) {
// Simple approach: split at cursor position in text
const textContent = editableElement.textContent || '';
const cursorPos = range.startOffset; // Simplified cursor position
const beforeText = textContent.substring(0, cursorPos);
const afterText = textContent.substring(cursorPos);
console.log('Splitting by text position (fallback):', {
cursorPos,
beforeText,
afterText,
totalLength: textContent.length
});
// For now, return text-based split
return {
before: beforeText,
after: afterText
};
}
2025-06-24 13:41:12 +00:00
// Create a temporary range to get content before cursor
const beforeRange = document.createRange();
2025-06-24 13:53:47 +00:00
beforeRange.selectNodeContents(editableElement);
beforeRange.setEnd(range.startContainer, range.startOffset);
2025-06-24 13:41:12 +00:00
// Create a temporary range to get content after cursor
const afterRange = document.createRange();
2025-06-24 13:53:47 +00:00
afterRange.selectNodeContents(editableElement);
afterRange.setStart(range.startContainer, range.startOffset);
2025-06-24 13:41:12 +00:00
// 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:', {
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;
}
2025-06-24 10:45:06 +00:00
}
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);
}
2025-06-24 13:41:12 +00:00
/**
* 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;
}
2025-06-24 10:45:06 +00:00
}