fix(dees-modal): theming

This commit is contained in:
Juergen Kunz
2025-06-24 10:45:06 +00:00
parent c82c407350
commit 8b02c5aea3
18 changed files with 2283 additions and 600 deletions

View File

@ -16,7 +16,7 @@
"license": "MIT",
"dependencies": {
"@design.estate/dees-domtools": "^2.3.3",
"@design.estate/dees-element": "^2.0.44",
"@design.estate/dees-element": "^2.0.45",
"@design.estate/dees-wcctools": "^1.0.98",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
@ -30,7 +30,7 @@
"apexcharts": "^4.7.0",
"highlight.js": "11.11.1",
"ibantools": "^4.5.1",
"lucide": "^0.518.0",
"lucide": "^0.522.0",
"monaco-editor": "^0.52.2",
"pdfjs-dist": "^4.10.38",
"xterm": "^5.3.0",

24
pnpm-lock.yaml generated
View File

@ -12,8 +12,8 @@ importers:
specifier: ^2.3.3
version: 2.3.3
'@design.estate/dees-element':
specifier: ^2.0.44
version: 2.0.44
specifier: ^2.0.45
version: 2.0.45
'@design.estate/dees-wcctools':
specifier: ^1.0.98
version: 1.0.98
@ -54,8 +54,8 @@ importers:
specifier: ^4.5.1
version: 4.5.1
lucide:
specifier: ^0.518.0
version: 0.518.0
specifier: ^0.522.0
version: 0.522.0
monaco-editor:
specifier: ^0.52.2
version: 0.52.2
@ -302,8 +302,8 @@ packages:
'@design.estate/dees-domtools@2.3.3':
resolution: {integrity: sha512-diIRuEWNRko508+eXDGVD9yxte+50VSuSsxBvWXUnE7ZPOLo9Y0oNyVi+R1Rb1AVJiXcGCORLdCtmCIcId40VA==}
'@design.estate/dees-element@2.0.44':
resolution: {integrity: sha512-CoTIIrp8R5J2ofAhX56JwWoOuCM8+SH2sIpt+gg1XJM4Jy3NL9YBIN0IHJ3mK3+fusaTtz0YJ+ChmR8OX8JV6g==}
'@design.estate/dees-element@2.0.45':
resolution: {integrity: sha512-dj8nOOtfwvqEtQceTXQQ5IEy75HIFZ+iuDxPeIynLedYpxtHPsxFrHW8IQ7/ad9MNvVO0kTnlwUOmkjylul+DA==}
'@design.estate/dees-wcctools@1.0.98':
resolution: {integrity: sha512-6EolTGBiXgF1wgr+KOeSXAIKpXqU95FU4vOJYPPEvb+e3ebFXCuL/B4UTFZYG3e1KuTZgxiaJ04L8ejm5HfTZA==}
@ -3315,8 +3315,8 @@ packages:
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
engines: {node: '>=16.14'}
lucide@0.518.0:
resolution: {integrity: sha512-lrdHOH8KA76Kr11HnHnuNXgq16RM0L86MS38nghvOnObnfbryE4AsEmuMljLQa6GRTMgovu0MKCtOFOzjZ/lBg==}
lucide@0.522.0:
resolution: {integrity: sha512-85Xqzt9tTP2QCgkjKWvCTPi3M9HjQvqRkLADmQTJuH3cOIjBLujGfDN4f0jm/qIFGCC51iRoFuwJ6wDh/9t3Iw==}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
@ -5325,7 +5325,7 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-element@2.0.44':
'@design.estate/dees-element@2.0.45':
dependencies:
'@design.estate/dees-domtools': 2.3.3
'@push.rocks/isounique': 1.0.5
@ -5340,7 +5340,7 @@ snapshots:
'@design.estate/dees-wcctools@1.0.98':
dependencies:
'@design.estate/dees-domtools': 2.3.3
'@design.estate/dees-element': 2.0.44
'@design.estate/dees-element': 2.0.45
'@push.rocks/smartdelay': 3.0.5
lit: 3.3.0
transitivePeerDependencies:
@ -6312,7 +6312,7 @@ snapshots:
'@push.rocks/smartntml@2.0.8':
dependencies:
'@design.estate/dees-element': 2.0.44
'@design.estate/dees-element': 2.0.45
'@happy-dom/global-registrator': 15.11.7
'@push.rocks/smartpromise': 4.2.3
fake-indexeddb: 6.0.0
@ -9162,7 +9162,7 @@ snapshots:
lru-cache@8.0.5: {}
lucide@0.518.0: {}
lucide@0.522.0: {}
make-dir@3.1.0:
dependencies:

View File

@ -136,8 +136,277 @@ The main `DeesInputWysiwyg` component now:
- Removed problematic `setBlockContents()` method
- Content is now managed directly through DOM properties
- Better timing for block creation and focus
- Slash menu no longer disappears immediately on first "/" press
- Focus is properly maintained when slash menu opens
- Removed duplicate event handling methods from main component
- Simplified focus management throughout the editor
#### Additional Refactoring (2025-06-24 - Part 2)
- **Removed duplicate code**: handleBlockInput and handleBlockKeyDown methods removed from main component
- **Simplified focus management**: Removed complex lifecycle methods and timers
- **Fixed slash menu behavior**: Changed to click events and proper event prevention
- **dees-wysiwyg-block component**: Now uses static HTML rendering for better content control
- **Improved formatting preservation**: HTML formatting (bold, italic, etc.) properly preserved in all block types
#### Notes
- Some old methods remain in the main component for backwards compatibility
- These can be removed in a future cleanup once all references are updated
- The refactoring maintains all existing functionality
- All input handling now goes through WysiwygInputHandler
- All keyboard handling goes through WysiwygKeyboardHandler
- The slash menu uses click events instead of mousedown for better UX
- Focus is maintained using requestAnimationFrame for better timing
- The refactoring maintains all existing functionality with improved reliability
### Global Menu Architecture (2025-06-24 - Part 3)
The slash menu and formatting menu have been refactored to render globally instead of inside the wysiwyg component. This fixes focus loss issues that were occurring when the menus were re-rendered with the component.
#### Key Components:
1. **DeesSlashMenu** (`dees-slash-menu.ts`)
- Singleton component that renders globally in the document body
- Accessed via `DeesSlashMenu.getInstance()`
- Manages its own visibility, position, and filtering
- Emits callbacks when items are selected
2. **DeesFormattingMenu** (`dees-formatting-menu.ts`)
- Singleton component that renders globally in the document body
- Accessed via `DeesFormattingMenu.getInstance()`
- Shows when text is selected
- Applies formatting commands via callback
3. **Integration in DeesInputWysiwyg**
- Stores singleton instances: `private slashMenu = DeesSlashMenu.getInstance()`
- Shows menus with absolute positioning
- Menus handle their own rendering and state management
#### Benefits:
- No focus loss when menus appear/disappear
- Better performance (menus don't re-render with component)
- Cleaner separation of concerns
- Menus persist across component updates
#### Usage:
```typescript
// Show slash menu
this.slashMenu.show(
{ x: cursorX, y: cursorY },
(type: string) => this.insertBlock(type)
);
// Show formatting menu
this.formattingMenu.show(
{ x: selectionX, y: selectionY },
(command: string) => this.applyFormat(command)
);
```
#### Previous Issues Fixed:
- Slash menu was disappearing immediately on first "/" press
- Focus was lost when menus appeared
- Text selection was not working properly
- Cursor position was lost after menu interactions
### Arrow Key Navigation (2025-06-24 - Part 4)
Enhanced arrow key handling for seamless navigation between blocks:
#### Features:
1. **ArrowUp at block start**: Automatically navigates to the end of the previous block
2. **ArrowDown at block end**: Automatically navigates to the beginning of the next block
3. **Smart detection**: Checks actual cursor position within the block content
4. **Slash menu integration**: When slash menu is open, arrow keys navigate menu items instead
5. **No focus loss**: Navigation maintains focus throughout
#### Implementation:
- Added `handleArrowUp()` and `handleArrowDown()` methods to `WysiwygKeyboardHandler`
- Smart cursor position detection for different block types (text, lists, etc.)
- Helper method `getLastTextNode()` for finding the last text position in complex HTML
- Prevents default behavior only when navigating between blocks
- Skips divider blocks during navigation
### Focus Management Improvements (2025-06-24 - Part 5)
Enhanced focus management to prevent focus loss during various operations:
#### Key Improvements:
1. **Formatting Without execCommand**:
- Replaced deprecated `document.execCommand` with modern DOM manipulation
- Proper selection restoration after formatting
- Async formatting operations to maintain focus
2. **Link Dialog**:
- Replaced `prompt()` with custom modal dialog
- Maintains focus context during async operations
- Auto-focuses input field in modal
3. **Robust Focus Methods**:
- Double `requestAnimationFrame` for DOM update timing
- Fallback focus attempts with microtasks
- Contenteditable attribute verification
4. **Cursor Positioning**:
- Enhanced `setCursorToStart/End` with edge case handling
- Zero-width space insertion for empty elements
- Recursive node traversal for complex HTML structures
5. **Async Keyboard Shortcuts**:
- Formatting shortcuts use Promise resolution
- Prevents focus loss during rapid keyboard input
#### Implementation Details:
- `focusWithCursor()` method now handles empty blocks and complex HTML
- `applyFormat()` is async and properly restores selection
- Link creation no longer uses blocking `prompt()` dialog
- All focus operations use proper timing with RAF and microtasks
### Focus Loss Prevention for Menus (2025-06-24 - Part 6)
Fixed focus loss issues when slash menu and formatting menu appear:
#### Key Fixes:
1. **Timeout Reduction**:
- Replaced 50ms setTimeout with requestAnimationFrame
- Immediate focus attempt before falling back to RAF
- Reduced delay when inserting blocks
2. **Menu Focus Prevention**:
- Added `tabindex="-1"` to prevent menus from taking focus
- Added focus event prevention on menus
- Menus now use mousedown prevention consistently
3. **Blur Event Handling**:
- Skip value updates when slash menu is visible
- Prevent auto-save during slash menu interaction
- Maintain focus after menu appears with RAF
4. **Block Focus Optimization**:
- Try immediate focus if block element exists
- Fall back to RAF only when necessary
- Consistent focus handling across all block types
#### Implementation:
- `handleBlockBlur()` checks if slash menu is visible before updating
- `scheduleAutoSave()` skips saving when slash menu is open
- Slash menu show adds RAF to restore focus if lost
- Reduced timing delays throughout the focus chain
### Slash Command Cleanup (2025-06-24 - Part 7)
Fixed the issue where "/" remained in the editor after selecting a block type:
#### The Fix:
1. **In `insertBlock()`**:
- Clear slash command before transforming block type
- Use regex `/^\/[^\s]*\s*/` to match slash + filter text
- Trim the result to ensure clean content
- Set content to empty for transformed blocks
2. **Improved Content Handling**:
- Wait for `updateComplete` before focusing
- Ensure lists start with empty content
- Consistent cleanup in both `insertBlock` and `closeSlashMenu`
3. **Edge Cases**:
- Handle filtered commands (e.g., "/hea" for heading)
- Clear content even with partial matches
- Proper content reset for all block types
Now when selecting a block type from the slash menu, the "/" and any filter text is properly removed before the block transformation occurs.
### Enhanced Enter Key and Block Settings (2025-06-24 - Part 8)
Added two major improvements to the wysiwyg editor:
#### 1. Smart Enter Key Behavior:
When pressing Enter, content after the cursor is now moved to the next block:
- **Content Splitting**: Uses Range API to extract content after cursor
- **HTML Preservation**: Maintains formatting when splitting blocks
- **Clean Split**: Current block keeps content before cursor, new block gets content after
- **Empty Block**: If cursor is at end, creates empty new block
Implementation in `WysiwygKeyboardHandler.handleEnter()`:
```typescript
// Clone the range to extract content after cursor
const afterRange = range.cloneRange();
afterRange.selectNodeContents(target);
afterRange.setStart(range.endContainer, range.endOffset);
// Extract content after cursor
const afterContent = afterRange.extractContents();
```
#### 2. Block Type Changing via Settings Menu:
The block settings menu (three dots) now includes block type selection:
- **Type Selector Grid**: Shows all available block types with icons
- **Smart Metadata Handling**:
- Clears code language when changing from code block
- Clears list type when changing from list
- Prompts for language when changing to code block
- **Visual Feedback**: Currently selected type is highlighted
- **Instant Update**: Block transforms immediately on selection
Features:
- Works for all block types (not just code blocks)
- Preserves content during type transformation
- Handles special cases like code block language selection
- Modal closes automatically after selection
### Complete WYSIWYG Refactoring (2025-06-24 - Part 9)
Major architectural improvements to fix Enter key behavior and left arrow focus loss:
#### 1. Async Operation Architecture:
- All focus operations are now async with proper Promise handling
- `insertBlockAfter()` waits for component updates before focusing
- `focusBlock()` ensures DOM is ready with `updateComplete`
- Eliminated arbitrary timeouts in favor of proper async/await
#### 2. Enter Key Split Content Fix:
- Added `getSplitContent()` method to block component
- Properly extracts content before/after cursor using Range API
- Updates current block and creates new block atomically
- Content after cursor correctly moves to new block
```typescript
// In block component
public getSplitContent(): { before: string; after: string } | null {
const beforeRange = range.cloneRange();
beforeRange.selectNodeContents(this.blockElement);
beforeRange.setEnd(range.startContainer, range.startOffset);
// ... extract and return split content
}
```
#### 3. Arrow Key Navigation:
- Added ArrowLeft/ArrowRight handlers for block boundaries
- Prevents focus loss when navigating between blocks
- Only intercepts at block boundaries, normal navigation otherwise
- All arrow key operations are async for proper timing
#### 4. Interface Architecture:
Created `wysiwyg.interfaces.ts` with proper typing:
- `IWysiwygComponent` - Main component contract
- `IBlockOperations` - Block operation methods
- `IWysiwygBlockComponent` - Block component interface
- `IBlockEventHandlers` - Event handler signatures
#### 5. Focus Management Improvements:
- Eliminated double RAF in favor of single async flow
- Focus operations wait for DOM updates via `updateComplete`
- Proper cursor positioning after all operations
- No more focus loss during navigation
#### Key Changes:
1. Keyboard handler methods are now async
2. Block operations return Promises
3. Enter key properly splits content at cursor
4. Arrow keys handle block navigation without focus loss
5. All timing is handled via proper async/await patterns
The refactoring eliminates race conditions and timing issues that were causing focus loss and content duplication problems.

82
readme.refactoring.md Normal file
View File

@ -0,0 +1,82 @@
# WYSIWYG Editor Refactoring
## Summary of Changes
This refactoring cleaned up the wysiwyg editor implementation to fix focus, cursor position, and selection issues.
### Phase 1: Code Organization
#### 1. Removed Duplicate Code
- Removed duplicate `handleBlockInput` method from main component (was already in inputHandler)
- Removed duplicate `handleBlockKeyDown` method from main component (was already in keyboardHandler)
- Consolidated all input handling in the respective handler classes
#### 2. Simplified Focus Management
- Removed complex `updated` lifecycle method that was trying to maintain focus
- Simplified `handleBlockBlur` to not immediately close menus
- Added `requestAnimationFrame` to focus operations for better timing
- Removed `slashMenuOpenTime` tracking which was no longer needed
#### 3. Fixed Slash Menu Behavior
- Changed from `@mousedown` to `@click` events for better UX
- Added proper event prevention to avoid focus loss
- Menu now closes when clicking outside
- Simplified the insertBlock method to close menu first
### Phase 2: Cursor & Selection Fixes
#### 4. Enhanced Cursor Position Management
- Added `focusWithCursor()` method to block component for precise cursor positioning
- Improved `handleSlashCommand` to preserve cursor position when menu opens
- Added `getCaretCoordinates()` for accurate menu positioning based on cursor location
- Updated `focusBlock()` to support numeric cursor positions
#### 5. Fixed Selection Across Shadow DOM
- Added custom `block-text-selected` event to communicate selections across shadow boundaries
- Implemented `handleMouseUp()` in block component to detect selections
- Updated main component to listen for selection events from blocks
- Selection now works properly even with nested shadow DOMs
#### 6. Improved Slash Menu Close Behavior
- Added optional `clearSlash` parameter to `closeSlashMenu()`
- Escape key now properly clears the slash command
- Clicking outside clears the slash if menu is open
- Selecting an item preserves content and just transforms the block
### Technical Improvements
#### Block Component (`dees-wysiwyg-block`)
- Better focus management with immediate focus (removed unnecessary requestAnimationFrame)
- Added cursor position control methods
- Custom event dispatching for cross-shadow-DOM communication
- Improved content handling for different block types
#### Input Handler
- Preserves cursor position when showing slash menu
- Better caret coordinate calculation for menu positioning
- Ensures focus stays in the block when menu appears
#### Block Operations
- Enhanced `focusBlock()` to support start/end/numeric positions
- Better timing with requestAnimationFrame for focus operations
### Key Benefits
- Slash menu no longer causes focus or cursor position loss
- Text selection works properly across shadow DOM boundaries
- Cursor position is preserved when interacting with menus
- Cleaner, more maintainable code structure
- Better separation of concerns
## Testing
Use the test files in `.nogit/debug/`:
- `test-slash-menu.html` - Tests slash menu focus behavior
- `test-wysiwyg-formatting.html` - Tests text formatting
## Known Issues Fixed
- Slash menu disappearing immediately on first "/"
- Focus lost when slash menu opens
- Cursor position lost when typing "/"
- Text selection not working properly
- Selection events not propagating across shadow DOM
- Duplicate event handling causing conflicts

View File

@ -0,0 +1,178 @@
import {
customElement,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import { WysiwygFormatting } from './wysiwyg.formatting.js';
declare global {
interface HTMLElementTagNameMap {
'dees-formatting-menu': DeesFormattingMenu;
}
}
@customElement('dees-formatting-menu')
export class DeesFormattingMenu extends DeesElement {
private static instance: DeesFormattingMenu;
public static getInstance(): DeesFormattingMenu {
if (!DeesFormattingMenu.instance) {
DeesFormattingMenu.instance = new DeesFormattingMenu();
document.body.appendChild(DeesFormattingMenu.instance);
}
return DeesFormattingMenu.instance;
}
@state()
public visible: boolean = false;
@state()
private position: { x: number; y: number } = { x: 0, y: 0 };
private callback: ((command: string) => void | Promise<void>) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
z-index: 10000;
pointer-events: none;
}
.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;
pointer-events: auto;
user-select: none;
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;
}
`,
];
render(): TemplateResult {
if (!this.visible) return html``;
return html`
<div
class="formatting-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
@mousedown="${(e: MouseEvent) => {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}}"
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}"
@focus="${(e: FocusEvent) => {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}}"
>
${WysiwygFormatting.formatButtons.map(button => html`
<button
class="format-button ${button.command}"
@click="${() => this.applyFormat(button.command)}"
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
>
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
</button>
`)}
</div>
`;
}
private applyFormat(command: string): void {
if (this.callback) {
this.callback(command);
}
// Don't hide menu after applying format (except for link)
if (command === 'link') {
this.hide();
}
}
public show(position: { x: number; y: number }, callback: (command: string) => void | Promise<void>): void {
this.position = position;
this.callback = callback;
this.visible = true;
}
public hide(): void {
this.visible = false;
this.callback = null;
}
public updatePosition(position: { x: number; y: number }): void {
this.position = position;
}
}

View File

@ -24,7 +24,9 @@ import {
WysiwygInputHandler,
WysiwygKeyboardHandler,
WysiwygDragDropHandler,
WysiwygModalManager
WysiwygModalManager,
DeesSlashMenu,
DeesFormattingMenu
} from './index.js';
declare global {
@ -55,17 +57,8 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
@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;
// Slash menu is now globally rendered
private slashMenu = DeesSlashMenu.getInstance();
@state()
private draggedBlockId: string | null = null;
@ -76,11 +69,8 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
@state()
private dragOverPosition: 'before' | 'after' | null = null;
@state()
private showFormattingMenu: boolean = false;
@state()
private formattingMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
// Formatting menu is now globally rendered
private formattingMenu = DeesFormattingMenu.getInstance();
@state()
private selectedText: string = '';
@ -129,8 +119,17 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
// Add global selection listener
console.log('Adding selectionchange listener');
document.addEventListener('selectionchange', this.selectionChangeHandler);
// Listen for custom selection events from blocks
this.addEventListener('block-text-selected', (e: CustomEvent) => {
if (!this.slashMenu.visible) {
this.selectedText = e.detail.text;
this.updateFormattingMenuPosition();
}
});
}
render(): TemplateResult {
return html`
<dees-label
@ -145,8 +144,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
>
${this.blocks.map(block => this.renderBlock(block))}
</div>
${this.showSlashMenu ? this.renderSlashMenu() : ''}
${this.showFormattingMenu ? this.renderFormattingMenu() : ''}
</div>
`;
}
@ -173,15 +170,19 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
@dragend="${() => this.dragDropHandler.handleDragEnd()}"
></div>
` : ''}
${WysiwygBlocks.renderBlock(block, isSelected, {
onInput: (e: InputEvent) => this.inputHandler.handleBlockInput(e, block),
onKeyDown: (e: KeyboardEvent) => this.keyboardHandler.handleBlockKeyDown(e, block),
onFocus: () => this.handleBlockFocus(block),
onBlur: () => this.handleBlockBlur(block),
onCompositionStart: () => this.isComposing = true,
onCompositionEnd: () => this.isComposing = false,
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
})}
<dees-wysiwyg-block
.block="${block}"
.isSelected="${isSelected}"
.handlers="${{
onInput: (e: InputEvent) => this.inputHandler.handleBlockInput(e, block),
onKeyDown: (e: KeyboardEvent) => this.keyboardHandler.handleBlockKeyDown(e, block),
onFocus: () => this.handleBlockFocus(block),
onBlur: () => this.handleBlockBlur(block),
onCompositionStart: () => this.isComposing = true,
onCompositionEnd: () => this.isComposing = false,
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
}}"
></dees-wysiwyg-block>
${block.type !== 'divider' ? html`
<div
class="block-settings"
@ -205,311 +206,56 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
`;
}
public 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 renderFormattingMenu(): TemplateResult {
return WysiwygFormatting.renderFormattingMenu(
this.formattingMenuPosition,
(command) => this.applyFormat(command)
);
}
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 if (block.type === 'code') {
// For code blocks, preserve the exact text content
block.content = target.textContent || '';
} else {
// For other blocks, preserve HTML formatting
block.content = target.innerHTML || '';
}
// Check for block type change intents (use text content for detection, not HTML)
const textContent = target.textContent || '';
const detectedType = this.detectBlockTypeIntent(textContent);
// 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 newBlock = this.createNewBlock();
this.insertBlockAfter(block, newBlock);
this.updateValue();
this.requestUpdate();
return;
} else if (detectedType.type === 'code') {
// For code blocks, ask for language
WysiwygModalManager.showLanguageSelectionModal().then(language => {
if (language) {
block.type = 'code';
block.content = '';
block.metadata = { language };
// Clear the DOM element immediately
target.textContent = '';
// Force update
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 (textContent === '/' || (textContent.startsWith('/') && this.showSlashMenu)) {
// Only show menu on initial '/', or update filter if already showing
if (!this.showSlashMenu && textContent === '/') {
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 = textContent.slice(1);
} else if (!textContent.startsWith('/')) {
this.closeSlashMenu();
}
// Don't update value on every input - let the browser handle typing normally
// But schedule a save after a delay
// Removed - now handled by inputHandler
}
private handleBlockKeyDown(e: KeyboardEvent, block: IBlock) {
if (this.showSlashMenu && ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
this.handleSlashMenuKeyboard(e);
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
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') {
// Handle code blocks specially
if (block.type === 'code') {
if (e.shiftKey) {
// Shift+Enter in code blocks creates a new block
e.preventDefault();
const newBlock = this.createNewBlock();
this.insertBlockAfter(block, newBlock);
}
// For normal Enter in code blocks, let the browser handle it (creates new line)
return;
}
// For other block types, handle Enter normally (without shift)
if (!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 newBlock = this.createNewBlock();
this.insertBlockAfter(block, newBlock);
}
// Otherwise, let the browser handle creating new list items
}
return;
}
e.preventDefault();
const newBlock = this.createNewBlock();
this.insertBlockAfter(block, newBlock);
}
} 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 wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${prevBlock.id}"]`);
if (wrapperElement && prevBlock.type !== 'divider') {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.focus();
WysiwygBlocks.setCursorToEnd(blockElement);
}
}
});
}
}
}
private handleSlashMenuKeyboard(e: KeyboardEvent) {
const menuItems = this.getFilteredMenuItems();
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
this.slashMenuSelectedIndex = (this.slashMenuSelectedIndex + 1) % menuItems.length;
this.slashMenu.navigate('down');
break;
case 'ArrowUp':
e.preventDefault();
this.slashMenuSelectedIndex = this.slashMenuSelectedIndex === 0
? menuItems.length - 1
: this.slashMenuSelectedIndex - 1;
this.slashMenu.navigate('up');
break;
case 'Enter':
e.preventDefault();
if (menuItems[this.slashMenuSelectedIndex]) {
this.insertBlock(menuItems[this.slashMenuSelectedIndex].type as IBlock['type']);
}
this.slashMenu.selectCurrent();
break;
case 'Escape':
e.preventDefault();
this.closeSlashMenu();
this.closeSlashMenu(true);
break;
}
}
public closeSlashMenu() {
if (this.showSlashMenu && this.selectedBlockId) {
public closeSlashMenu(clearSlash: boolean = false) {
if (clearSlash && 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) {
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
if (wrapperElement) {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement && (blockElement.textContent || '').startsWith('/')) {
// Clear the slash command text
blockElement.textContent = '';
currentBlock.content = '';
// Ensure cursor stays in the block
blockElement.focus();
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
const content = blockComponent.getContent();
if (content.startsWith('/')) {
// Remove the entire slash command (slash + any filter text)
const cleanContent = content.replace(/^\/[^\s]*\s*/, '').trim();
blockComponent.setContent(cleanContent);
currentBlock.content = cleanContent;
// Focus and set cursor at beginning
requestAnimationFrame(() => {
blockComponent.focusWithCursor(0);
});
}
}
}
}
this.showSlashMenu = false;
this.slashMenuFilter = '';
this.slashMenuSelectedIndex = 0;
this.slashMenu.hide();
}
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
@ -552,33 +298,31 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}
private handleBlockBlur(block: IBlock) {
// Don't update value if slash menu is visible
if (this.slashMenu.visible) {
return;
}
// 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);
// Don't immediately clear selectedBlockId or close menus
// Let click handlers decide what to do
}
private handleEditorClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Close slash menu if clicking outside of it
if (this.slashMenu.visible) {
this.closeSlashMenu(true);
}
// Focus last block if clicking on empty editor area
if (target.classList.contains('editor-content')) {
const lastBlock = this.blocks[this.blocks.length - 1];
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${lastBlock.id}"]`);
if (wrapperElement && lastBlock.type !== 'divider') {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.focus();
WysiwygBlocks.setCursorToEnd(blockElement);
}
if (lastBlock.type !== 'divider') {
this.blockOperations.focusBlock(lastBlock.id, 'end');
}
}
}
@ -592,79 +336,91 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
};
}
private insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): void {
private async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
const blockIndex = this.blocks.findIndex(b => b.id === afterBlock.id);
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
this.updateValue();
this.requestUpdate();
if (focusNewBlock) {
setTimeout(() => {
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`);
if (wrapperElement && newBlock.type !== 'divider') {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.focus();
WysiwygBlocks.setCursorToStart(blockElement);
}
}
}, 50);
if (focusNewBlock && newBlock.type !== 'divider') {
await this.blockOperations.focusBlock(newBlock.id, 'start');
}
}
public async insertBlock(type: IBlock['type']) {
const currentBlockIndex = this.blocks.findIndex(b => b.id === this.selectedBlockId);
const currentBlock = this.blocks[currentBlockIndex];
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
if (currentBlock) {
// If it's a code block, ask for language
if (type === 'code') {
const language = await WysiwygModalManager.showLanguageSelectionModal();
if (!language) {
// User cancelled
this.closeSlashMenu();
return;
}
currentBlock.metadata = { language };
}
currentBlock.type = type;
currentBlock.content = '';
if (type === 'divider') {
currentBlock.content = ' ';
const newBlock = this.createNewBlock();
this.insertBlockAfter(currentBlock, newBlock);
} else if (type === 'list') {
// Handle list type specially
currentBlock.metadata = { listType: 'bullet' }; // Default to bullet list
setTimeout(() => {
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
if (wrapperElement) {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.innerHTML = '<ul><li></li></ul>';
WysiwygBlocks.focusListItem(blockElement);
}
}
});
} else {
// Force update the contenteditable element
setTimeout(() => {
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
if (wrapperElement) {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.textContent = '';
blockElement.focus();
}
}
});
if (!currentBlock) {
this.closeSlashMenu();
return;
}
// Get the block component to extract clean content
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
// Clear the slash command from the content before transforming
if (blockComponent) {
const content = blockComponent.getContent();
if (content.startsWith('/')) {
// Remove the slash and any filter text (including non-word characters)
const cleanContent = content.replace(/^\/[^\s]*\s*/, '').trim();
blockComponent.setContent(cleanContent);
currentBlock.content = cleanContent;
}
}
this.closeSlashMenu();
// Close menu
this.closeSlashMenu(false);
// If it's a code block, ask for language
if (type === 'code') {
const language = await WysiwygModalManager.showLanguageSelectionModal();
if (!language) {
return; // User cancelled
}
currentBlock.metadata = { language };
}
// Transform the current block
currentBlock.type = type;
currentBlock.content = currentBlock.content || '';
if (type === 'divider') {
currentBlock.content = ' ';
const newBlock = this.createNewBlock();
this.insertBlockAfter(currentBlock, newBlock);
} else if (type === 'list') {
currentBlock.metadata = { listType: 'bullet' };
// For lists, ensure we start with empty content
currentBlock.content = '';
} else {
// For all other block types, ensure content is clean
currentBlock.content = currentBlock.content || '';
}
// Update and refocus
this.updateValue();
this.requestUpdate();
// Wait for update to complete before focusing
await this.updateComplete;
// Focus the block after rendering
if (type === 'list') {
this.blockOperations.focusBlock(currentBlock.id, 'start');
// Additional list-specific focus handling
requestAnimationFrame(() => {
const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${currentBlock.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
blockComponent.focusListItem();
}
});
} else if (type !== 'divider') {
this.blockOperations.focusBlock(currentBlock.id, 'start');
}
}
private updateValue() {
@ -833,35 +589,10 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
private handleTextSelection(e: MouseEvent): void {
// Stop event to prevent it from bubbling up
e.stopPropagation();
// Don't interfere with slash menu
if (this.slashMenu.visible) return;
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);
// Let the block component handle selection via custom event
}
private handleSelectionChange(): void {
@ -902,7 +633,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
this.selectedText = selectedText;
this.updateFormattingMenuPosition();
}
} else if (this.showFormattingMenu) {
} else if (this.formattingMenu.visible) {
console.log('No text selected, hiding menu');
this.hideFormattingMenu();
}
@ -922,42 +653,33 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}
const containerRect = container.getBoundingClientRect();
this.formattingMenuPosition = {
const 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);
console.log('Setting menu position:', formattingMenuPosition);
// Show the global formatting menu
this.formattingMenu.show(
{ x: coords.x, y: coords.y }, // Use absolute coordinates
async (command: string) => await this.applyFormat(command)
);
} else {
console.log('No coordinates found');
}
}
private hideFormattingMenu(): void {
this.showFormattingMenu = false;
this.formattingMenu.hide();
this.selectedText = '';
}
public applyFormat(command: string): void {
public async applyFormat(command: string): Promise<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
// Get the current block
const anchorNode = selection.anchorNode;
const blockElement = anchorNode?.nodeType === Node.TEXT_NODE
? anchorNode.parentElement?.closest('.block')
@ -965,31 +687,120 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
if (!blockElement) return;
const blockId = blockElement.closest('.block-wrapper')?.getAttribute('data-block-id');
const blockWrapper = blockElement.closest('.block-wrapper');
const blockId = blockWrapper?.getAttribute('data-block-id');
const block = this.blocks.find(b => b.id === blockId);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (!block) return;
if (!block || !blockComponent) return;
// Apply the format
WysiwygFormatting.applyFormat(command);
// Handle link command specially
if (command === 'link') {
const url = await this.showLinkDialog();
if (!url) {
// User cancelled - restore focus to block
blockComponent.focus();
return;
}
WysiwygFormatting.applyFormat(command, url);
} else {
// 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;
}
// Update content after a microtask to ensure DOM is updated
await new Promise(resolve => setTimeout(resolve, 0));
// Force content update
block.content = blockComponent.getContent();
// Update value to persist changes
this.updateValue();
// For link command, close the formatting menu
if (command === 'link') {
this.hideFormattingMenu();
} else if (this.formattingMenu.visible) {
// Update menu position if still showing
this.updateFormattingMenuPosition();
}
// Ensure block still has focus
if (document.activeElement !== blockElement) {
blockComponent.focus();
}
}
private async showLinkDialog(): Promise<string | null> {
return new Promise((resolve) => {
let linkUrl: string | null = null;
this.updateValue();
DeesModal.createAndShow({
heading: 'Add Link',
content: html`
<style>
.link-input {
width: 100%;
padding: 12px;
font-size: 16px;
border: 1px solid var(--dees-color-line-bright);
border-radius: 4px;
background: var(--dees-color-input);
color: var(--dees-color-text);
margin: 16px 0;
}
.link-input:focus {
outline: none;
border-color: var(--dees-color-primary);
}
</style>
<input
class="link-input"
type="url"
placeholder="https://example.com"
@keydown="${(e: KeyboardEvent) => {
if (e.key === 'Enter') {
const input = e.target as HTMLInputElement;
linkUrl = input.value;
// Find and click the OK button
const modal = input.closest('dees-modal');
if (modal) {
const okButton = modal.shadowRoot?.querySelector('.bottomButton:last-child') as HTMLElement;
if (okButton) okButton.click();
}
}
}}"
@input="${(e: InputEvent) => {
linkUrl = (e.target as HTMLInputElement).value;
}}"
/>
`,
menuOptions: [
{
name: 'Cancel',
action: async (modal) => {
modal.destroy();
resolve(null);
}
},
{
name: 'Add Link',
action: async (modal) => {
modal.destroy();
resolve(linkUrl);
}
}
]
});
// Keep selection active
if (command !== 'link') {
this.updateFormattingMenuPosition();
}
}, 10);
// Focus the input after modal is shown
setTimeout(() => {
const input = document.querySelector('dees-modal .link-input') as HTMLInputElement;
if (input) {
input.focus();
}
}, 100);
});
}
private async showLanguageSelectionModal(): Promise<string | null> {

View File

@ -0,0 +1,209 @@
import {
customElement,
property,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import { type ISlashMenuItem } from './wysiwyg.types.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
declare global {
interface HTMLElementTagNameMap {
'dees-slash-menu': DeesSlashMenu;
}
}
@customElement('dees-slash-menu')
export class DeesSlashMenu extends DeesElement {
private static instance: DeesSlashMenu;
public static getInstance(): DeesSlashMenu {
if (!DeesSlashMenu.instance) {
DeesSlashMenu.instance = new DeesSlashMenu();
document.body.appendChild(DeesSlashMenu.instance);
}
return DeesSlashMenu.instance;
}
@state()
public visible: boolean = false;
@state()
private position: { x: number; y: number } = { x: 0, y: 0 };
@state()
private filter: string = '';
@state()
private selectedIndex: number = 0;
private callback: ((type: string) => void) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
z-index: 10000;
pointer-events: none;
}
.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;
min-width: 220px;
max-height: 300px;
overflow-y: auto;
pointer-events: auto;
user-select: none;
animation: fadeInScale 0.15s ease-out;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.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')};
}
`,
];
render(): TemplateResult {
if (!this.visible) return html``;
const menuItems = this.getFilteredMenuItems();
return html`
<div
class="slash-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
@mousedown="${(e: MouseEvent) => {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}}"
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}"
@focus="${(e: FocusEvent) => {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}}"
>
${menuItems.map((item, index) => html`
<div
class="slash-menu-item ${index === this.selectedIndex ? 'selected' : ''}"
@click="${() => this.selectItem(item.type)}"
@mouseenter="${() => this.selectedIndex = index}"
>
<span class="icon">${item.icon}</span>
<span>${item.label}</span>
</div>
`)}
</div>
`;
}
private getFilteredMenuItems(): ISlashMenuItem[] {
const allItems = WysiwygShortcuts.getSlashMenuItems();
return allItems.filter(item =>
this.filter === '' ||
item.label.toLowerCase().includes(this.filter.toLowerCase())
);
}
private selectItem(type: string): void {
if (this.callback) {
this.callback(type);
}
this.hide();
}
public show(position: { x: number; y: number }, callback: (type: string) => void): void {
this.position = position;
this.callback = callback;
this.filter = '';
this.selectedIndex = 0;
this.visible = true;
}
public hide(): void {
this.visible = false;
this.callback = null;
this.filter = '';
this.selectedIndex = 0;
}
public updateFilter(filter: string): void {
this.filter = filter;
this.selectedIndex = 0;
}
public navigate(direction: 'up' | 'down'): void {
const items = this.getFilteredMenuItems();
if (direction === 'down') {
this.selectedIndex = (this.selectedIndex + 1) % items.length;
} else {
this.selectedIndex = this.selectedIndex === 0
? items.length - 1
: this.selectedIndex - 1;
}
}
public selectCurrent(): void {
const items = this.getFilteredMenuItems();
if (items[this.selectedIndex]) {
this.selectItem(items[this.selectedIndex].type);
}
}
}

