feat: Add WYSIWYG editor components and utilities
- Implemented WysiwygModalManager for managing modals related to code blocks and block settings. - Created WysiwygSelection for handling text selection across Shadow DOM boundaries. - Introduced WysiwygShortcuts for managing keyboard shortcuts and slash menu items. - Developed wysiwygStyles for consistent styling of the WYSIWYG editor. - Defined types for blocks, slash menu items, and shortcut patterns in wysiwyg.types.ts.
This commit is contained in:
295
ts_web/elements/dees-input-wysiwyg/wysiwyg.modalmanager.ts
Normal file
295
ts_web/elements/dees-input-wysiwyg/wysiwyg.modalmanager.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { html, type TemplateResult, cssManager } from '@design.estate/dees-element';
|
||||
import { DeesModal } from '../dees-modal.js';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import { PROGRAMMING_LANGUAGES } from './wysiwyg.constants.js';
|
||||
|
||||
export class WysiwygModalManager {
|
||||
/**
|
||||
* Shows language selection modal for code blocks
|
||||
*/
|
||||
static async showLanguageSelectionModal(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let selectedLanguage: string | null = null;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Select Programming Language',
|
||||
content: html`
|
||||
<style>
|
||||
.language-container {
|
||||
padding: 16px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.language-button {
|
||||
padding: 12px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
.language-button:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
.language-button.selected {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
</style>
|
||||
<div class="language-container">
|
||||
<div class="language-grid">
|
||||
${this.getLanguages().map(lang => html`
|
||||
<div
|
||||
class="language-button ${selectedLanguage === lang.toLowerCase() ? 'selected' : ''}"
|
||||
@click="${() => {
|
||||
selectedLanguage = lang.toLowerCase();
|
||||
// Close modal by finding it in DOM
|
||||
const modal = document.querySelector('dees-modal');
|
||||
if (modal && typeof (modal as any).destroy === 'function') {
|
||||
(modal as any).destroy();
|
||||
}
|
||||
resolve(selectedLanguage);
|
||||
}}">
|
||||
${lang}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows block settings modal
|
||||
*/
|
||||
static async showBlockSettingsModal(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): Promise<void> {
|
||||
|
||||
const content = html`
|
||||
<style>
|
||||
.settings-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.settings-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.settings-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.block-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.block-type-button {
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
.block-type-button:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
.block-type-button.selected {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
.block-type-icon {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
<div class="settings-container">
|
||||
${this.getBlockTypeSelector(block, onUpdate)}
|
||||
${block.type === 'code' ? this.getCodeBlockSettings(block, onUpdate) : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Block Settings',
|
||||
content,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Done',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets code block settings content
|
||||
*/
|
||||
private static getCodeBlockSettings(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): TemplateResult {
|
||||
const currentLanguage = block.metadata?.language || 'javascript';
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.language-button {
|
||||
padding: 8px 4px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
.language-button:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
.language-button.selected {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
</style>
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Programming Language</div>
|
||||
<div class="language-grid">
|
||||
${this.getLanguages().map(lang => html`
|
||||
<div
|
||||
class="language-button ${currentLanguage === lang.toLowerCase() ? 'selected' : ''}"
|
||||
@click="${() => {
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.language = lang.toLowerCase();
|
||||
onUpdate(block);
|
||||
|
||||
// Close modal immediately
|
||||
const modal = document.querySelector('dees-modal');
|
||||
if (modal && typeof (modal as any).destroy === 'function') {
|
||||
(modal as any).destroy();
|
||||
}
|
||||
}}"
|
||||
data-lang="${lang}"
|
||||
>${lang}</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets available programming languages
|
||||
*/
|
||||
private static getLanguages(): string[] {
|
||||
return [...PROGRAMMING_LANGUAGES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block type selector
|
||||
*/
|
||||
private static getBlockTypeSelector(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): TemplateResult {
|
||||
const blockTypes = WysiwygShortcuts.getSlashMenuItems().filter(item => item.type !== 'divider');
|
||||
|
||||
return html`
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Block Type</div>
|
||||
<div class="block-type-grid">
|
||||
${blockTypes.map(item => html`
|
||||
<div
|
||||
class="block-type-button ${block.type === item.type ? 'selected' : ''}"
|
||||
@click="${async (e: MouseEvent) => {
|
||||
const button = e.currentTarget as HTMLElement;
|
||||
|
||||
const oldType = block.type;
|
||||
block.type = item.type as IBlock['type'];
|
||||
|
||||
// Reset metadata for type change
|
||||
if (oldType === 'code' && block.type !== 'code') {
|
||||
delete block.metadata?.language;
|
||||
} else if (oldType === 'list' && block.type !== 'list') {
|
||||
delete block.metadata?.listType;
|
||||
} else if (block.type === 'list' && !block.metadata?.listType) {
|
||||
block.metadata = { listType: 'bullet' };
|
||||
} else if (block.type === 'code' && !block.metadata?.language) {
|
||||
// Ask for language if changing to code block
|
||||
const language = await this.showLanguageSelectionModal();
|
||||
if (language) {
|
||||
block.metadata = { language };
|
||||
} else {
|
||||
// User cancelled, revert
|
||||
block.type = oldType;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(block);
|
||||
|
||||
// Close modal immediately
|
||||
const modal = document.querySelector('dees-modal');
|
||||
if (modal && typeof (modal as any).destroy === 'function') {
|
||||
(modal as any).destroy();
|
||||
}
|
||||
}}"
|
||||
>
|
||||
<span class="block-type-icon">${item.icon}</span>
|
||||
<span>${item.label}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user