772 lines
23 KiB
TypeScript
772 lines
23 KiB
TypeScript
import {
|
|
customElement,
|
|
property,
|
|
static as html,
|
|
DeesElement,
|
|
type TemplateResult,
|
|
cssManager,
|
|
css,
|
|
query,
|
|
} 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 {
|
|
'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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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 (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;
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
// Return static HTML without event bindings
|
|
return html`
|
|
<div
|
|
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
|
|
contenteditable="true"
|
|
data-placeholder="${placeholder}"
|
|
></div>
|
|
`;
|
|
}
|
|
|
|
private getPlaceholder(): string {
|
|
switch (this.block.type) {
|
|
case 'paragraph':
|
|
return "Type '/' for commands...";
|
|
case 'heading-1':
|
|
return 'Heading 1';
|
|
case 'heading-2':
|
|
return 'Heading 2';
|
|
case 'heading-3':
|
|
return 'Heading 3';
|
|
case 'quote':
|
|
return 'Quote';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
|
|
public focus(): void {
|
|
if (!this.blockElement) return;
|
|
|
|
// Ensure the element is focusable
|
|
if (!this.blockElement.hasAttribute('contenteditable')) {
|
|
this.blockElement.setAttribute('contenteditable', 'true');
|
|
}
|
|
|
|
this.blockElement.focus();
|
|
|
|
// If focus failed, try again after a microtask
|
|
if (document.activeElement !== this.blockElement) {
|
|
Promise.resolve().then(() => {
|
|
this.blockElement.focus();
|
|
});
|
|
}
|
|
}
|
|
|
|
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
|
|
if (!this.blockElement) return;
|
|
|
|
// Ensure element is focusable first
|
|
if (!this.blockElement.hasAttribute('contenteditable')) {
|
|
this.blockElement.setAttribute('contenteditable', 'true');
|
|
}
|
|
|
|
// Focus the element
|
|
this.blockElement.focus();
|
|
|
|
// Set cursor position after focus is established
|
|
const setCursor = () => {
|
|
if (position === 'start') {
|
|
this.setCursorToStart();
|
|
} else if (position === 'end') {
|
|
this.setCursorToEnd();
|
|
} else if (typeof position === 'number') {
|
|
// Use the new selection utility to set cursor position
|
|
WysiwygSelection.setCursorPosition(this.blockElement, position);
|
|
}
|
|
};
|
|
|
|
// Ensure cursor is set after focus
|
|
if (document.activeElement === this.blockElement) {
|
|
setCursor();
|
|
} else {
|
|
// Wait for focus to be established
|
|
Promise.resolve().then(() => {
|
|
if (document.activeElement === this.blockElement) {
|
|
setCursor();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private getFirstTextNode(node: Node): Text | null {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return node as Text;
|
|
}
|
|
|
|
for (let i = 0; i < node.childNodes.length; i++) {
|
|
const textNode = this.getFirstTextNode(node.childNodes[i]);
|
|
if (textNode) return textNode;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public getContent(): string {
|
|
if (!this.blockElement) return '';
|
|
|
|
if (this.block.type === 'list') {
|
|
const listItems = this.blockElement.querySelectorAll('li');
|
|
return Array.from(listItems).map(li => li.innerHTML || '').join('\n');
|
|
} else if (this.block.type === 'code') {
|
|
return this.blockElement.textContent || '';
|
|
} else {
|
|
return this.blockElement.innerHTML || '';
|
|
}
|
|
}
|
|
|
|
public setContent(content: string): void {
|
|
if (!this.blockElement) return;
|
|
|
|
// Store if we have focus
|
|
const hadFocus = document.activeElement === this.blockElement;
|
|
|
|
if (this.block.type === 'list') {
|
|
this.blockElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata);
|
|
} else if (this.block.type === 'code') {
|
|
this.blockElement.textContent = content;
|
|
} else {
|
|
this.blockElement.innerHTML = content;
|
|
}
|
|
|
|
// Restore focus if we had it
|
|
if (hadFocus) {
|
|
this.blockElement.focus();
|
|
}
|
|
}
|
|
|
|
public setCursorToStart(): void {
|
|
WysiwygBlocks.setCursorToStart(this.blockElement);
|
|
}
|
|
|
|
public setCursorToEnd(): void {
|
|
WysiwygBlocks.setCursorToEnd(this.blockElement);
|
|
}
|
|
|
|
public focusListItem(): void {
|
|
if (this.block.type === 'list') {
|
|
WysiwygBlocks.focusListItem(this.blockElement);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets content split at cursor position
|
|
*/
|
|
public getSplitContent(): { before: string; after: string } | null {
|
|
if (!this.blockElement) return null;
|
|
|
|
// 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: fullContent,
|
|
after: ''
|
|
};
|
|
}
|
|
|
|
// Check if selection is within this block
|
|
if (!WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!)) {
|
|
console.log('getSplitContent: Selection not in this block');
|
|
return null;
|
|
}
|
|
|
|
// 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
|
|
});
|
|
|
|
// 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 };
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
} |