feat(editor): Add wysiwyg editor
This commit is contained in:
564
ts_web/elements/wysiwyg/dees-input-wysiwyg.ts
Normal file
564
ts_web/elements/wysiwyg/dees-input-wysiwyg.ts
Normal file
@ -0,0 +1,564 @@
|
||||
import { DeesInputBase } from '../dees-input-base.js';
|
||||
import { demoFunc } from '../dees-input-wysiwyg.demo.js';
|
||||
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
html,
|
||||
cssManager,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import {
|
||||
type IBlock,
|
||||
type OutputFormat,
|
||||
wysiwygStyles,
|
||||
WysiwygConverters,
|
||||
WysiwygShortcuts,
|
||||
WysiwygBlocks,
|
||||
type ISlashMenuItem
|
||||
} from './index.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-wysiwyg': DeesInputWysiwyg;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-wysiwyg')
|
||||
export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
public value: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public outputFormat: OutputFormat = 'html';
|
||||
|
||||
@state()
|
||||
private blocks: IBlock[] = [
|
||||
{
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type: 'paragraph',
|
||||
content: '',
|
||||
}
|
||||
];
|
||||
|
||||
@state()
|
||||
private selectedBlockId: string | null = null;
|
||||
|
||||
@state()
|
||||
private showSlashMenu: boolean = false;
|
||||
|
||||
@state()
|
||||
private slashMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
@state()
|
||||
private slashMenuFilter: string = '';
|
||||
|
||||
@state()
|
||||
private slashMenuSelectedIndex: number = 0;
|
||||
|
||||
private editorContentRef: HTMLDivElement;
|
||||
private isComposing: boolean = false;
|
||||
private saveTimeout: any = null;
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
wysiwygStyles
|
||||
];
|
||||
|
||||
async firstUpdated() {
|
||||
this.updateValue();
|
||||
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
// Set initial content for blocks
|
||||
this.setBlockContents();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
// When blocks change (e.g., from setValue), update DOM content
|
||||
if (changedProperties.has('blocks')) {
|
||||
this.setBlockContents();
|
||||
}
|
||||
}
|
||||
|
||||
private setBlockContents() {
|
||||
// Only set content for blocks that aren't being edited
|
||||
this.blocks.forEach(block => {
|
||||
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement;
|
||||
if (blockElement && document.activeElement !== blockElement && block.type !== 'divider') {
|
||||
if (block.type === 'list') {
|
||||
blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata);
|
||||
} else {
|
||||
blockElement.textContent = block.content;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<dees-label
|
||||
.label="${this.label}"
|
||||
.description="${this.description}"
|
||||
.required="${this.required}"
|
||||
></dees-label>
|
||||
<div class="wysiwyg-container">
|
||||
<div
|
||||
class="editor-content"
|
||||
@click="${this.handleEditorClick}"
|
||||
>
|
||||
${this.blocks.map(block => this.renderBlock(block))}
|
||||
</div>
|
||||
${this.showSlashMenu ? this.renderSlashMenu() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBlock(block: IBlock): TemplateResult {
|
||||
const isSelected = this.selectedBlockId === block.id;
|
||||
|
||||
return WysiwygBlocks.renderBlock(block, isSelected, {
|
||||
onInput: (e: InputEvent) => this.handleBlockInput(e, block),
|
||||
onKeyDown: (e: KeyboardEvent) => this.handleBlockKeyDown(e, block),
|
||||
onFocus: () => this.handleBlockFocus(block),
|
||||
onBlur: () => this.handleBlockBlur(block),
|
||||
onCompositionStart: () => this.isComposing = true,
|
||||
onCompositionEnd: () => this.isComposing = false,
|
||||
});
|
||||
}
|
||||
|
||||
private getFilteredMenuItems(): ISlashMenuItem[] {
|
||||
const allItems = WysiwygShortcuts.getSlashMenuItems();
|
||||
return allItems.filter(item =>
|
||||
this.slashMenuFilter === '' ||
|
||||
item.label.toLowerCase().includes(this.slashMenuFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
private renderSlashMenu(): TemplateResult {
|
||||
const menuItems = this.getFilteredMenuItems();
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="slash-menu"
|
||||
style="top: ${this.slashMenuPosition.y}px; left: ${this.slashMenuPosition.x}px;"
|
||||
>
|
||||
${menuItems.map((item, index) => html`
|
||||
<div
|
||||
class="slash-menu-item ${index === this.slashMenuSelectedIndex ? 'selected' : ''}"
|
||||
@click="${() => this.insertBlock(item.type as IBlock['type'])}"
|
||||
@mouseenter="${() => this.slashMenuSelectedIndex = index}"
|
||||
>
|
||||
<span class="icon">${item.icon}</span>
|
||||
<span>${item.label}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleBlockInput(e: InputEvent, block: IBlock) {
|
||||
if (this.isComposing) return;
|
||||
|
||||
const target = e.target as HTMLDivElement;
|
||||
|
||||
if (block.type === 'list') {
|
||||
// Extract text from list items
|
||||
const listItems = target.querySelectorAll('li');
|
||||
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
|
||||
// Preserve list type
|
||||
const listElement = target.querySelector('ol, ul');
|
||||
if (listElement) {
|
||||
block.metadata = { listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet' };
|
||||
}
|
||||
} else {
|
||||
block.content = target.textContent || '';
|
||||
}
|
||||
|
||||
// Check for block type change intents
|
||||
const detectedType = this.detectBlockTypeIntent(block.content);
|
||||
|
||||
// Only process if the detected type is different from current type
|
||||
if (detectedType && detectedType.type !== block.type) {
|
||||
e.preventDefault();
|
||||
|
||||
// Handle special cases
|
||||
if (detectedType.type === 'list') {
|
||||
block.type = 'list';
|
||||
block.content = '';
|
||||
block.metadata = { listType: detectedType.listType };
|
||||
|
||||
// Update list structure immediately
|
||||
const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul';
|
||||
target.innerHTML = `<${listTag}><li></li></${listTag}>`;
|
||||
|
||||
// Force update and focus
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
WysiwygBlocks.focusListItem(target);
|
||||
}, 0);
|
||||
|
||||
return;
|
||||
} else if (detectedType.type === 'divider') {
|
||||
block.type = 'divider';
|
||||
block.content = ' ';
|
||||
|
||||
// Create a new paragraph block after the divider
|
||||
const blockIndex = this.blocks.findIndex(b => b.id === block.id);
|
||||
const newBlock: IBlock = {
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type: 'paragraph',
|
||||
content: '',
|
||||
};
|
||||
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
||||
|
||||
setTimeout(() => {
|
||||
const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement;
|
||||
if (newBlockElement) {
|
||||
newBlockElement.focus();
|
||||
}
|
||||
});
|
||||
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
return;
|
||||
} else {
|
||||
// For all other block types
|
||||
block.type = detectedType.type;
|
||||
block.content = '';
|
||||
|
||||
// Clear the DOM element immediately
|
||||
target.textContent = '';
|
||||
|
||||
// Force update
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for slash commands at the beginning of any block
|
||||
if (block.content === '/' || (block.content.startsWith('/') && this.showSlashMenu)) {
|
||||
// Only show menu on initial '/', or update filter if already showing
|
||||
if (!this.showSlashMenu && block.content === '/') {
|
||||
this.showSlashMenu = true;
|
||||
this.slashMenuSelectedIndex = 0;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const containerRect = this.shadowRoot!.querySelector('.wysiwyg-container')!.getBoundingClientRect();
|
||||
this.slashMenuPosition = {
|
||||
x: rect.left - containerRect.left,
|
||||
y: rect.bottom - containerRect.top + 4
|
||||
};
|
||||
}
|
||||
this.slashMenuFilter = block.content.slice(1);
|
||||
} else if (!block.content.startsWith('/')) {
|
||||
this.closeSlashMenu();
|
||||
}
|
||||
|
||||
// Don't update value on every input - let the browser handle typing normally
|
||||
// But schedule a save after a delay
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
this.updateValue();
|
||||
}, 1000); // Save after 1 second of inactivity
|
||||
}
|
||||
|
||||
private handleBlockKeyDown(e: KeyboardEvent, block: IBlock) {
|
||||
if (this.showSlashMenu && ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
|
||||
this.handleSlashMenuKeyboard(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Tab key for indentation
|
||||
if (e.key === 'Tab') {
|
||||
if (block.type === 'code') {
|
||||
// Allow tab in code blocks
|
||||
e.preventDefault();
|
||||
document.execCommand('insertText', false, ' ');
|
||||
return;
|
||||
} else if (block.type === 'list') {
|
||||
// Future: implement list indentation
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (block.type === 'list') {
|
||||
// Handle Enter in lists differently
|
||||
const target = e.target as HTMLDivElement;
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const currentLi = range.startContainer.parentElement?.closest('li');
|
||||
|
||||
if (currentLi && currentLi.textContent === '') {
|
||||
// Empty list item - exit list mode
|
||||
e.preventDefault();
|
||||
const blockIndex = this.blocks.findIndex(b => b.id === block.id);
|
||||
const newBlock: IBlock = {
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type: 'paragraph',
|
||||
content: '',
|
||||
};
|
||||
|
||||
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
||||
|
||||
setTimeout(() => {
|
||||
const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement;
|
||||
if (newBlockElement) {
|
||||
newBlockElement.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Otherwise, let the browser handle creating new list items
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const blockIndex = this.blocks.findIndex(b => b.id === block.id);
|
||||
const newBlock: IBlock = {
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type: 'paragraph',
|
||||
content: '',
|
||||
};
|
||||
|
||||
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
||||
|
||||
this.updateValue();
|
||||
|
||||
setTimeout(() => {
|
||||
const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement;
|
||||
if (newBlockElement) {
|
||||
newBlockElement.focus();
|
||||
}
|
||||
});
|
||||
} else if (e.key === 'Backspace' && block.content === '' && this.blocks.length > 1) {
|
||||
e.preventDefault();
|
||||
const blockIndex = this.blocks.findIndex(b => b.id === block.id);
|
||||
if (blockIndex > 0) {
|
||||
const prevBlock = this.blocks[blockIndex - 1];
|
||||
this.blocks = this.blocks.filter(b => b.id !== block.id);
|
||||
|
||||
this.updateValue();
|
||||
|
||||
setTimeout(() => {
|
||||
const prevBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${prevBlock.id}"]`) as HTMLDivElement;
|
||||
if (prevBlockElement && prevBlock.type !== 'divider') {
|
||||
prevBlockElement.focus();
|
||||
WysiwygBlocks.setCursorToEnd(prevBlockElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlashMenuKeyboard(e: KeyboardEvent) {
|
||||
const menuItems = this.getFilteredMenuItems();
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.slashMenuSelectedIndex = (this.slashMenuSelectedIndex + 1) % menuItems.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.slashMenuSelectedIndex = this.slashMenuSelectedIndex === 0
|
||||
? menuItems.length - 1
|
||||
: this.slashMenuSelectedIndex - 1;
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (menuItems[this.slashMenuSelectedIndex]) {
|
||||
this.insertBlock(menuItems[this.slashMenuSelectedIndex].type as IBlock['type']);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.closeSlashMenu();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private closeSlashMenu() {
|
||||
if (this.showSlashMenu && this.selectedBlockId) {
|
||||
// Clear the slash command from the content if menu is closing without selection
|
||||
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
|
||||
if (currentBlock && currentBlock.content.startsWith('/')) {
|
||||
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`) as HTMLDivElement;
|
||||
if (blockElement) {
|
||||
// Clear the slash command text
|
||||
blockElement.textContent = '';
|
||||
currentBlock.content = '';
|
||||
// Ensure cursor stays in the block
|
||||
blockElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.showSlashMenu = false;
|
||||
this.slashMenuFilter = '';
|
||||
this.slashMenuSelectedIndex = 0;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (block.type !== 'divider') {
|
||||
this.selectedBlockId = block.id;
|
||||
}
|
||||
}
|
||||
|
||||
private handleBlockBlur(block: IBlock) {
|
||||
// Update value on blur to ensure it's saved
|
||||
this.updateValue();
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.selectedBlockId === block.id) {
|
||||
this.selectedBlockId = null;
|
||||
}
|
||||
// Don't close slash menu on blur if clicking on menu item
|
||||
const activeElement = document.activeElement;
|
||||
const slashMenu = this.shadowRoot?.querySelector('.slash-menu');
|
||||
if (!slashMenu?.contains(activeElement as Node)) {
|
||||
this.closeSlashMenu();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private handleEditorClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('editor-content')) {
|
||||
const lastBlock = this.blocks[this.blocks.length - 1];
|
||||
const lastBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${lastBlock.id}"]`) as HTMLDivElement;
|
||||
if (lastBlockElement && lastBlock.type !== 'divider') {
|
||||
lastBlockElement.focus();
|
||||
WysiwygBlocks.setCursorToEnd(lastBlockElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private insertBlock(type: IBlock['type']) {
|
||||
const currentBlockIndex = this.blocks.findIndex(b => b.id === this.selectedBlockId);
|
||||
const currentBlock = this.blocks[currentBlockIndex];
|
||||
|
||||
if (currentBlock && currentBlock.content.startsWith('/')) {
|
||||
currentBlock.type = type;
|
||||
currentBlock.content = '';
|
||||
|
||||
if (type === 'divider') {
|
||||
currentBlock.content = ' ';
|
||||
const newBlock: IBlock = {
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type: 'paragraph',
|
||||
content: '',
|
||||
};
|
||||
this.blocks = [...this.blocks.slice(0, currentBlockIndex + 1), newBlock, ...this.blocks.slice(currentBlockIndex + 1)];
|
||||
|
||||
setTimeout(() => {
|
||||
const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement;
|
||||
if (newBlockElement) {
|
||||
newBlockElement.focus();
|
||||
}
|
||||
});
|
||||
} else if (type === 'list') {
|
||||
// Handle list type specially
|
||||
currentBlock.metadata = { listType: 'bullet' }; // Default to bullet list
|
||||
setTimeout(() => {
|
||||
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`) as HTMLDivElement;
|
||||
if (blockElement) {
|
||||
blockElement.innerHTML = '<ul><li></li></ul>';
|
||||
WysiwygBlocks.focusListItem(blockElement);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Force update the contenteditable element
|
||||
setTimeout(() => {
|
||||
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`) as HTMLDivElement;
|
||||
if (blockElement) {
|
||||
blockElement.textContent = '';
|
||||
blockElement.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.closeSlashMenu();
|
||||
this.updateValue();
|
||||
}
|
||||
|
||||
private updateValue() {
|
||||
if (this.outputFormat === 'html') {
|
||||
this.value = WysiwygConverters.getHtmlOutput(this.blocks);
|
||||
} else {
|
||||
this.value = WysiwygConverters.getMarkdownOutput(this.blocks);
|
||||
}
|
||||
this.changeSubject.next(this.value);
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
|
||||
if (this.outputFormat === 'html') {
|
||||
this.blocks = WysiwygConverters.parseHtmlToBlocks(value);
|
||||
} else {
|
||||
this.blocks = WysiwygConverters.parseMarkdownToBlocks(value);
|
||||
}
|
||||
|
||||
if (this.blocks.length === 0) {
|
||||
this.blocks = [{
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type: 'paragraph',
|
||||
content: '',
|
||||
}];
|
||||
}
|
||||
|
||||
this.changeSubject.next(this.value);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
5
ts_web/elements/wysiwyg/index.ts
Normal file
5
ts_web/elements/wysiwyg/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './wysiwyg.types.js';
|
||||
export * from './wysiwyg.styles.js';
|
||||
export * from './wysiwyg.converters.js';
|
||||
export * from './wysiwyg.shortcuts.js';
|
||||
export * from './wysiwyg.blocks.js';
|
98
ts_web/elements/wysiwyg/wysiwyg.blocks.ts
Normal file
98
ts_web/elements/wysiwyg/wysiwyg.blocks.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygConverters } from './wysiwyg.converters.js';
|
||||
|
||||
export class WysiwygBlocks {
|
||||
static renderListContent(content: string, metadata?: any): string {
|
||||
const items = content.split('\n').filter(item => item.trim());
|
||||
if (items.length === 0) return '';
|
||||
const listTag = metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
||||
return `<${listTag}>${items.map(item => `<li>${WysiwygConverters.escapeHtml(item)}</li>`).join('')}</${listTag}>`;
|
||||
}
|
||||
|
||||
static renderBlock(
|
||||
block: IBlock,
|
||||
isSelected: boolean,
|
||||
handlers: {
|
||||
onInput: (e: InputEvent) => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
}
|
||||
): TemplateResult {
|
||||
if (block.type === 'divider') {
|
||||
return html`
|
||||
<div
|
||||
class="block divider"
|
||||
data-block-id="${block.id}"
|
||||
>
|
||||
<hr>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (block.type === 'list') {
|
||||
return html`
|
||||
<div
|
||||
class="block list ${isSelected ? 'selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
contenteditable="true"
|
||||
@input="${handlers.onInput}"
|
||||
@keydown="${handlers.onKeyDown}"
|
||||
@focus="${handlers.onFocus}"
|
||||
@blur="${handlers.onBlur}"
|
||||
@compositionstart="${handlers.onCompositionStart}"
|
||||
@compositionend="${handlers.onCompositionEnd}"
|
||||
.innerHTML="${this.renderListContent(block.content, block.metadata)}"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="block ${block.type} ${isSelected ? 'selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
contenteditable="true"
|
||||
@input="${handlers.onInput}"
|
||||
@keydown="${handlers.onKeyDown}"
|
||||
@focus="${handlers.onFocus}"
|
||||
@blur="${handlers.onBlur}"
|
||||
@compositionstart="${handlers.onCompositionStart}"
|
||||
@compositionend="${handlers.onCompositionEnd}"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
static setCursorToEnd(element: HTMLElement): void {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
sel!.removeAllRanges();
|
||||
sel!.addRange(range);
|
||||
}
|
||||
|
||||
static setCursorToStart(element: HTMLElement): void {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(true);
|
||||
sel!.removeAllRanges();
|
||||
sel!.addRange(range);
|
||||
}
|
||||
|
||||
static focusListItem(listElement: HTMLElement): void {
|
||||
const firstLi = listElement.querySelector('li');
|
||||
if (firstLi) {
|
||||
firstLi.focus();
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(firstLi);
|
||||
range.collapse(true);
|
||||
sel!.removeAllRanges();
|
||||
sel!.addRange(range);
|
||||
}
|
||||
}
|
||||
}
|
244
ts_web/elements/wysiwyg/wysiwyg.converters.ts
Normal file
244
ts_web/elements/wysiwyg/wysiwyg.converters.ts
Normal file
@ -0,0 +1,244 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
|
||||
export class WysiwygConverters {
|
||||
static escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
static getHtmlOutput(blocks: IBlock[]): string {
|
||||
return blocks.map(block => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return block.content ? `<p>${this.escapeHtml(block.content)}</p>` : '';
|
||||
case 'heading-1':
|
||||
return `<h1>${this.escapeHtml(block.content)}</h1>`;
|
||||
case 'heading-2':
|
||||
return `<h2>${this.escapeHtml(block.content)}</h2>`;
|
||||
case 'heading-3':
|
||||
return `<h3>${this.escapeHtml(block.content)}</h3>`;
|
||||
case 'quote':
|
||||
return `<blockquote>${this.escapeHtml(block.content)}</blockquote>`;
|
||||
case 'code':
|
||||
return `<pre><code>${this.escapeHtml(block.content)}</code></pre>`;
|
||||
case 'list':
|
||||
const items = block.content.split('\n').filter(item => item.trim());
|
||||
if (items.length > 0) {
|
||||
const listTag = block.metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
||||
return `<${listTag}>${items.map(item => `<li>${this.escapeHtml(item)}</li>`).join('')}</${listTag}>`;
|
||||
}
|
||||
return '';
|
||||
case 'divider':
|
||||
return '<hr>';
|
||||
default:
|
||||
return `<p>${this.escapeHtml(block.content)}</p>`;
|
||||
}
|
||||
}).filter(html => html !== '').join('\n');
|
||||
}
|
||||
|
||||
static getMarkdownOutput(blocks: IBlock[]): string {
|
||||
return blocks.map(block => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return block.content;
|
||||
case 'heading-1':
|
||||
return `# ${block.content}`;
|
||||
case 'heading-2':
|
||||
return `## ${block.content}`;
|
||||
case 'heading-3':
|
||||
return `### ${block.content}`;
|
||||
case 'quote':
|
||||
return `> ${block.content}`;
|
||||
case 'code':
|
||||
return `\`\`\`\n${block.content}\n\`\`\``;
|
||||
case 'list':
|
||||
const items = block.content.split('\n').filter(item => item.trim());
|
||||
if (block.metadata?.listType === 'ordered') {
|
||||
return items.map((item, index) => `${index + 1}. ${item}`).join('\n');
|
||||
} else {
|
||||
return items.map(item => `- ${item}`).join('\n');
|
||||
}
|
||||
case 'divider':
|
||||
return '---';
|
||||
default:
|
||||
return block.content;
|
||||
}
|
||||
}).filter(md => md !== '').join('\n\n');
|
||||
}
|
||||
|
||||
static parseHtmlToBlocks(html: string): IBlock[] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const blocks: IBlock[] = [];
|
||||
|
||||
const processNode = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'paragraph',
|
||||
content: node.textContent.trim(),
|
||||
});
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
switch (tagName) {
|
||||
case 'p':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'paragraph',
|
||||
content: element.textContent || '',
|
||||
});
|
||||
break;
|
||||
case 'h1':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-1',
|
||||
content: element.textContent || '',
|
||||
});
|
||||
break;
|
||||
case 'h2':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-2',
|
||||
content: element.textContent || '',
|
||||
});
|
||||
break;
|
||||
case 'h3':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-3',
|
||||
content: element.textContent || '',
|
||||
});
|
||||
break;
|
||||
case 'blockquote':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'quote',
|
||||
content: element.textContent || '',
|
||||
});
|
||||
break;
|
||||
case 'pre':
|
||||
case 'code':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'code',
|
||||
content: element.textContent || '',
|
||||
});
|
||||
break;
|
||||
case 'ul':
|
||||
case 'ol':
|
||||
const listItems = Array.from(element.querySelectorAll('li'));
|
||||
const content = listItems.map(li => li.textContent || '').join('\n');
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
content: content,
|
||||
metadata: { listType: tagName === 'ol' ? 'ordered' : 'bullet' }
|
||||
});
|
||||
break;
|
||||
case 'hr':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'divider',
|
||||
content: ' ',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// Process children for other elements
|
||||
element.childNodes.forEach(child => processNode(child));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
doc.body.childNodes.forEach(node => processNode(node));
|
||||
return blocks;
|
||||
}
|
||||
|
||||
static parseMarkdownToBlocks(markdown: string): IBlock[] {
|
||||
const lines = markdown.split('\n');
|
||||
const blocks: IBlock[] = [];
|
||||
let currentListItems: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('# ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-1',
|
||||
content: line.substring(2),
|
||||
});
|
||||
} else if (line.startsWith('## ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-2',
|
||||
content: line.substring(3),
|
||||
});
|
||||
} else if (line.startsWith('### ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-3',
|
||||
content: line.substring(4),
|
||||
});
|
||||
} else if (line.startsWith('> ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'quote',
|
||||
content: line.substring(2),
|
||||
});
|
||||
} else if (line.startsWith('```')) {
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'code',
|
||||
content: codeLines.join('\n'),
|
||||
});
|
||||
} else if (line.match(/^(\*|-) /)) {
|
||||
currentListItems.push(line.substring(2));
|
||||
// Check if next line is not a list item
|
||||
if (i === lines.length - 1 || (!lines[i + 1].match(/^(\*|-) /))) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
content: currentListItems.join('\n'),
|
||||
metadata: { listType: 'bullet' }
|
||||
});
|
||||
currentListItems = [];
|
||||
}
|
||||
} else if (line.match(/^\d+\. /)) {
|
||||
currentListItems.push(line.replace(/^\d+\. /, ''));
|
||||
// Check if next line is not a numbered list item
|
||||
if (i === lines.length - 1 || (!lines[i + 1].match(/^\d+\. /))) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
content: currentListItems.join('\n'),
|
||||
metadata: { listType: 'ordered' }
|
||||
});
|
||||
currentListItems = [];
|
||||
}
|
||||
} else if (line === '---' || line === '***' || line === '___') {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'divider',
|
||||
content: ' ',
|
||||
});
|
||||
} else if (line.trim()) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'paragraph',
|
||||
content: line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
}
|
69
ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts
Normal file
69
ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { type IBlock, type IShortcutPattern, type ISlashMenuItem } from './wysiwyg.types.js';
|
||||
|
||||
export class WysiwygShortcuts {
|
||||
static readonly HEADING_PATTERNS: IShortcutPattern[] = [
|
||||
{ pattern: /^#[\s\u00A0]$/, type: 'heading-1' },
|
||||
{ pattern: /^##[\s\u00A0]$/, type: 'heading-2' },
|
||||
{ pattern: /^###[\s\u00A0]$/, type: 'heading-3' }
|
||||
];
|
||||
|
||||
static readonly LIST_PATTERNS: IShortcutPattern[] = [
|
||||
{ pattern: /^[*-][\s\u00A0]$/, type: 'bullet' },
|
||||
{ pattern: /^(\d+)\.[\s\u00A0]$/, type: 'ordered' },
|
||||
{ pattern: /^(\d+)\)[\s\u00A0]$/, type: 'ordered' }
|
||||
];
|
||||
|
||||
static readonly QUOTE_PATTERN = /^>[\s\u00A0]$/;
|
||||
static readonly CODE_PATTERN = /^```$/;
|
||||
static readonly DIVIDER_PATTERNS = ['---', '***', '___'];
|
||||
|
||||
static checkHeadingShortcut(content: string): { type: IBlock['type'] } | null {
|
||||
for (const { pattern, type } of this.HEADING_PATTERNS) {
|
||||
if (pattern.test(content)) {
|
||||
return { type: type as IBlock['type'] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static checkListShortcut(content: string): { type: 'list', listType: 'bullet' | 'ordered' } | null {
|
||||
for (const { pattern, type } of this.LIST_PATTERNS) {
|
||||
if (pattern.test(content)) {
|
||||
return { type: 'list', listType: type as 'bullet' | 'ordered' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static checkQuoteShortcut(content: string): boolean {
|
||||
return this.QUOTE_PATTERN.test(content);
|
||||
}
|
||||
|
||||
static checkCodeShortcut(content: string): boolean {
|
||||
return this.CODE_PATTERN.test(content);
|
||||
}
|
||||
|
||||
static checkDividerShortcut(content: string): boolean {
|
||||
return this.DIVIDER_PATTERNS.includes(content);
|
||||
}
|
||||
|
||||
static getSlashMenuItems(): ISlashMenuItem[] {
|
||||
return [
|
||||
{ type: 'paragraph', label: 'Paragraph', icon: '¶' },
|
||||
{ type: 'heading-1', label: 'Heading 1', icon: 'H₁' },
|
||||
{ type: 'heading-2', label: 'Heading 2', icon: 'H₂' },
|
||||
{ type: 'heading-3', label: 'Heading 3', icon: 'H₃' },
|
||||
{ type: 'quote', label: 'Quote', icon: '"' },
|
||||
{ type: 'code', label: 'Code', icon: '<>' },
|
||||
{ type: 'list', label: 'List', icon: '•' },
|
||||
{ type: 'divider', label: 'Divider', icon: '—' },
|
||||
];
|
||||
}
|
||||
|
||||
static generateBlockId(): string {
|
||||
return `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the type that is used in this module
|
||||
export type { ISlashMenuItem } from './wysiwyg.types.js';
|
285
ts_web/elements/wysiwyg/wysiwyg.styles.ts
Normal file
285
ts_web/elements/wysiwyg/wysiwyg.styles.ts
Normal file
@ -0,0 +1,285 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const wysiwygStyles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wysiwyg-container {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#1a1a1a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
border-radius: 8px;
|
||||
min-height: 200px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.wysiwyg-container:hover {
|
||||
border-color: ${cssManager.bdTheme('#d0d0d0', '#444')};
|
||||
}
|
||||
|
||||
.wysiwyg-container:focus-within {
|
||||
border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(77, 148, 255, 0.1)')};
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
outline: none;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
min-height: 1.6em;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
}
|
||||
|
||||
.block:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.block.selected {
|
||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.08)', 'rgba(77, 148, 255, 0.12)')};
|
||||
margin-left: -12px;
|
||||
margin-right: -12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.block[contenteditable] {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.block.paragraph {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.block.paragraph:empty::before {
|
||||
content: "Type '/' for commands...";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.block.heading-1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-1:empty::before {
|
||||
content: "Heading 1";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.block.heading-2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 14px;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-2:empty::before {
|
||||
content: "Heading 2";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.block.heading-3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-3:empty::before {
|
||||
content: "Heading 3";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.block.quote {
|
||||
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
padding-left: 16px;
|
||||
font-style: italic;
|
||||
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.block.quote:empty::before {
|
||||
content: "Quote";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.block.code {
|
||||
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
white-space: pre-wrap;
|
||||
color: ${cssManager.bdTheme('#d14', '#ff6b6b')};
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.block.code:empty::before {
|
||||
content: "// Code block";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.block.list {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.block.list ul,
|
||||
.block.list ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.block.list ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.block.list ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.block.list li {
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block.divider {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.block.divider hr {
|
||||
border: none;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slash-menu {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
min-width: 220px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover,
|
||||
.slash-menu-item.selected {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.slash-menu-item .icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover .icon,
|
||||
.slash-menu-item.selected .icon {
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
padding: 4px;
|
||||
display: none;
|
||||
gap: 4px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toolbar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toolbar-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')};
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
`;
|
20
ts_web/elements/wysiwyg/wysiwyg.types.ts
Normal file
20
ts_web/elements/wysiwyg/wysiwyg.types.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export interface IBlock {
|
||||
id: string;
|
||||
type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider';
|
||||
content: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export interface ISlashMenuItem {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export interface IShortcutPattern {
|
||||
pattern: RegExp;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type OutputFormat = 'html' | 'markdown';
|
Reference in New Issue
Block a user