feat(editor): Add wysiwyg editor
This commit is contained in:
@ -17,7 +17,8 @@ import {
|
|||||||
WysiwygConverters,
|
WysiwygConverters,
|
||||||
WysiwygShortcuts,
|
WysiwygShortcuts,
|
||||||
WysiwygBlocks,
|
WysiwygBlocks,
|
||||||
type ISlashMenuItem
|
type ISlashMenuItem,
|
||||||
|
WysiwygFormatting
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@ -69,9 +70,19 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
@state()
|
@state()
|
||||||
private dragOverPosition: 'before' | 'after' | null = null;
|
private dragOverPosition: 'before' | 'after' | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private showFormattingMenu: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private formattingMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private selectedText: string = '';
|
||||||
|
|
||||||
private editorContentRef: HTMLDivElement;
|
private editorContentRef: HTMLDivElement;
|
||||||
private isComposing: boolean = false;
|
private isComposing: boolean = false;
|
||||||
private saveTimeout: any = null;
|
private saveTimeout: any = null;
|
||||||
|
private selectionChangeHandler = () => this.handleSelectionChange();
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
...DeesInputBase.baseStyles,
|
...DeesInputBase.baseStyles,
|
||||||
@ -79,9 +90,24 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
wysiwygStyles
|
wysiwygStyles
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectedCallback() {
|
||||||
|
await super.disconnectedCallback();
|
||||||
|
// Remove selection listener
|
||||||
|
document.removeEventListener('selectionchange', this.selectionChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||||
|
|
||||||
|
// Add global selection listener
|
||||||
|
console.log('Adding selectionchange listener');
|
||||||
|
document.addEventListener('selectionchange', this.selectionChangeHandler);
|
||||||
|
|
||||||
// Set initial content for blocks after a brief delay to ensure DOM is ready
|
// Set initial content for blocks after a brief delay to ensure DOM is ready
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -116,6 +142,9 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
if (isEmpty && block.content) {
|
if (isEmpty && block.content) {
|
||||||
if (block.type === 'list') {
|
if (block.type === 'list') {
|
||||||
blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata);
|
blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata);
|
||||||
|
} else if (block.content.includes('<') && block.content.includes('>')) {
|
||||||
|
// Content contains HTML formatting
|
||||||
|
blockElement.innerHTML = block.content;
|
||||||
} else {
|
} else {
|
||||||
blockElement.textContent = block.content;
|
blockElement.textContent = block.content;
|
||||||
}
|
}
|
||||||
@ -140,6 +169,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
${this.blocks.map(block => this.renderBlock(block))}
|
${this.blocks.map(block => this.renderBlock(block))}
|
||||||
</div>
|
</div>
|
||||||
${this.showSlashMenu ? this.renderSlashMenu() : ''}
|
${this.showSlashMenu ? this.renderSlashMenu() : ''}
|
||||||
|
${this.showFormattingMenu ? this.renderFormattingMenu() : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -172,6 +202,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
onBlur: () => this.handleBlockBlur(block),
|
onBlur: () => this.handleBlockBlur(block),
|
||||||
onCompositionStart: () => this.isComposing = true,
|
onCompositionStart: () => this.isComposing = true,
|
||||||
onCompositionEnd: () => this.isComposing = false,
|
onCompositionEnd: () => this.isComposing = false,
|
||||||
|
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -207,6 +238,13 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderFormattingMenu(): TemplateResult {
|
||||||
|
return WysiwygFormatting.renderFormattingMenu(
|
||||||
|
this.formattingMenuPosition,
|
||||||
|
(command) => this.applyFormat(command)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private handleBlockInput(e: InputEvent, block: IBlock) {
|
private handleBlockInput(e: InputEvent, block: IBlock) {
|
||||||
if (this.isComposing) return;
|
if (this.isComposing) return;
|
||||||
|
|
||||||
@ -327,6 +365,28 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle formatting shortcuts
|
||||||
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case 'b':
|
||||||
|
e.preventDefault();
|
||||||
|
this.applyFormat('bold');
|
||||||
|
return;
|
||||||
|
case 'i':
|
||||||
|
e.preventDefault();
|
||||||
|
this.applyFormat('italic');
|
||||||
|
return;
|
||||||
|
case 'u':
|
||||||
|
e.preventDefault();
|
||||||
|
this.applyFormat('underline');
|
||||||
|
return;
|
||||||
|
case 'k':
|
||||||
|
e.preventDefault();
|
||||||
|
this.applyFormat('link');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Tab key for indentation
|
// Handle Tab key for indentation
|
||||||
if (e.key === 'Tab') {
|
if (e.key === 'Tab') {
|
||||||
if (block.type === 'code') {
|
if (block.type === 'code') {
|
||||||
@ -756,4 +816,172 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private handleTextSelection(e: MouseEvent): void {
|
||||||
|
// Stop event to prevent it from bubbling up
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
console.log('handleTextSelection called from mouseup on contenteditable');
|
||||||
|
|
||||||
|
// Small delay to ensure selection is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
// Alternative approach: check selection directly within the target element
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const selectedText = selection.toString();
|
||||||
|
console.log('Direct selection check in handleTextSelection:', {
|
||||||
|
selectedText: selectedText.substring(0, 50),
|
||||||
|
hasText: selectedText.length > 0,
|
||||||
|
target: target.tagName + '.' + target.className
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedText.length > 0) {
|
||||||
|
// We know this came from a mouseup on our contenteditable, so it's definitely our selection
|
||||||
|
console.log('✅ Text selected via mouseup:', selectedText);
|
||||||
|
this.selectedText = selectedText;
|
||||||
|
this.updateFormattingMenuPosition();
|
||||||
|
} else if (this.showFormattingMenu) {
|
||||||
|
this.hideFormattingMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSelectionChange(): void {
|
||||||
|
// Try to get selection from shadow root first, then fall back to window
|
||||||
|
const shadowSelection = (this.shadowRoot as any).getSelection ? (this.shadowRoot as any).getSelection() : null;
|
||||||
|
const windowSelection = window.getSelection();
|
||||||
|
const editorContent = this.shadowRoot?.querySelector('.editor-content') as HTMLElement;
|
||||||
|
|
||||||
|
// Check both shadow and window selections
|
||||||
|
let selection = shadowSelection;
|
||||||
|
let selectedText = shadowSelection?.toString() || '';
|
||||||
|
|
||||||
|
// If no shadow selection, check window selection
|
||||||
|
if (!selectedText && windowSelection) {
|
||||||
|
selection = windowSelection;
|
||||||
|
selectedText = windowSelection.toString() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Selection change:', {
|
||||||
|
hasText: selectedText.length > 0,
|
||||||
|
selectedText: selectedText.substring(0, 50),
|
||||||
|
shadowSelection: !!shadowSelection,
|
||||||
|
windowSelection: !!windowSelection,
|
||||||
|
rangeCount: selection?.rangeCount,
|
||||||
|
editorContent: !!editorContent
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selection || selection.rangeCount === 0 || !editorContent) {
|
||||||
|
console.log('No selection or editor content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have selected text, show the formatting menu
|
||||||
|
if (selectedText.length > 0) {
|
||||||
|
console.log('✅ Text selected:', selectedText);
|
||||||
|
|
||||||
|
if (selectedText !== this.selectedText) {
|
||||||
|
this.selectedText = selectedText;
|
||||||
|
this.updateFormattingMenuPosition();
|
||||||
|
}
|
||||||
|
} else if (this.showFormattingMenu) {
|
||||||
|
console.log('No text selected, hiding menu');
|
||||||
|
this.hideFormattingMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRootNodeOfNode(node: Node): Node {
|
||||||
|
let current: Node = node;
|
||||||
|
while (current.parentNode) {
|
||||||
|
current = current.parentNode;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFormattingMenuPosition(): void {
|
||||||
|
console.log('updateFormattingMenuPosition called');
|
||||||
|
const coords = WysiwygFormatting.getSelectionCoordinates(this.shadowRoot as ShadowRoot);
|
||||||
|
console.log('Selection coordinates:', coords);
|
||||||
|
|
||||||
|
if (coords) {
|
||||||
|
const container = this.shadowRoot!.querySelector('.wysiwyg-container');
|
||||||
|
if (!container) {
|
||||||
|
console.error('Container not found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
this.formattingMenuPosition = {
|
||||||
|
x: coords.x - containerRect.left,
|
||||||
|
y: coords.y - containerRect.top
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Setting menu position:', this.formattingMenuPosition);
|
||||||
|
this.showFormattingMenu = true;
|
||||||
|
console.log('showFormattingMenu set to:', this.showFormattingMenu);
|
||||||
|
|
||||||
|
// Force update
|
||||||
|
this.requestUpdate();
|
||||||
|
|
||||||
|
// Check if menu exists in DOM after update
|
||||||
|
setTimeout(() => {
|
||||||
|
const menu = this.shadowRoot?.querySelector('.formatting-menu');
|
||||||
|
console.log('Menu in DOM after update:', menu);
|
||||||
|
if (menu) {
|
||||||
|
console.log('Menu style:', menu.getAttribute('style'));
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
console.log('No coordinates found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideFormattingMenu(): void {
|
||||||
|
this.showFormattingMenu = false;
|
||||||
|
this.selectedText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyFormat(command: string): void {
|
||||||
|
// Save current selection before applying format
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return;
|
||||||
|
|
||||||
|
// Get the current block to update its content
|
||||||
|
const anchorNode = selection.anchorNode;
|
||||||
|
const blockElement = anchorNode?.nodeType === Node.TEXT_NODE
|
||||||
|
? anchorNode.parentElement?.closest('.block')
|
||||||
|
: (anchorNode as Element)?.closest('.block');
|
||||||
|
|
||||||
|
if (!blockElement) return;
|
||||||
|
|
||||||
|
const blockId = blockElement.closest('.block-wrapper')?.getAttribute('data-block-id');
|
||||||
|
const block = this.blocks.find(b => b.id === blockId);
|
||||||
|
|
||||||
|
if (!block) return;
|
||||||
|
|
||||||
|
// Apply the format
|
||||||
|
WysiwygFormatting.applyFormat(command);
|
||||||
|
|
||||||
|
// Update block content after format is applied
|
||||||
|
setTimeout(() => {
|
||||||
|
if (block.type === 'list') {
|
||||||
|
const listItems = blockElement.querySelectorAll('li');
|
||||||
|
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
|
||||||
|
} else {
|
||||||
|
// For other blocks, preserve HTML formatting
|
||||||
|
block.content = blockElement.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateValue();
|
||||||
|
|
||||||
|
// Keep selection active
|
||||||
|
if (command !== 'link') {
|
||||||
|
this.updateFormattingMenuPosition();
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
}
|
}
|
@ -2,4 +2,5 @@ export * from './wysiwyg.types.js';
|
|||||||
export * from './wysiwyg.styles.js';
|
export * from './wysiwyg.styles.js';
|
||||||
export * from './wysiwyg.converters.js';
|
export * from './wysiwyg.converters.js';
|
||||||
export * from './wysiwyg.shortcuts.js';
|
export * from './wysiwyg.shortcuts.js';
|
||||||
export * from './wysiwyg.blocks.js';
|
export * from './wysiwyg.blocks.js';
|
||||||
|
export * from './wysiwyg.formatting.js';
|
@ -20,6 +20,7 @@ export class WysiwygBlocks {
|
|||||||
onBlur: () => void;
|
onBlur: () => void;
|
||||||
onCompositionStart: () => void;
|
onCompositionStart: () => void;
|
||||||
onCompositionEnd: () => void;
|
onCompositionEnd: () => void;
|
||||||
|
onMouseUp?: (e: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
): TemplateResult {
|
): TemplateResult {
|
||||||
if (block.type === 'divider') {
|
if (block.type === 'divider') {
|
||||||
@ -45,6 +46,10 @@ export class WysiwygBlocks {
|
|||||||
@blur="${handlers.onBlur}"
|
@blur="${handlers.onBlur}"
|
||||||
@compositionstart="${handlers.onCompositionStart}"
|
@compositionstart="${handlers.onCompositionStart}"
|
||||||
@compositionend="${handlers.onCompositionEnd}"
|
@compositionend="${handlers.onCompositionEnd}"
|
||||||
|
@mouseup="${(e: MouseEvent) => {
|
||||||
|
console.log('Block mouseup event fired');
|
||||||
|
if (handlers.onMouseUp) handlers.onMouseUp(e);
|
||||||
|
}}"
|
||||||
.innerHTML="${this.renderListContent(block.content, block.metadata)}"
|
.innerHTML="${this.renderListContent(block.content, block.metadata)}"
|
||||||
></div>
|
></div>
|
||||||
`;
|
`;
|
||||||
@ -60,6 +65,10 @@ export class WysiwygBlocks {
|
|||||||
@blur="${handlers.onBlur}"
|
@blur="${handlers.onBlur}"
|
||||||
@compositionstart="${handlers.onCompositionStart}"
|
@compositionstart="${handlers.onCompositionStart}"
|
||||||
@compositionend="${handlers.onCompositionEnd}"
|
@compositionend="${handlers.onCompositionEnd}"
|
||||||
|
@mouseup="${(e: MouseEvent) => {
|
||||||
|
console.log('Block mouseup event fired');
|
||||||
|
if (handlers.onMouseUp) handlers.onMouseUp(e);
|
||||||
|
}}"
|
||||||
></div>
|
></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -9,17 +9,22 @@ export class WysiwygConverters {
|
|||||||
|
|
||||||
static getHtmlOutput(blocks: IBlock[]): string {
|
static getHtmlOutput(blocks: IBlock[]): string {
|
||||||
return blocks.map(block => {
|
return blocks.map(block => {
|
||||||
|
// Check if content already contains HTML formatting
|
||||||
|
const content = block.content.includes('<') && block.content.includes('>')
|
||||||
|
? block.content // Already contains HTML formatting
|
||||||
|
: this.escapeHtml(block.content);
|
||||||
|
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case 'paragraph':
|
case 'paragraph':
|
||||||
return block.content ? `<p>${this.escapeHtml(block.content)}</p>` : '';
|
return block.content ? `<p>${content}</p>` : '';
|
||||||
case 'heading-1':
|
case 'heading-1':
|
||||||
return `<h1>${this.escapeHtml(block.content)}</h1>`;
|
return `<h1>${content}</h1>`;
|
||||||
case 'heading-2':
|
case 'heading-2':
|
||||||
return `<h2>${this.escapeHtml(block.content)}</h2>`;
|
return `<h2>${content}</h2>`;
|
||||||
case 'heading-3':
|
case 'heading-3':
|
||||||
return `<h3>${this.escapeHtml(block.content)}</h3>`;
|
return `<h3>${content}</h3>`;
|
||||||
case 'quote':
|
case 'quote':
|
||||||
return `<blockquote>${this.escapeHtml(block.content)}</blockquote>`;
|
return `<blockquote>${content}</blockquote>`;
|
||||||
case 'code':
|
case 'code':
|
||||||
return `<pre><code>${this.escapeHtml(block.content)}</code></pre>`;
|
return `<pre><code>${this.escapeHtml(block.content)}</code></pre>`;
|
||||||
case 'list':
|
case 'list':
|
||||||
@ -32,7 +37,7 @@ export class WysiwygConverters {
|
|||||||
case 'divider':
|
case 'divider':
|
||||||
return '<hr>';
|
return '<hr>';
|
||||||
default:
|
default:
|
||||||
return `<p>${this.escapeHtml(block.content)}</p>`;
|
return `<p>${content}</p>`;
|
||||||
}
|
}
|
||||||
}).filter(html => html !== '').join('\n');
|
}).filter(html => html !== '').join('\n');
|
||||||
}
|
}
|
||||||
@ -88,35 +93,35 @@ export class WysiwygConverters {
|
|||||||
blocks.push({
|
blocks.push({
|
||||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
content: element.textContent || '',
|
content: element.innerHTML || '',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'h1':
|
case 'h1':
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
type: 'heading-1',
|
type: 'heading-1',
|
||||||
content: element.textContent || '',
|
content: element.innerHTML || '',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'h2':
|
case 'h2':
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
type: 'heading-2',
|
type: 'heading-2',
|
||||||
content: element.textContent || '',
|
content: element.innerHTML || '',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'h3':
|
case 'h3':
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
type: 'heading-3',
|
type: 'heading-3',
|
||||||
content: element.textContent || '',
|
content: element.innerHTML || '',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'blockquote':
|
case 'blockquote':
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
type: 'quote',
|
type: 'quote',
|
||||||
content: element.textContent || '',
|
content: element.innerHTML || '',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'pre':
|
case 'pre':
|
||||||
|
149
ts_web/elements/wysiwyg/wysiwyg.formatting.ts
Normal file
149
ts_web/elements/wysiwyg/wysiwyg.formatting.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export interface IFormatButton {
|
||||||
|
command: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
shortcut?: string;
|
||||||
|
action?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WysiwygFormatting {
|
||||||
|
static readonly formatButtons: IFormatButton[] = [
|
||||||
|
{ command: 'bold', icon: 'B', label: 'Bold', shortcut: '⌘B' },
|
||||||
|
{ command: 'italic', icon: 'I', label: 'Italic', shortcut: '⌘I' },
|
||||||
|
{ command: 'underline', icon: 'U', label: 'Underline', shortcut: '⌘U' },
|
||||||
|
{ command: 'strikeThrough', icon: 'S̶', label: 'Strikethrough' },
|
||||||
|
{ command: 'code', icon: '{ }', label: 'Inline Code' },
|
||||||
|
{ command: 'link', icon: '🔗', label: 'Link', shortcut: '⌘K' },
|
||||||
|
];
|
||||||
|
|
||||||
|
static renderFormattingMenu(
|
||||||
|
position: { x: number; y: number },
|
||||||
|
onFormat: (command: string) => void
|
||||||
|
): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="formatting-menu"
|
||||||
|
style="top: ${position.y}px; left: ${position.x}px;"
|
||||||
|
@mousedown="${(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}"
|
||||||
|
@click="${(e: MouseEvent) => e.stopPropagation()}"
|
||||||
|
>
|
||||||
|
${this.formatButtons.map(button => html`
|
||||||
|
<button
|
||||||
|
class="format-button ${button.command}"
|
||||||
|
@click="${() => onFormat(button.command)}"
|
||||||
|
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
|
||||||
|
>
|
||||||
|
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
|
||||||
|
</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static applyFormat(command: string, value?: string): void {
|
||||||
|
// Save current selection
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return;
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
|
// Apply format based on command
|
||||||
|
switch (command) {
|
||||||
|
case 'bold':
|
||||||
|
case 'italic':
|
||||||
|
case 'underline':
|
||||||
|
case 'strikeThrough':
|
||||||
|
document.execCommand(command, false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'code':
|
||||||
|
// For inline code, wrap selection in <code> tags
|
||||||
|
const codeElement = document.createElement('code');
|
||||||
|
try {
|
||||||
|
codeElement.appendChild(range.extractContents());
|
||||||
|
range.insertNode(codeElement);
|
||||||
|
|
||||||
|
// Select the newly created code element
|
||||||
|
range.selectNodeContents(codeElement);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to execCommand if range manipulation fails
|
||||||
|
document.execCommand('fontName', false, 'monospace');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'link':
|
||||||
|
const url = value || prompt('Enter URL:');
|
||||||
|
if (url) {
|
||||||
|
document.execCommand('createLink', false, url);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('getSelectionCoordinates - selection:', selection);
|
||||||
|
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
console.log('No selection or no ranges');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
console.log('Range rect:', rect);
|
||||||
|
|
||||||
|
if (rect.width === 0) {
|
||||||
|
console.log('Rect width is 0');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = {
|
||||||
|
x: rect.left + (rect.width / 2),
|
||||||
|
y: Math.max(45, rect.top - 45) // Position above selection, but ensure it's not negative
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Returning coords:', coords);
|
||||||
|
return coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isFormattingApplied(command: string): boolean {
|
||||||
|
try {
|
||||||
|
return document.queryCommandState(command);
|
||||||
|
} catch {
|
||||||
|
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() : '';
|
||||||
|
}
|
||||||
|
}
|
@ -26,6 +26,11 @@ export const wysiwygStyles = css`
|
|||||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(77, 148, 255, 0.1)')};
|
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(77, 148, 255, 0.1)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Visual hint for text selection */
|
||||||
|
.editor-content:hover {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-content {
|
.editor-content {
|
||||||
outline: none;
|
outline: none;
|
||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
@ -388,4 +393,120 @@ export const wysiwygStyles = css`
|
|||||||
.editor-content.dragging * {
|
.editor-content.dragging * {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Text Selection Styles */
|
||||||
|
.block ::selection {
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formatting Menu */
|
||||||
|
.formatting-menu {
|
||||||
|
position: absolute;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
z-index: 1001;
|
||||||
|
animation: fadeInScale 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-button:hover {
|
||||||
|
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||||
|
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-button.bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-button.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-button.underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-button .code-icon {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Applied format styles in content */
|
||||||
|
.block strong,
|
||||||
|
.block b {
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.block em,
|
||||||
|
.block i {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block u {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block strike,
|
||||||
|
.block s {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block code {
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block a:hover {
|
||||||
|
border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||||
|
}
|
||||||
`;
|
`;
|
Reference in New Issue
Block a user