Files
dees-catalog/ts_web/elements/dees-input-wysiwyg.ts
2025-06-23 17:05:28 +00:00

1113 lines
34 KiB
TypeScript

import * as colors from './00colors.js';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-wysiwyg.demo.js';
import {
customElement,
type TemplateResult,
property,
html,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
declare global {
interface HTMLElementTagNameMap {
'dees-input-wysiwyg': DeesInputWysiwyg;
}
}
interface IBlock {
id: string;
type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider';
content: string;
metadata?: any;
}
@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: 'html' | 'markdown' = 'html';
@state()
private blocks: IBlock[] = [
{
id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
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;
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
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')};
}
`,
];
async firstUpdated() {
this.updateValue();
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
}
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;
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="${(e: InputEvent) => this.handleBlockInput(e, block)}"
@keydown="${(e: KeyboardEvent) => this.handleBlockKeyDown(e, block)}"
@focus="${() => this.handleBlockFocus(block)}"
@blur="${() => this.handleBlockBlur(block)}"
@compositionstart="${() => this.isComposing = true}"
@compositionend="${() => this.isComposing = false}"
.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="${(e: InputEvent) => this.handleBlockInput(e, block)}"
@keydown="${(e: KeyboardEvent) => this.handleBlockKeyDown(e, block)}"
@focus="${() => this.handleBlockFocus(block)}"
@blur="${() => this.handleBlockBlur(block)}"
@compositionstart="${() => this.isComposing = true}"
@compositionend="${() => this.isComposing = false}"
.textContent="${block.content}"
></div>
`;
}
private 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>${this.escapeHtml(item)}</li>`).join('')}</${listTag}>`;
}
private getFilteredMenuItems() {
const allItems = [
{ 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: '—' },
];
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 shortcuts at the beginning of a paragraph
if (block.type === 'paragraph') {
// Check for heading shortcuts
const headingPatterns = [
{ pattern: /^# $/, type: 'heading-1' },
{ pattern: /^## $/, type: 'heading-2' },
{ pattern: /^### $/, type: 'heading-3' }
];
for (const { pattern, type } of headingPatterns) {
if (pattern.test(block.content)) {
e.preventDefault();
block.type = type as IBlock['type'];
block.content = '';
// Force update the block
setTimeout(() => {
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement;
if (blockElement) {
blockElement.textContent = '';
blockElement.focus();
}
});
this.updateValue();
this.requestUpdate();
return;
}
}
// Check for list shortcuts
const listPatterns = [
{ pattern: /^(\*|-) $/, type: 'bullet' },
{ pattern: /^(\d+)\. $/, type: 'ordered' },
{ pattern: /^(\d+)\) $/, type: 'ordered' }
];
for (const { pattern, type } of listPatterns) {
if (pattern.test(block.content)) {
e.preventDefault();
block.type = 'list';
block.content = '';
block.metadata = { listType: type };
// Force update the block to be a list
setTimeout(() => {
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement;
if (blockElement) {
const listTag = type === 'ordered' ? 'ol' : 'ul';
blockElement.innerHTML = `<${listTag}><li></li></${listTag}>`;
// Focus the first list item
const firstLi = blockElement.querySelector('li');
if (firstLi) {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(firstLi);
range.collapse(true);
sel!.removeAllRanges();
sel!.addRange(range);
}
}
});
this.updateValue();
this.requestUpdate();
return;
}
}
// Check for quote shortcut
if (block.content === '> ') {
e.preventDefault();
block.type = 'quote';
block.content = '';
setTimeout(() => {
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement;
if (blockElement) {
blockElement.textContent = '';
blockElement.focus();
}
});
this.updateValue();
this.requestUpdate();
return;
}
// Check for code block shortcut
if (block.content === '```') {
e.preventDefault();
block.type = 'code';
block.content = '';
setTimeout(() => {
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement;
if (blockElement) {
blockElement.textContent = '';
blockElement.focus();
}
});
this.updateValue();
this.requestUpdate();
return;
}
// Check for divider shortcut
if (block.content === '---' || block.content === '***' || block.content === '___') {
e.preventDefault();
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: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
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;
}
}
// Check for slash commands (should be last to allow other shortcuts to take precedence)
if (block.content.startsWith('/') && block.type === 'paragraph') {
this.slashMenuFilter = block.content.slice(1);
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
};
} else {
this.closeSlashMenu();
}
this.updateValue();
}
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: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
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: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
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();
}
});
} 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);
setTimeout(() => {
const prevBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${prevBlock.id}"]`) as HTMLDivElement;
if (prevBlockElement && prevBlock.type !== 'divider') {
prevBlockElement.focus();
this.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() {
this.showSlashMenu = false;
this.slashMenuFilter = '';
this.slashMenuSelectedIndex = 0;
}
private setCursorToEnd(element: HTMLElement) {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(element);
range.collapse(false);
sel!.removeAllRanges();
sel!.addRange(range);
}
private handleBlockFocus(block: IBlock) {
if (block.type !== 'divider') {
this.selectedBlockId = block.id;
}
}
private handleBlockBlur(block: IBlock) {
setTimeout(() => {
if (this.selectedBlockId === block.id) {
this.selectedBlockId = null;
}
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();
this.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: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
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 {
// 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 = this.getHtmlOutput();
} else {
this.value = this.getMarkdownOutput();
}
this.changeSubject.next(this.value);
}
private getHtmlOutput(): string {
return this.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');
}
private getMarkdownOutput(): string {
return this.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');
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
public getValue(): string {
return this.value;
}
public setValue(value: string): void {
this.value = value;
if (this.outputFormat === 'html') {
this.blocks = this.parseHtmlToBlocks(value);
} else {
this.blocks = this.parseMarkdownToBlocks(value);
}
if (this.blocks.length === 0) {
this.blocks = [{
id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'paragraph',
content: '',
}];
}
this.changeSubject.next(this.value);
this.requestUpdate();
}
private 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).substr(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).substr(2, 9)}`,
type: 'paragraph',
content: element.textContent || '',
});
break;
case 'h1':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'heading-1',
content: element.textContent || '',
});
break;
case 'h2':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'heading-2',
content: element.textContent || '',
});
break;
case 'h3':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'heading-3',
content: element.textContent || '',
});
break;
case 'blockquote':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'quote',
content: element.textContent || '',
});
break;
case 'pre':
case 'code':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substr(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).substr(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).substr(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;
}
private 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).substr(2, 9)}`,
type: 'heading-1',
content: line.substring(2),
});
} else if (line.startsWith('## ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'heading-2',
content: line.substring(3),
});
} else if (line.startsWith('### ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'heading-3',
content: line.substring(4),
});
} else if (line.startsWith('> ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substr(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).substr(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).substr(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).substr(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).substr(2, 9)}`,
type: 'divider',
content: ' ',
});
} else if (line.trim()) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'paragraph',
content: line,
});
}
}
return blocks;
}
}