View File

@ -0,0 +1,551 @@
import {
customElement,
property,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
query,
unsafeStatic,
static as staticHtml,
} from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
declare global {
interface HTMLElementTagNameMap {
'dees-wysiwyg-block': DeesWysiwygBlock;
}
}
@customElement('dees-wysiwyg-block')
export class DeesWysiwygBlock extends DeesElement {
@property({ type: Object })
public block: IBlock;
@property({ type: Boolean })
public isSelected: boolean = false;
@property({ type: Object })
public handlers: {
onInput: (e: InputEvent) => void;
onKeyDown: (e: KeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void;
};
@query('.block')
private blockElement: HTMLDivElement;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.block {
padding: 4px 0;
min-height: 1.6em;
outline: none;
width: 100%;
word-wrap: break-word;
position: relative;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
}
.block:empty:not(:focus)::before {
content: attr(data-placeholder);
color: ${cssManager.bdTheme('#999', '#666')};
position: absolute;
pointer-events: none;
}
.block.heading-1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
margin: 24px 0 8px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
margin: 20px 0 6px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-3 {
font-size: 20px;
font-weight: 600;
line-height: 1.4;
margin: 16px 0 4px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.quote {
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
padding-left: 20px;
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
font-style: italic;
line-height: 1.6;
margin: 16px 0;
}
.block.code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
padding: 16px 20px;
padding-top: 32px;
border-radius: 6px;
white-space: pre-wrap;
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
line-height: 1.5;
overflow-x: auto;
margin: 20px 0;
}
.block.list {
padding: 0;
}
.block.list ul,
.block.list ol {
margin: 0;
padding-left: 24px;
}
.block.list li {
margin: 4px 0;
}
.block.divider {
padding: 0;
margin: 16px 0;
pointer-events: none;
}
.block.divider hr {
border: none;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
margin: 0;
}
/* Formatting styles */
.block :is(b, strong) {
font-weight: 600;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block :is(i, em) {
font-style: italic;
}
.block u {
text-decoration: underline;
}
.block s {
text-decoration: line-through;
}
.block code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 0.9em;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
padding: 2px 6px;
border-radius: 3px;
color: ${cssManager.bdTheme('#d14', '#ff6b6b')};
}
.block a {
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.15s ease;
cursor: pointer;
}
.block a:hover {
border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
.code-language {
position: absolute;
top: 0;
right: 0;
background: ${cssManager.bdTheme('#e1e4e8', '#333333')};
color: ${cssManager.bdTheme('#586069', '#8b949e')};
padding: 4px 12px;
font-size: 12px;
border-radius: 0 6px 0 6px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-transform: lowercase;
z-index: 1;
}
.code-block-container {
position: relative;
margin: 20px 0;
}
/* Selection styles */
.block ::selection {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
color: inherit;
}
/* Paragraph specific styles */
.block.paragraph {
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
/* Strike through */
.block :is(s, strike) {
text-decoration: line-through;
opacity: 0.7;
}
/* List specific margin adjustments */
.block.list li {
margin-bottom: 8px;
line-height: 1.6;
}
.block.list li:last-child {
margin-bottom: 0;
}
/* Block margin adjustments based on type */
:host-context(.block-wrapper:first-child) .block {
margin-top: 0 !important;
}
:host-context(.block-wrapper:last-child) .block {
margin-bottom: 0;
}
/* Selected state */
.block.selected {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.05)', 'rgba(77, 148, 255, 0.08)')};
box-shadow: inset 0 0 0 2px ${cssManager.bdTheme('rgba(0, 102, 204, 0.2)', 'rgba(77, 148, 255, 0.2)')};
border-radius: 4px;
margin-left: -8px;
margin-right: -8px;
padding-left: 8px;
padding-right: 8px;
}
`,
];
protected shouldUpdate(): boolean {
// Only update if the block type or id changes
// Content changes are handled directly in the DOM
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
}
render(): TemplateResult {
if (!this.block) return html``;
if (this.block.type === 'divider') {
return html`
<div class="block divider" data-block-id="${this.block.id}" data-block-type="${this.block.type}">
<hr>
</div>
`;
}
if (this.block.type === 'code') {
const language = this.block.metadata?.language || 'plain text';
return html`
<div class="code-block-container">
<div class="code-language">${language}</div>
<div
class="block code ${this.isSelected ? 'selected' : ''}"
contenteditable="true"
data-block-type="${this.block.type}"
@input="${this.handlers?.onInput}"
@keydown="${this.handlers?.onKeyDown}"
@focus="${this.handlers?.onFocus}"
@blur="${this.handlers?.onBlur}"
@compositionstart="${this.handlers?.onCompositionStart}"
@compositionend="${this.handlers?.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
this.handleMouseUp(e);
this.handlers?.onMouseUp?.(e);
}}"
.textContent="${this.block.content || ''}"
></div>
</div>
`;
}
const placeholder = this.getPlaceholder();
const initialContent = this.getInitialContent();
return staticHtml`
<div
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
contenteditable="true"
data-placeholder="${placeholder}"
@input="${this.handlers?.onInput}"
@keydown="${this.handlers?.onKeyDown}"
@focus="${this.handlers?.onFocus}"
@blur="${this.handlers?.onBlur}"
@compositionstart="${this.handlers?.onCompositionStart}"
@compositionend="${this.handlers?.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
this.handleMouseUp(e);
this.handlers?.onMouseUp?.(e);
}}"
>${unsafeStatic(initialContent)}</div>
`;
}
private getPlaceholder(): string {
switch (this.block.type) {
case 'paragraph':
return "Type '/' for commands...";
case 'heading-1':
return 'Heading 1';
case 'heading-2':
return 'Heading 2';
case 'heading-3':
return 'Heading 3';
case 'quote':
return 'Quote';
default:
return '';
}
}
private getInitialContent(): string {
if (this.block.type === 'list') {
return WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
}
return this.block.content || '';
}
public focus(): void {
if (!this.blockElement) return;
// Ensure the element is focusable
if (!this.blockElement.hasAttribute('contenteditable')) {
this.blockElement.setAttribute('contenteditable', 'true');
}
this.blockElement.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== this.blockElement) {
Promise.resolve().then(() => {
this.blockElement.focus();
});
}
}
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
if (!this.blockElement) return;
// Ensure element is focusable first
if (!this.blockElement.hasAttribute('contenteditable')) {
this.blockElement.setAttribute('contenteditable', 'true');
}
// Focus the element
this.blockElement.focus();
// Set cursor position after focus is established
const setCursor = () => {
const selection = window.getSelection();
if (!selection) return;
if (position === 'start') {
this.setCursorToStart();
} else if (position === 'end') {
this.setCursorToEnd();
} else if (typeof position === 'number') {
// Set cursor at specific position
const range = document.createRange();
const textNode = this.getFirstTextNode(this.blockElement);
if (textNode) {
const length = textNode.textContent?.length || 0;
const safePosition = Math.min(position, length);
range.setStart(textNode, safePosition);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} else if (this.blockElement.childNodes.length === 0) {
// Empty block - create a text node
const emptyText = document.createTextNode('');
this.blockElement.appendChild(emptyText);
range.setStart(emptyText, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
};
// Ensure cursor is set after focus
if (document.activeElement === this.blockElement) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === this.blockElement) {
setCursor();
}
});
}
}
private getFirstTextNode(node: Node): Text | null {
if (node.nodeType === Node.TEXT_NODE) {
return node as Text;
}
for (let i = 0; i < node.childNodes.length; i++) {
const textNode = this.getFirstTextNode(node.childNodes[i]);
if (textNode) return textNode;
}
return null;
}
public getContent(): string {
if (!this.blockElement) return '';
if (this.block.type === 'list') {
const listItems = this.blockElement.querySelectorAll('li');
return Array.from(listItems).map(li => li.innerHTML || '').join('\n');
} else if (this.block.type === 'code') {
return this.blockElement.textContent || '';
} else {
return this.blockElement.innerHTML || '';
}
}
public setContent(content: string): void {
if (!this.blockElement) return;
// Store if we have focus
const hadFocus = document.activeElement === this.blockElement;
if (this.block.type === 'list') {
this.blockElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata);
} else if (this.block.type === 'code') {
this.blockElement.textContent = content;
} else {
this.blockElement.innerHTML = content;
}
// Restore focus if we had it
if (hadFocus) {
this.blockElement.focus();
}
}
public setCursorToStart(): void {
WysiwygBlocks.setCursorToStart(this.blockElement);
}
public setCursorToEnd(): void {
WysiwygBlocks.setCursorToEnd(this.blockElement);
}
public focusListItem(): void {
if (this.block.type === 'list') {
WysiwygBlocks.focusListItem(this.blockElement);
}
}
/**
* Gets content split at cursor position
*/
public getSplitContent(): { before: string; after: string } | null {
if (!this.blockElement) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return {
before: this.getContent(),
after: ''
};
}
const range = selection.getRangeAt(0);
// Check if selection is within this block
if (!this.blockElement.contains(range.commonAncestorContainer)) {
return null;
}
// Clone the range to extract content before and after cursor
const beforeRange = range.cloneRange();
beforeRange.selectNodeContents(this.blockElement);
beforeRange.setEnd(range.startContainer, range.startOffset);
const afterRange = range.cloneRange();
afterRange.selectNodeContents(this.blockElement);
afterRange.setStart(range.endContainer, range.endOffset);
// Extract content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
return {
before: beforeHtml,
after: afterHtml
};
}
private handleMouseUp(_e: MouseEvent): void {
// Check if we have a selection within this block
setTimeout(() => {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
// Check if selection is within this block
if (this.blockElement && this.blockElement.contains(range.commonAncestorContainer)) {
const selectedText = selection.toString();
if (selectedText.length > 0) {
// Dispatch a custom event that can cross shadow DOM boundaries
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: selectedText,
blockId: this.block.id,
range: range
},
bubbles: true,
composed: true
}));
}
}
}
}, 10);
}
}

View File

@ -1,4 +1,5 @@
export * from './wysiwyg.types.js';
export * from './wysiwyg.interfaces.js';
export * from './wysiwyg.styles.js';
export * from './wysiwyg.converters.js';
export * from './wysiwyg.shortcuts.js';
@ -8,4 +9,7 @@ export * from './wysiwyg.blockoperations.js';
export * from './wysiwyg.inputhandler.js';
export * from './wysiwyg.keyboardhandler.js';
export * from './wysiwyg.dragdrophandler.js';
export * from './wysiwyg.modalmanager.js';
export * from './wysiwyg.modalmanager.js';
export * from './dees-wysiwyg-block.js';
export * from './dees-slash-menu.js';
export * from './dees-formatting-menu.js';

View File

@ -24,7 +24,7 @@ export class WysiwygBlockOperations {
/**
* Inserts a block after the specified block
*/
insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): void {
async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
const blocks = this.component.blocks;
const blockIndex = blocks.findIndex((b: IBlock) => b.id === afterBlock.id);
@ -35,11 +35,14 @@ export class WysiwygBlockOperations {
];
this.component.updateValue();
this.component.requestUpdate();
if (focusNewBlock && newBlock.type !== 'divider') {
setTimeout(() => {
this.focusBlock(newBlock.id);
}, 50);
// Wait for the component to update
await this.component.updateComplete;
// Focus the new block
await this.focusBlock(newBlock.id, 'start');
}
}
@ -68,17 +71,19 @@ export class WysiwygBlockOperations {
/**
* Focuses a specific block
*/
focusBlock(blockId: string, cursorPosition: 'start' | 'end' = 'start'): void {
async focusBlock(blockId: string, cursorPosition: 'start' | 'end' | number = 'start'): Promise<void> {
// First ensure the component is updated
await this.component.updateComplete;
const wrapperElement = this.component.shadowRoot!.querySelector(`[data-block-id="${blockId}"]`);
if (wrapperElement) {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement) {
blockElement.focus();
if (cursorPosition === 'start') {
WysiwygBlocks.setCursorToStart(blockElement);
} else {
WysiwygBlocks.setCursorToEnd(blockElement);
}
const blockComponent = wrapperElement.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
// Wait a frame to ensure the block is rendered
await new Promise(resolve => requestAnimationFrame(resolve));
// Now focus with cursor position
blockComponent.focusWithCursor(cursorPosition);
}
}
}

View File

@ -7,7 +7,8 @@ export class WysiwygBlocks {
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}>`;
// Don't escape HTML to preserve formatting
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
}
static renderBlock(
@ -102,21 +103,88 @@ export class WysiwygBlocks {
}
static setCursorToEnd(element: HTMLElement): void {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(element);
range.collapse(false);
sel!.removeAllRanges();
sel!.addRange(range);
if (!sel) return;
const range = document.createRange();
// Handle different content types
if (element.childNodes.length === 0) {
// Empty element - add a zero-width space to enable cursor
const textNode = document.createTextNode('\u200B');
element.appendChild(textNode);
range.setStart(textNode, 1);
range.collapse(true);
} else {
// Find the last text node or element
const lastNode = this.getLastNode(element);
if (lastNode.nodeType === Node.TEXT_NODE) {
range.setStart(lastNode, lastNode.textContent?.length || 0);
} else {
range.setStartAfter(lastNode);
}
range.collapse(true);
}
sel.removeAllRanges();
sel.addRange(range);
// Remove zero-width space if it was added
if (element.textContent === '\u200B') {
element.textContent = '';
}
}
static setCursorToStart(element: HTMLElement): void {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(element);
range.collapse(true);
sel!.removeAllRanges();
sel!.addRange(range);
if (!sel) return;
const range = document.createRange();
// Handle different content types
if (element.childNodes.length === 0) {
// Empty element
range.setStart(element, 0);
range.collapse(true);
} else {
// Find the first text node or element
const firstNode = this.getFirstNode(element);
if (firstNode.nodeType === Node.TEXT_NODE) {
range.setStart(firstNode, 0);
} else {
range.setStartBefore(firstNode);
}
range.collapse(true);
}
sel.removeAllRanges();
sel.addRange(range);
}
private static getLastNode(element: Node): Node {
if (element.childNodes.length === 0) {
return element;
}
const lastChild = element.childNodes[element.childNodes.length - 1];
if (lastChild.nodeType === Node.TEXT_NODE || lastChild.childNodes.length === 0) {
return lastChild;
}
return this.getLastNode(lastChild);
}
private static getFirstNode(element: Node): Node {
if (element.childNodes.length === 0) {
return element;
}
const firstChild = element.childNodes[0];
if (firstChild.nodeType === Node.TEXT_NODE || firstChild.childNodes.length === 0) {
return firstChild;
}
return this.getFirstNode(firstChild);
}
static focusListItem(listElement: HTMLElement): void {

View File

@ -31,7 +31,8 @@ export class WysiwygConverters {
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}>`;
// Don't escape HTML in list items to preserve formatting
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
}
return '';
case 'divider':
@ -135,7 +136,8 @@ export class WysiwygConverters {
case 'ul':
case 'ol':
const listItems = Array.from(element.querySelectorAll('li'));
const content = listItems.map(li => li.textContent || '').join('\n');
// Use innerHTML to preserve formatting
const content = listItems.map(li => li.innerHTML || '').join('\n');
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'list',

View File

@ -42,46 +42,104 @@ export class WysiwygFormatting {
`;
}
static applyFormat(command: string, value?: string): void {
static applyFormat(command: string, value?: string): boolean {
// Save current selection
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
// Apply format based on command
switch (command) {
case 'bold':
this.wrapSelection(range, 'strong');
break;
case 'italic':
this.wrapSelection(range, 'em');
break;
case 'underline':
this.wrapSelection(range, 'u');
break;
case 'strikeThrough':
document.execCommand(command, false);
this.wrapSelection(range, 's');
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');
}
this.wrapSelection(range, 'code');
break;
case 'link':
const url = value || prompt('Enter URL:');
if (url) {
document.execCommand('createLink', false, url);
// Don't use prompt - return false to indicate we need async input
if (!value) {
return false;
}
this.wrapSelectionWithLink(range, value);
break;
}
return true;
}
private static wrapSelection(range: Range, tagName: string): void {
const selection = window.getSelection();
if (!selection) return;
// Check if we're already wrapped in this tag
const parentElement = range.commonAncestorContainer.parentElement;
if (parentElement && parentElement.tagName.toLowerCase() === tagName) {
// Unwrap
const parent = parentElement.parentNode;
while (parentElement.firstChild) {
parent?.insertBefore(parentElement.firstChild, parentElement);
}
parent?.removeChild(parentElement);
// Restore selection
selection.removeAllRanges();
selection.addRange(range);
} else {
// Wrap selection
const wrapper = document.createElement(tagName);
try {
// Extract and wrap contents
const contents = range.extractContents();
wrapper.appendChild(contents);
range.insertNode(wrapper);
// Select the wrapped content
range.selectNodeContents(wrapper);
selection.removeAllRanges();
selection.addRange(range);
} catch (e) {
console.error('Failed to wrap selection:', e);
}
}
}
private static wrapSelectionWithLink(range: Range, url: string): void {
const selection = window.getSelection();
if (!selection) return;
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
try {
const contents = range.extractContents();
link.appendChild(contents);
range.insertNode(link);
// Select the link
range.selectNodeContents(link);
selection.removeAllRanges();
selection.addRange(range);
} catch (e) {
console.error('Failed to create link:', e);
}
}
static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null {
@ -118,10 +176,33 @@ export class WysiwygFormatting {
}
static isFormattingApplied(command: string): boolean {
try {
return document.queryCommandState(command);
} catch {
return false;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
const container = range.commonAncestorContainer;
const element = container.nodeType === Node.TEXT_NODE
? container.parentElement
: container as Element;
if (!element) return false;
// Check if formatting is applied by looking at parent elements
switch (command) {
case 'bold':
return !!element.closest('b, strong');
case 'italic':
return !!element.closest('i, em');
case 'underline':
return !!element.closest('u');
case 'strikeThrough':
return !!element.closest('s, strike');
case 'code':
return !!element.closest('code');
case 'link':
return !!element.closest('a');
default:
return false;
}
}

View File

@ -42,20 +42,41 @@ export class WysiwygInputHandler {
* Updates block content based on its type
*/
private updateBlockContent(block: IBlock, target: HTMLDivElement): void {
if (block.type === 'list') {
const listItems = target.querySelectorAll('li');
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
// Get the block component for proper content extraction
const wrapperElement = target.closest('.block-wrapper');
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
// Use the block component's getContent method for consistency
block.content = blockComponent.getContent();
const listElement = target.querySelector('ol, ul');
if (listElement) {
block.metadata = {
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
};
// Update list metadata if needed
if (block.type === 'list') {
const listElement = target.querySelector('ol, ul');
if (listElement) {
block.metadata = {
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
};
}
}
} else if (block.type === 'code') {
block.content = target.textContent || '';
} else {
block.content = target.innerHTML || '';
// Fallback if block component not found
if (block.type === 'list') {
const listItems = target.querySelectorAll('li');
// Use innerHTML to preserve formatting
block.content = Array.from(listItems).map(li => li.innerHTML || '').join('\n');
const listElement = target.querySelector('ol, ul');
if (listElement) {
block.metadata = {
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
};
}
} else if (block.type === 'code') {
block.content = target.textContent || '';
} else {
block.content = target.innerHTML || '';
}
}
}
@ -151,24 +172,54 @@ export class WysiwygInputHandler {
* Handles slash command detection and menu display
*/
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
if (textContent === '/' || (textContent.startsWith('/') && this.component.showSlashMenu)) {
if (!this.component.showSlashMenu && textContent === '/') {
this.component.showSlashMenu = true;
this.component.slashMenuSelectedIndex = 0;
const slashMenu = this.component.slashMenu;
const isSlashMenuVisible = slashMenu && slashMenu.visible;
if (textContent === '/' || (textContent.startsWith('/') && isSlashMenuVisible)) {
if (!isSlashMenuVisible && textContent === '/') {
// Get position for menu based on cursor location
const rect = this.getCaretCoordinates(target);
const rect = target.getBoundingClientRect();
const containerRect = this.component.shadowRoot!.querySelector('.wysiwyg-container')!.getBoundingClientRect();
// Show the slash menu at the cursor position
slashMenu.show(
{ x: rect.left, y: rect.bottom + 4 },
(type: string) => {
this.component.insertBlock(type);
}
);
this.component.slashMenuPosition = {
x: rect.left - containerRect.left,
y: rect.bottom - containerRect.top + 4
};
// Ensure the block maintains focus
requestAnimationFrame(() => {
if (document.activeElement !== target) {
target.focus();
}
});
}
// Update filter
if (slashMenu) {
slashMenu.updateFilter(textContent.slice(1));
}
this.component.slashMenuFilter = textContent.slice(1);
} else if (!textContent.startsWith('/')) {
this.component.closeSlashMenu();
}
}
/**
* Gets the coordinates of the caret/cursor
*/
private getCaretCoordinates(element: HTMLElement): DOMRect {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) {
return rect;
}
}
// Fallback to element position
return element.getBoundingClientRect();
}
/**
* Schedules auto-save after a delay
@ -177,6 +228,10 @@ export class WysiwygInputHandler {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
// Don't auto-save if slash menu is open
if (this.component.slashMenu && this.component.slashMenu.visible) {
return;
}
this.saveTimeout = setTimeout(() => {
this.component.updateValue();
}, 1000);

View File

@ -0,0 +1,79 @@
import { type TemplateResult } from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { DeesSlashMenu } from './dees-slash-menu.js';
import { DeesFormattingMenu } from './dees-formatting-menu.js';
/**
* Interface for the main wysiwyg component
*/
export interface IWysiwygComponent {
// State
blocks: IBlock[];
selectedBlockId: string | null;
shadowRoot: ShadowRoot | null;
// Menus
slashMenu: DeesSlashMenu;
formattingMenu: DeesFormattingMenu;
// Methods
updateValue(): void;
requestUpdate(): Promise<void>;
updateComplete: Promise<boolean>;
insertBlock(type: string): Promise<void>;
closeSlashMenu(clearSlash?: boolean): void;
applyFormat(command: string): Promise<void>;
handleSlashMenuKeyboard(e: KeyboardEvent): void;
// Handlers
blockOperations: IBlockOperations;
}
/**
* Interface for block operations
*/
export interface IBlockOperations {
createBlock(type?: IBlock['type'], content?: string, metadata?: any): IBlock;
insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock?: boolean): Promise<void>;
removeBlock(blockId: string): void;
findBlock(blockId: string): IBlock | undefined;
getBlockIndex(blockId: string): number;
focusBlock(blockId: string, cursorPosition?: 'start' | 'end' | number): Promise<void>;
updateBlockContent(blockId: string, content: string): void;
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void;
moveBlock(blockId: string, targetIndex: number): void;
getPreviousBlock(blockId: string): IBlock | null;
getNextBlock(blockId: string): IBlock | null;
splitBlock(blockId: string, splitPosition: number): Promise<IBlock>;
}
/**
* Interface for block component
*/
export interface IWysiwygBlockComponent {
block: IBlock;
isSelected: boolean;
blockElement: HTMLDivElement | null;
focus(): void;
focusWithCursor(position: 'start' | 'end' | number): void;
getContent(): string;
setContent(content: string): void;
setCursorToStart(): void;
setCursorToEnd(): void;
focusListItem(): void;
getSplitContent(splitPosition: number): { before: string; after: string };
}
/**
* Event handler interfaces
*/
export interface IBlockEventHandlers {
onInput: (e: InputEvent) => void;
onKeyDown: (e: KeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void;
}

View File

@ -1,6 +1,4 @@
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
export class WysiwygKeyboardHandler {
private component: any;
@ -12,10 +10,10 @@ export class WysiwygKeyboardHandler {
/**
* Handles keyboard events for blocks
*/
handleBlockKeyDown(e: KeyboardEvent, block: IBlock): void {
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
// Handle slash menu navigation
if (this.component.showSlashMenu && this.isSlashMenuKey(e.key)) {
this.handleSlashMenuKeyboard(e);
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
this.component.handleSlashMenuKeyboard(e);
return;
}
@ -30,10 +28,22 @@ export class WysiwygKeyboardHandler {
this.handleTab(e, block);
break;
case 'Enter':
this.handleEnter(e, block);
await this.handleEnter(e, block);
break;
case 'Backspace':
this.handleBackspace(e, block);
await this.handleBackspace(e, block);
break;
case 'ArrowUp':
await this.handleArrowUp(e, block);
break;
case 'ArrowDown':
await this.handleArrowDown(e, block);
break;
case 'ArrowLeft':
await this.handleArrowLeft(e, block);
break;
case 'ArrowRight':
await this.handleArrowRight(e, block);
break;
}
}
@ -54,19 +64,20 @@ export class WysiwygKeyboardHandler {
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
this.component.applyFormat('bold');
// Use Promise to ensure focus is maintained
Promise.resolve().then(() => this.component.applyFormat('bold'));
return true;
case 'i':
e.preventDefault();
this.component.applyFormat('italic');
Promise.resolve().then(() => this.component.applyFormat('italic'));
return true;
case 'u':
e.preventDefault();
this.component.applyFormat('underline');
Promise.resolve().then(() => this.component.applyFormat('underline'));
return true;
case 'k':
e.preventDefault();
this.component.applyFormat('link');
Promise.resolve().then(() => this.component.applyFormat('link'));
return true;
}
return false;
@ -79,7 +90,18 @@ export class WysiwygKeyboardHandler {
if (block.type === 'code') {
// Allow tab in code blocks
e.preventDefault();
document.execCommand('insertText', false, ' ');
// Insert two spaces for tab
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(' ');
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
}
} else if (block.type === 'list') {
// Future: implement list indentation
e.preventDefault();
@ -89,7 +111,7 @@ export class WysiwygKeyboardHandler {
/**
* Handles Enter key
*/
private handleEnter(e: KeyboardEvent, block: IBlock): void {
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
const blockOps = this.component.blockOperations;
if (block.type === 'code') {
@ -97,7 +119,7 @@ export class WysiwygKeyboardHandler {
// Shift+Enter in code blocks creates a new block
e.preventDefault();
const newBlock = blockOps.createBlock();
blockOps.insertBlockAfter(block, newBlock);
await blockOps.insertBlockAfter(block, newBlock);
}
// Normal Enter in code blocks creates new line (let browser handle it)
return;
@ -105,12 +127,42 @@ export class WysiwygKeyboardHandler {
if (!e.shiftKey) {
if (block.type === 'list') {
this.handleEnterInList(e, block);
await this.handleEnterInList(e, block);
} else {
// Create new paragraph block
// Split content at cursor position
e.preventDefault();
const newBlock = blockOps.createBlock();
blockOps.insertBlockAfter(block, newBlock);
// Get the block component
const target = e.target as HTMLElement;
const blockWrapper = target.closest('.block-wrapper');
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent && blockComponent.getSplitContent) {
const splitContent = blockComponent.getSplitContent();
if (splitContent) {
// Update current block with content before cursor
blockComponent.setContent(splitContent.before);
block.content = splitContent.before;
// Create new block with content after cursor
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
// Insert the new block
await blockOps.insertBlockAfter(block, newBlock);
// Update the value after both blocks are set
this.component.updateValue();
} else {
// Fallback - just create empty block
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
} else {
// No block component or method, just create empty block
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
}
}
// Shift+Enter creates line break (let browser handle it)
@ -119,8 +171,7 @@ export class WysiwygKeyboardHandler {
/**
* Handles Enter key in list blocks
*/
private handleEnterInList(e: KeyboardEvent, block: IBlock): void {
const target = e.target as HTMLDivElement;
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
@ -132,7 +183,7 @@ export class WysiwygKeyboardHandler {
e.preventDefault();
const blockOps = this.component.blockOperations;
const newBlock = blockOps.createBlock();
blockOps.insertBlockAfter(block, newBlock);
await blockOps.insertBlockAfter(block, newBlock);
}
// Otherwise, let browser create new list item
}
@ -141,7 +192,7 @@ export class WysiwygKeyboardHandler {
/**
* Handles Backspace key
*/
private handleBackspace(e: KeyboardEvent, block: IBlock): void {
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
if (block.content === '' && this.component.blocks.length > 1) {
e.preventDefault();
const blockOps = this.component.blockOperations;
@ -150,49 +201,184 @@ export class WysiwygKeyboardHandler {
if (prevBlock) {
blockOps.removeBlock(block.id);
setTimeout(() => {
if (prevBlock.type !== 'divider') {
blockOps.focusBlock(prevBlock.id, 'end');
}
});
if (prevBlock.type !== 'divider') {
await blockOps.focusBlock(prevBlock.id, 'end');
}
}
}
}
/**
* Handles slash menu keyboard navigation
* Handles ArrowUp key - navigate to previous block if at beginning
*/
private handleSlashMenuKeyboard(e: KeyboardEvent): void {
const menuItems = this.component.getFilteredMenuItems();
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const target = e.target as HTMLElement;
switch(e.key) {
case 'ArrowDown':
// Check if cursor is at the beginning of the block
const isAtStart = range.startOffset === 0 && range.endOffset === 0;
if (isAtStart) {
const firstNode = target.firstChild;
const isReallyAtStart = !firstNode ||
(range.startContainer === firstNode && range.startOffset === 0) ||
(range.startContainer === target && range.startOffset === 0);
if (isReallyAtStart) {
e.preventDefault();
this.component.slashMenuSelectedIndex =
(this.component.slashMenuSelectedIndex + 1) % menuItems.length;
break;
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
case 'ArrowUp':
e.preventDefault();
this.component.slashMenuSelectedIndex =
this.component.slashMenuSelectedIndex === 0
? menuItems.length - 1
: this.component.slashMenuSelectedIndex - 1;
break;
case 'Enter':
e.preventDefault();
if (menuItems[this.component.slashMenuSelectedIndex]) {
this.component.insertBlock(
menuItems[this.component.slashMenuSelectedIndex].type as IBlock['type']
);
if (prevBlock && prevBlock.type !== 'divider') {
await blockOps.focusBlock(prevBlock.id, 'end');
}
break;
case 'Escape':
e.preventDefault();
this.component.closeSlashMenu();
break;
}
}
}
/**
* Handles ArrowDown key - navigate to next block if at end
*/
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const target = e.target as HTMLElement;
// Check if cursor is at the end of the block
const lastNode = target.lastChild;
// For different block types, check if we're at the end
let isAtEnd = false;
if (!lastNode) {
// Empty block
isAtEnd = true;
} else if (lastNode.nodeType === Node.TEXT_NODE) {
isAtEnd = range.endContainer === lastNode && range.endOffset === lastNode.textContent?.length;
} else if (block.type === 'list') {
// For lists, check if we're in the last item at the end
const lastLi = target.querySelector('li:last-child');
if (lastLi) {
const lastTextNode = this.getLastTextNode(lastLi);
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
range.endOffset === lastTextNode.textContent?.length;
}
} else {
// For other HTML content
const lastTextNode = this.getLastTextNode(target);
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
range.endOffset === lastTextNode.textContent?.length;
}
if (isAtEnd) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock && nextBlock.type !== 'divider') {
await blockOps.focusBlock(nextBlock.id, 'start');
}
}
}
/**
* Helper to get the last text node in an element
*/
private getLastTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = element.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(element.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
/**
* Handles ArrowLeft key - navigate to previous block if at beginning
*/
private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
// Check if cursor is at the very beginning (collapsed and at offset 0)
if (range.collapsed && range.startOffset === 0) {
const target = e.target as HTMLElement;
const firstNode = target.firstChild;
// Verify we're really at the start
const isAtStart = !firstNode ||
(range.startContainer === firstNode) ||
(range.startContainer === target);
if (isAtStart) {
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock && prevBlock.type !== 'divider') {
e.preventDefault();
await blockOps.focusBlock(prevBlock.id, 'end');
}
}
}
// Otherwise, let the browser handle normal left arrow navigation
}
/**
* Handles ArrowRight key - navigate to next block if at end
*/
private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const target = e.target as HTMLElement;
// Check if cursor is at the very end
if (range.collapsed) {
const textLength = target.textContent?.length || 0;
let isAtEnd = false;
if (textLength === 0) {
// Empty block
isAtEnd = true;
} else if (range.endContainer.nodeType === Node.TEXT_NODE) {
const textNode = range.endContainer as Text;
isAtEnd = range.endOffset === textNode.textContent?.length;
} else {
// Check if we're at the end of the last text node
const lastTextNode = this.getLastTextNode(target);
if (lastTextNode) {
isAtEnd = range.endContainer === lastTextNode &&
range.endOffset === lastTextNode.textContent?.length;
}
}
if (isAtEnd) {
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock && nextBlock.type !== 'divider') {
e.preventDefault();
await blockOps.focusBlock(nextBlock.id, 'start');
}
}
}
// Otherwise, let the browser handle normal right arrow navigation
}
/**
* Handles slash menu keyboard navigation
* Note: This is now handled by the component directly
*/
}

View File

@ -1,6 +1,7 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import { DeesModal } from '../dees-modal.js';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
export class WysiwygModalManager {
/**
@ -74,13 +75,55 @@ export class WysiwygModalManager {
block: IBlock,
onUpdate: (block: IBlock) => void
): Promise<void> {
let content: TemplateResult;
if (block.type === 'code') {
content = this.getCodeBlockSettings(block, onUpdate);
} else {
content = html`<div style="padding: 16px;">No settings available for this block type.</div>`;
}
const content = html`
<style>
.settings-container {
padding: 16px;
}
.settings-section {
margin-bottom: 20px;
}
.settings-label {
font-weight: 500;
margin-bottom: 8px;
color: var(--dees-color-text);
}
.block-type-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.block-type-button {
padding: 12px;
background: var(--dees-color-box);
border: 1px solid var(--dees-color-line-bright);
border-radius: 4px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.block-type-button:hover {
background: var(--dees-color-box-highlight);
border-color: var(--dees-color-primary);
}
.block-type-button.selected {
background: var(--dees-color-primary);
color: white;
}
.block-type-icon {
font-weight: 600;
font-size: 16px;
}
</style>
<div class="settings-container">
${this.getBlockTypeSelector(block, onUpdate)}
${block.type === 'code' ? this.getCodeBlockSettings(block, onUpdate) : ''}
</div>
`;
DeesModal.createAndShow({
heading: 'Block Settings',
@ -170,4 +213,62 @@ export class WysiwygModalManager {
'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'
];
}
/**
* 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 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 after selection
const modal = (e.target as HTMLElement).closest('dees-modal');
if (modal) {
const closeButton = modal.shadowRoot?.querySelector('.bottomButton') as HTMLElement;
if (closeButton) closeButton.click();
}
}}"
>
<span class="block-type-icon">${item.icon}</span>
<span>${item.label}</span>
</div>
`)}
</div>
</div>
`;
}
}

View File

@ -248,6 +248,8 @@ export const wysiwygStyles = css`
min-width: 220px;
max-height: 300px;
overflow-y: auto;
pointer-events: auto;
user-select: none;
}
.slash-menu-item {