fix(dees-modal): theming
This commit is contained in:
@ -16,7 +16,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@design.estate/dees-domtools": "^2.3.3",
|
"@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",
|
"@design.estate/dees-wcctools": "^1.0.98",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||||
@ -30,7 +30,7 @@
|
|||||||
"apexcharts": "^4.7.0",
|
"apexcharts": "^4.7.0",
|
||||||
"highlight.js": "11.11.1",
|
"highlight.js": "11.11.1",
|
||||||
"ibantools": "^4.5.1",
|
"ibantools": "^4.5.1",
|
||||||
"lucide": "^0.518.0",
|
"lucide": "^0.522.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
|
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@ -12,8 +12,8 @@ importers:
|
|||||||
specifier: ^2.3.3
|
specifier: ^2.3.3
|
||||||
version: 2.3.3
|
version: 2.3.3
|
||||||
'@design.estate/dees-element':
|
'@design.estate/dees-element':
|
||||||
specifier: ^2.0.44
|
specifier: ^2.0.45
|
||||||
version: 2.0.44
|
version: 2.0.45
|
||||||
'@design.estate/dees-wcctools':
|
'@design.estate/dees-wcctools':
|
||||||
specifier: ^1.0.98
|
specifier: ^1.0.98
|
||||||
version: 1.0.98
|
version: 1.0.98
|
||||||
@ -54,8 +54,8 @@ importers:
|
|||||||
specifier: ^4.5.1
|
specifier: ^4.5.1
|
||||||
version: 4.5.1
|
version: 4.5.1
|
||||||
lucide:
|
lucide:
|
||||||
specifier: ^0.518.0
|
specifier: ^0.522.0
|
||||||
version: 0.518.0
|
version: 0.522.0
|
||||||
monaco-editor:
|
monaco-editor:
|
||||||
specifier: ^0.52.2
|
specifier: ^0.52.2
|
||||||
version: 0.52.2
|
version: 0.52.2
|
||||||
@ -302,8 +302,8 @@ packages:
|
|||||||
'@design.estate/dees-domtools@2.3.3':
|
'@design.estate/dees-domtools@2.3.3':
|
||||||
resolution: {integrity: sha512-diIRuEWNRko508+eXDGVD9yxte+50VSuSsxBvWXUnE7ZPOLo9Y0oNyVi+R1Rb1AVJiXcGCORLdCtmCIcId40VA==}
|
resolution: {integrity: sha512-diIRuEWNRko508+eXDGVD9yxte+50VSuSsxBvWXUnE7ZPOLo9Y0oNyVi+R1Rb1AVJiXcGCORLdCtmCIcId40VA==}
|
||||||
|
|
||||||
'@design.estate/dees-element@2.0.44':
|
'@design.estate/dees-element@2.0.45':
|
||||||
resolution: {integrity: sha512-CoTIIrp8R5J2ofAhX56JwWoOuCM8+SH2sIpt+gg1XJM4Jy3NL9YBIN0IHJ3mK3+fusaTtz0YJ+ChmR8OX8JV6g==}
|
resolution: {integrity: sha512-dj8nOOtfwvqEtQceTXQQ5IEy75HIFZ+iuDxPeIynLedYpxtHPsxFrHW8IQ7/ad9MNvVO0kTnlwUOmkjylul+DA==}
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@1.0.98':
|
'@design.estate/dees-wcctools@1.0.98':
|
||||||
resolution: {integrity: sha512-6EolTGBiXgF1wgr+KOeSXAIKpXqU95FU4vOJYPPEvb+e3ebFXCuL/B4UTFZYG3e1KuTZgxiaJ04L8ejm5HfTZA==}
|
resolution: {integrity: sha512-6EolTGBiXgF1wgr+KOeSXAIKpXqU95FU4vOJYPPEvb+e3ebFXCuL/B4UTFZYG3e1KuTZgxiaJ04L8ejm5HfTZA==}
|
||||||
@ -3315,8 +3315,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
|
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
|
||||||
engines: {node: '>=16.14'}
|
engines: {node: '>=16.14'}
|
||||||
|
|
||||||
lucide@0.518.0:
|
lucide@0.522.0:
|
||||||
resolution: {integrity: sha512-lrdHOH8KA76Kr11HnHnuNXgq16RM0L86MS38nghvOnObnfbryE4AsEmuMljLQa6GRTMgovu0MKCtOFOzjZ/lBg==}
|
resolution: {integrity: sha512-85Xqzt9tTP2QCgkjKWvCTPi3M9HjQvqRkLADmQTJuH3cOIjBLujGfDN4f0jm/qIFGCC51iRoFuwJ6wDh/9t3Iw==}
|
||||||
|
|
||||||
make-dir@3.1.0:
|
make-dir@3.1.0:
|
||||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||||
@ -5325,7 +5325,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@design.estate/dees-element@2.0.44':
|
'@design.estate/dees-element@2.0.45':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-domtools': 2.3.3
|
'@design.estate/dees-domtools': 2.3.3
|
||||||
'@push.rocks/isounique': 1.0.5
|
'@push.rocks/isounique': 1.0.5
|
||||||
@ -5340,7 +5340,7 @@ snapshots:
|
|||||||
'@design.estate/dees-wcctools@1.0.98':
|
'@design.estate/dees-wcctools@1.0.98':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-domtools': 2.3.3
|
'@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
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
lit: 3.3.0
|
lit: 3.3.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@ -6312,7 +6312,7 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/smartntml@2.0.8':
|
'@push.rocks/smartntml@2.0.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-element': 2.0.44
|
'@design.estate/dees-element': 2.0.45
|
||||||
'@happy-dom/global-registrator': 15.11.7
|
'@happy-dom/global-registrator': 15.11.7
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
fake-indexeddb: 6.0.0
|
fake-indexeddb: 6.0.0
|
||||||
@ -9162,7 +9162,7 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@8.0.5: {}
|
lru-cache@8.0.5: {}
|
||||||
|
|
||||||
lucide@0.518.0: {}
|
lucide@0.522.0: {}
|
||||||
|
|
||||||
make-dir@3.1.0:
|
make-dir@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
275
readme.hints.md
275
readme.hints.md
@ -136,8 +136,277 @@ The main `DeesInputWysiwyg` component now:
|
|||||||
- Removed problematic `setBlockContents()` method
|
- Removed problematic `setBlockContents()` method
|
||||||
- Content is now managed directly through DOM properties
|
- Content is now managed directly through DOM properties
|
||||||
- Better timing for block creation and focus
|
- 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
|
#### Notes
|
||||||
- Some old methods remain in the main component for backwards compatibility
|
- All input handling now goes through WysiwygInputHandler
|
||||||
- These can be removed in a future cleanup once all references are updated
|
- All keyboard handling goes through WysiwygKeyboardHandler
|
||||||
- The refactoring maintains all existing functionality
|
- 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
82
readme.refactoring.md
Normal 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
|
178
ts_web/elements/wysiwyg/dees-formatting-menu.ts
Normal file
178
ts_web/elements/wysiwyg/dees-formatting-menu.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -24,7 +24,9 @@ import {
|
|||||||
WysiwygInputHandler,
|
WysiwygInputHandler,
|
||||||
WysiwygKeyboardHandler,
|
WysiwygKeyboardHandler,
|
||||||
WysiwygDragDropHandler,
|
WysiwygDragDropHandler,
|
||||||
WysiwygModalManager
|
WysiwygModalManager,
|
||||||
|
DeesSlashMenu,
|
||||||
|
DeesFormattingMenu
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@ -55,17 +57,8 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
@state()
|
@state()
|
||||||
private selectedBlockId: string | null = null;
|
private selectedBlockId: string | null = null;
|
||||||
|
|
||||||
@state()
|
// Slash menu is now globally rendered
|
||||||
private showSlashMenu: boolean = false;
|
private slashMenu = DeesSlashMenu.getInstance();
|
||||||
|
|
||||||
@state()
|
|
||||||
private slashMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private slashMenuFilter: string = '';
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private slashMenuSelectedIndex: number = 0;
|
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private draggedBlockId: string | null = null;
|
private draggedBlockId: string | null = null;
|
||||||
@ -76,11 +69,8 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
@state()
|
@state()
|
||||||
private dragOverPosition: 'before' | 'after' | null = null;
|
private dragOverPosition: 'before' | 'after' | null = null;
|
||||||
|
|
||||||
@state()
|
// Formatting menu is now globally rendered
|
||||||
private showFormattingMenu: boolean = false;
|
private formattingMenu = DeesFormattingMenu.getInstance();
|
||||||
|
|
||||||
@state()
|
|
||||||
private formattingMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private selectedText: string = '';
|
private selectedText: string = '';
|
||||||
@ -129,7 +119,16 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
// Add global selection listener
|
// Add global selection listener
|
||||||
console.log('Adding selectionchange listener');
|
console.log('Adding selectionchange listener');
|
||||||
document.addEventListener('selectionchange', this.selectionChangeHandler);
|
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 {
|
render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
@ -145,8 +144,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
>
|
>
|
||||||
${this.blocks.map(block => this.renderBlock(block))}
|
${this.blocks.map(block => this.renderBlock(block))}
|
||||||
</div>
|
</div>
|
||||||
${this.showSlashMenu ? this.renderSlashMenu() : ''}
|
|
||||||
${this.showFormattingMenu ? this.renderFormattingMenu() : ''}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -173,7 +170,10 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
@dragend="${() => this.dragDropHandler.handleDragEnd()}"
|
@dragend="${() => this.dragDropHandler.handleDragEnd()}"
|
||||||
></div>
|
></div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${WysiwygBlocks.renderBlock(block, isSelected, {
|
<dees-wysiwyg-block
|
||||||
|
.block="${block}"
|
||||||
|
.isSelected="${isSelected}"
|
||||||
|
.handlers="${{
|
||||||
onInput: (e: InputEvent) => this.inputHandler.handleBlockInput(e, block),
|
onInput: (e: InputEvent) => this.inputHandler.handleBlockInput(e, block),
|
||||||
onKeyDown: (e: KeyboardEvent) => this.keyboardHandler.handleBlockKeyDown(e, block),
|
onKeyDown: (e: KeyboardEvent) => this.keyboardHandler.handleBlockKeyDown(e, block),
|
||||||
onFocus: () => this.handleBlockFocus(block),
|
onFocus: () => this.handleBlockFocus(block),
|
||||||
@ -181,7 +181,8 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
onCompositionStart: () => this.isComposing = true,
|
onCompositionStart: () => this.isComposing = true,
|
||||||
onCompositionEnd: () => this.isComposing = false,
|
onCompositionEnd: () => this.isComposing = false,
|
||||||
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
|
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
|
||||||
})}
|
}}"
|
||||||
|
></dees-wysiwyg-block>
|
||||||
${block.type !== 'divider' ? html`
|
${block.type !== 'divider' ? html`
|
||||||
<div
|
<div
|
||||||
class="block-settings"
|
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) {
|
private handleSlashMenuKeyboard(e: KeyboardEvent) {
|
||||||
const menuItems = this.getFilteredMenuItems();
|
|
||||||
|
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.slashMenuSelectedIndex = (this.slashMenuSelectedIndex + 1) % menuItems.length;
|
this.slashMenu.navigate('down');
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.slashMenuSelectedIndex = this.slashMenuSelectedIndex === 0
|
this.slashMenu.navigate('up');
|
||||||
? menuItems.length - 1
|
|
||||||
: this.slashMenuSelectedIndex - 1;
|
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (menuItems[this.slashMenuSelectedIndex]) {
|
this.slashMenu.selectCurrent();
|
||||||
this.insertBlock(menuItems[this.slashMenuSelectedIndex].type as IBlock['type']);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.closeSlashMenu();
|
this.closeSlashMenu(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public closeSlashMenu() {
|
public closeSlashMenu(clearSlash: boolean = false) {
|
||||||
if (this.showSlashMenu && this.selectedBlockId) {
|
if (clearSlash && this.selectedBlockId) {
|
||||||
// Clear the slash command from the content if menu is closing without selection
|
// Clear the slash command from the content if menu is closing without selection
|
||||||
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
|
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
|
||||||
if (currentBlock) {
|
if (currentBlock) {
|
||||||
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
|
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
|
||||||
if (wrapperElement) {
|
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||||
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
|
||||||
if (blockElement && (blockElement.textContent || '').startsWith('/')) {
|
if (blockComponent) {
|
||||||
// Clear the slash command text
|
const content = blockComponent.getContent();
|
||||||
blockElement.textContent = '';
|
if (content.startsWith('/')) {
|
||||||
currentBlock.content = '';
|
// Remove the entire slash command (slash + any filter text)
|
||||||
// Ensure cursor stays in the block
|
const cleanContent = content.replace(/^\/[^\s]*\s*/, '').trim();
|
||||||
blockElement.focus();
|
blockComponent.setContent(cleanContent);
|
||||||
|
currentBlock.content = cleanContent;
|
||||||
|
|
||||||
|
// Focus and set cursor at beginning
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
blockComponent.focusWithCursor(0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.showSlashMenu = false;
|
this.slashMenu.hide();
|
||||||
this.slashMenuFilter = '';
|
|
||||||
this.slashMenuSelectedIndex = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
|
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) {
|
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
|
// Update value on blur to ensure it's saved
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
|
|
||||||
setTimeout(() => {
|
// Don't immediately clear selectedBlockId or close menus
|
||||||
if (this.selectedBlockId === block.id) {
|
// Let click handlers decide what to do
|
||||||
this.selectedBlockId = null;
|
|
||||||
}
|
|
||||||
// Don't close slash menu on blur if clicking on menu item
|
|
||||||
const activeElement = document.activeElement;
|
|
||||||
const slashMenu = this.shadowRoot?.querySelector('.slash-menu');
|
|
||||||
if (!slashMenu?.contains(activeElement as Node)) {
|
|
||||||
this.closeSlashMenu();
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleEditorClick(e: MouseEvent) {
|
private handleEditorClick(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement;
|
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')) {
|
if (target.classList.contains('editor-content')) {
|
||||||
const lastBlock = this.blocks[this.blocks.length - 1];
|
const lastBlock = this.blocks[this.blocks.length - 1];
|
||||||
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${lastBlock.id}"]`);
|
if (lastBlock.type !== 'divider') {
|
||||||
if (wrapperElement && lastBlock.type !== 'divider') {
|
this.blockOperations.focusBlock(lastBlock.id, 'end');
|
||||||
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
|
||||||
if (blockElement) {
|
|
||||||
blockElement.focus();
|
|
||||||
WysiwygBlocks.setCursorToEnd(blockElement);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
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.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
||||||
|
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
|
this.requestUpdate();
|
||||||
|
|
||||||
if (focusNewBlock) {
|
if (focusNewBlock && newBlock.type !== 'divider') {
|
||||||
setTimeout(() => {
|
await this.blockOperations.focusBlock(newBlock.id, 'start');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async insertBlock(type: IBlock['type']) {
|
public async insertBlock(type: IBlock['type']) {
|
||||||
const currentBlockIndex = this.blocks.findIndex(b => b.id === this.selectedBlockId);
|
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
|
||||||
const currentBlock = this.blocks[currentBlockIndex];
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu
|
||||||
|
this.closeSlashMenu(false);
|
||||||
|
|
||||||
if (currentBlock) {
|
|
||||||
// If it's a code block, ask for language
|
// If it's a code block, ask for language
|
||||||
if (type === 'code') {
|
if (type === 'code') {
|
||||||
const language = await WysiwygModalManager.showLanguageSelectionModal();
|
const language = await WysiwygModalManager.showLanguageSelectionModal();
|
||||||
if (!language) {
|
if (!language) {
|
||||||
// User cancelled
|
return; // User cancelled
|
||||||
this.closeSlashMenu();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
currentBlock.metadata = { language };
|
currentBlock.metadata = { language };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transform the current block
|
||||||
currentBlock.type = type;
|
currentBlock.type = type;
|
||||||
currentBlock.content = '';
|
currentBlock.content = currentBlock.content || '';
|
||||||
|
|
||||||
if (type === 'divider') {
|
if (type === 'divider') {
|
||||||
currentBlock.content = ' ';
|
currentBlock.content = ' ';
|
||||||
const newBlock = this.createNewBlock();
|
const newBlock = this.createNewBlock();
|
||||||
this.insertBlockAfter(currentBlock, newBlock);
|
this.insertBlockAfter(currentBlock, newBlock);
|
||||||
} else if (type === 'list') {
|
} else if (type === 'list') {
|
||||||
// Handle list type specially
|
currentBlock.metadata = { listType: 'bullet' };
|
||||||
currentBlock.metadata = { listType: 'bullet' }; // Default to bullet list
|
// For lists, ensure we start with empty content
|
||||||
setTimeout(() => {
|
currentBlock.content = '';
|
||||||
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 {
|
} else {
|
||||||
// Force update the contenteditable element
|
// For all other block types, ensure content is clean
|
||||||
setTimeout(() => {
|
currentBlock.content = currentBlock.content || '';
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closeSlashMenu();
|
// Update and refocus
|
||||||
this.updateValue();
|
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() {
|
private updateValue() {
|
||||||
@ -833,35 +589,10 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
|
|
||||||
|
|
||||||
private handleTextSelection(e: MouseEvent): void {
|
private handleTextSelection(e: MouseEvent): void {
|
||||||
// Stop event to prevent it from bubbling up
|
// Don't interfere with slash menu
|
||||||
e.stopPropagation();
|
if (this.slashMenu.visible) return;
|
||||||
|
|
||||||
console.log('handleTextSelection called from mouseup on contenteditable');
|
// Let the block component handle selection via custom event
|
||||||
|
|
||||||
// Small delay to ensure selection is complete
|
|
||||||
setTimeout(() => {
|
|
||||||
// Alternative approach: check selection directly within the target element
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const selection = window.getSelection();
|
|
||||||
|
|
||||||
if (selection && selection.rangeCount > 0) {
|
|
||||||
const selectedText = selection.toString();
|
|
||||||
console.log('Direct selection check in handleTextSelection:', {
|
|
||||||
selectedText: selectedText.substring(0, 50),
|
|
||||||
hasText: selectedText.length > 0,
|
|
||||||
target: target.tagName + '.' + target.className
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedText.length > 0) {
|
|
||||||
// We know this came from a mouseup on our contenteditable, so it's definitely our selection
|
|
||||||
console.log('✅ Text selected via mouseup:', selectedText);
|
|
||||||
this.selectedText = selectedText;
|
|
||||||
this.updateFormattingMenuPosition();
|
|
||||||
} else if (this.showFormattingMenu) {
|
|
||||||
this.hideFormattingMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSelectionChange(): void {
|
private handleSelectionChange(): void {
|
||||||
@ -902,7 +633,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
this.selectedText = selectedText;
|
this.selectedText = selectedText;
|
||||||
this.updateFormattingMenuPosition();
|
this.updateFormattingMenuPosition();
|
||||||
}
|
}
|
||||||
} else if (this.showFormattingMenu) {
|
} else if (this.formattingMenu.visible) {
|
||||||
console.log('No text selected, hiding menu');
|
console.log('No text selected, hiding menu');
|
||||||
this.hideFormattingMenu();
|
this.hideFormattingMenu();
|
||||||
}
|
}
|
||||||
@ -922,42 +653,33 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
this.formattingMenuPosition = {
|
const formattingMenuPosition = {
|
||||||
x: coords.x - containerRect.left,
|
x: coords.x - containerRect.left,
|
||||||
y: coords.y - containerRect.top
|
y: coords.y - containerRect.top
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Setting menu position:', this.formattingMenuPosition);
|
console.log('Setting menu position:', formattingMenuPosition);
|
||||||
this.showFormattingMenu = true;
|
// Show the global formatting menu
|
||||||
console.log('showFormattingMenu set to:', this.showFormattingMenu);
|
this.formattingMenu.show(
|
||||||
|
{ x: coords.x, y: coords.y }, // Use absolute coordinates
|
||||||
// Force update
|
async (command: string) => await this.applyFormat(command)
|
||||||
this.requestUpdate();
|
);
|
||||||
|
|
||||||
// Check if menu exists in DOM after update
|
|
||||||
setTimeout(() => {
|
|
||||||
const menu = this.shadowRoot?.querySelector('.formatting-menu');
|
|
||||||
console.log('Menu in DOM after update:', menu);
|
|
||||||
if (menu) {
|
|
||||||
console.log('Menu style:', menu.getAttribute('style'));
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} else {
|
} else {
|
||||||
console.log('No coordinates found');
|
console.log('No coordinates found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private hideFormattingMenu(): void {
|
private hideFormattingMenu(): void {
|
||||||
this.showFormattingMenu = false;
|
this.formattingMenu.hide();
|
||||||
this.selectedText = '';
|
this.selectedText = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyFormat(command: string): void {
|
public async applyFormat(command: string): Promise<void> {
|
||||||
// Save current selection before applying format
|
// Save current selection before applying format
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection || selection.rangeCount === 0) return;
|
if (!selection || selection.rangeCount === 0) return;
|
||||||
|
|
||||||
// Get the current block to update its content
|
// Get the current block
|
||||||
const anchorNode = selection.anchorNode;
|
const anchorNode = selection.anchorNode;
|
||||||
const blockElement = anchorNode?.nodeType === Node.TEXT_NODE
|
const blockElement = anchorNode?.nodeType === Node.TEXT_NODE
|
||||||
? anchorNode.parentElement?.closest('.block')
|
? anchorNode.parentElement?.closest('.block')
|
||||||
@ -965,31 +687,120 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
|
|
||||||
if (!blockElement) return;
|
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 block = this.blocks.find(b => b.id === blockId);
|
||||||
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||||
|
|
||||||
if (!block) return;
|
if (!block || !blockComponent) return;
|
||||||
|
|
||||||
|
// 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
|
// Apply the format
|
||||||
WysiwygFormatting.applyFormat(command);
|
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();
|
this.updateValue();
|
||||||
|
|
||||||
// Keep selection active
|
// For link command, close the formatting menu
|
||||||
if (command !== 'link') {
|
if (command === 'link') {
|
||||||
|
this.hideFormattingMenu();
|
||||||
|
} else if (this.formattingMenu.visible) {
|
||||||
|
// Update menu position if still showing
|
||||||
this.updateFormattingMenuPosition();
|
this.updateFormattingMenuPosition();
|
||||||
}
|
}
|
||||||
}, 10);
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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> {
|
private async showLanguageSelectionModal(): Promise<string | null> {
|
||||||
|
209
ts_web/elements/wysiwyg/dees-slash-menu.ts
Normal file
209
ts_web/elements/wysiwyg/dees-slash-menu.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
551
ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
Normal file
551
ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
export * from './wysiwyg.types.js';
|
export * from './wysiwyg.types.js';
|
||||||
|
export * from './wysiwyg.interfaces.js';
|
||||||
export * from './wysiwyg.styles.js';
|
export * from './wysiwyg.styles.js';
|
||||||
export * from './wysiwyg.converters.js';
|
export * from './wysiwyg.converters.js';
|
||||||
export * from './wysiwyg.shortcuts.js';
|
export * from './wysiwyg.shortcuts.js';
|
||||||
@ -9,3 +10,6 @@ export * from './wysiwyg.inputhandler.js';
|
|||||||
export * from './wysiwyg.keyboardhandler.js';
|
export * from './wysiwyg.keyboardhandler.js';
|
||||||
export * from './wysiwyg.dragdrophandler.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';
|
@ -24,7 +24,7 @@ export class WysiwygBlockOperations {
|
|||||||
/**
|
/**
|
||||||
* Inserts a block after the specified block
|
* 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 blocks = this.component.blocks;
|
||||||
const blockIndex = blocks.findIndex((b: IBlock) => b.id === afterBlock.id);
|
const blockIndex = blocks.findIndex((b: IBlock) => b.id === afterBlock.id);
|
||||||
|
|
||||||
@ -35,11 +35,14 @@ export class WysiwygBlockOperations {
|
|||||||
];
|
];
|
||||||
|
|
||||||
this.component.updateValue();
|
this.component.updateValue();
|
||||||
|
this.component.requestUpdate();
|
||||||
|
|
||||||
if (focusNewBlock && newBlock.type !== 'divider') {
|
if (focusNewBlock && newBlock.type !== 'divider') {
|
||||||
setTimeout(() => {
|
// Wait for the component to update
|
||||||
this.focusBlock(newBlock.id);
|
await this.component.updateComplete;
|
||||||
}, 50);
|
|
||||||
|
// Focus the new block
|
||||||
|
await this.focusBlock(newBlock.id, 'start');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,17 +71,19 @@ export class WysiwygBlockOperations {
|
|||||||
/**
|
/**
|
||||||
* Focuses a specific block
|
* 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}"]`);
|
const wrapperElement = this.component.shadowRoot!.querySelector(`[data-block-id="${blockId}"]`);
|
||||||
if (wrapperElement) {
|
if (wrapperElement) {
|
||||||
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
const blockComponent = wrapperElement.querySelector('dees-wysiwyg-block') as any;
|
||||||
if (blockElement) {
|
if (blockComponent) {
|
||||||
blockElement.focus();
|
// Wait a frame to ensure the block is rendered
|
||||||
if (cursorPosition === 'start') {
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
WysiwygBlocks.setCursorToStart(blockElement);
|
|
||||||
} else {
|
// Now focus with cursor position
|
||||||
WysiwygBlocks.setCursorToEnd(blockElement);
|
blockComponent.focusWithCursor(cursorPosition);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,8 @@ export class WysiwygBlocks {
|
|||||||
const items = content.split('\n').filter(item => item.trim());
|
const items = content.split('\n').filter(item => item.trim());
|
||||||
if (items.length === 0) return '';
|
if (items.length === 0) return '';
|
||||||
const listTag = metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
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(
|
static renderBlock(
|
||||||
@ -102,21 +103,88 @@ export class WysiwygBlocks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static setCursorToEnd(element: HTMLElement): void {
|
static setCursorToEnd(element: HTMLElement): void {
|
||||||
const range = document.createRange();
|
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
range.selectNodeContents(element);
|
if (!sel) return;
|
||||||
range.collapse(false);
|
|
||||||
sel!.removeAllRanges();
|
const range = document.createRange();
|
||||||
sel!.addRange(range);
|
|
||||||
|
// 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 {
|
static setCursorToStart(element: HTMLElement): void {
|
||||||
const range = document.createRange();
|
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
range.selectNodeContents(element);
|
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);
|
range.collapse(true);
|
||||||
sel!.removeAllRanges();
|
} else {
|
||||||
sel!.addRange(range);
|
// 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 {
|
static focusListItem(listElement: HTMLElement): void {
|
||||||
|
@ -31,7 +31,8 @@ export class WysiwygConverters {
|
|||||||
const items = block.content.split('\n').filter(item => item.trim());
|
const items = block.content.split('\n').filter(item => item.trim());
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
const listTag = block.metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
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 '';
|
return '';
|
||||||
case 'divider':
|
case 'divider':
|
||||||
@ -135,7 +136,8 @@ export class WysiwygConverters {
|
|||||||
case 'ul':
|
case 'ul':
|
||||||
case 'ol':
|
case 'ol':
|
||||||
const listItems = Array.from(element.querySelectorAll('li'));
|
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({
|
blocks.push({
|
||||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
type: 'list',
|
type: 'list',
|
||||||
|
@ -42,46 +42,104 @@ export class WysiwygFormatting {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static applyFormat(command: string, value?: string): void {
|
static applyFormat(command: string, value?: string): boolean {
|
||||||
// Save current selection
|
// Save current selection
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection || selection.rangeCount === 0) return;
|
if (!selection || selection.rangeCount === 0) return false;
|
||||||
|
|
||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
// Apply format based on command
|
// Apply format based on command
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'bold':
|
case 'bold':
|
||||||
|
this.wrapSelection(range, 'strong');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'italic':
|
case 'italic':
|
||||||
|
this.wrapSelection(range, 'em');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'underline':
|
case 'underline':
|
||||||
|
this.wrapSelection(range, 'u');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'strikeThrough':
|
case 'strikeThrough':
|
||||||
document.execCommand(command, false);
|
this.wrapSelection(range, 's');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'code':
|
case 'code':
|
||||||
// For inline code, wrap selection in <code> tags
|
this.wrapSelection(range, 'code');
|
||||||
const codeElement = document.createElement('code');
|
|
||||||
try {
|
|
||||||
codeElement.appendChild(range.extractContents());
|
|
||||||
range.insertNode(codeElement);
|
|
||||||
|
|
||||||
// Select the newly created code element
|
|
||||||
range.selectNodeContents(codeElement);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to execCommand if range manipulation fails
|
|
||||||
document.execCommand('fontName', false, 'monospace');
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'link':
|
case 'link':
|
||||||
const url = value || prompt('Enter URL:');
|
// Don't use prompt - return false to indicate we need async input
|
||||||
if (url) {
|
if (!value) {
|
||||||
document.execCommand('createLink', false, url);
|
return false;
|
||||||
}
|
}
|
||||||
|
this.wrapSelectionWithLink(range, value);
|
||||||
break;
|
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 {
|
static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null {
|
||||||
@ -118,9 +176,32 @@ export class WysiwygFormatting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static isFormattingApplied(command: string): boolean {
|
static isFormattingApplied(command: string): boolean {
|
||||||
try {
|
const selection = window.getSelection();
|
||||||
return document.queryCommandState(command);
|
if (!selection || selection.rangeCount === 0) return false;
|
||||||
} catch {
|
|
||||||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -42,9 +42,29 @@ export class WysiwygInputHandler {
|
|||||||
* Updates block content based on its type
|
* Updates block content based on its type
|
||||||
*/
|
*/
|
||||||
private updateBlockContent(block: IBlock, target: HTMLDivElement): void {
|
private updateBlockContent(block: IBlock, target: HTMLDivElement): void {
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Fallback if block component not found
|
||||||
if (block.type === 'list') {
|
if (block.type === 'list') {
|
||||||
const listItems = target.querySelectorAll('li');
|
const listItems = target.querySelectorAll('li');
|
||||||
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
|
// Use innerHTML to preserve formatting
|
||||||
|
block.content = Array.from(listItems).map(li => li.innerHTML || '').join('\n');
|
||||||
|
|
||||||
const listElement = target.querySelector('ol, ul');
|
const listElement = target.querySelector('ol, ul');
|
||||||
if (listElement) {
|
if (listElement) {
|
||||||
@ -58,6 +78,7 @@ export class WysiwygInputHandler {
|
|||||||
block.content = target.innerHTML || '';
|
block.content = target.innerHTML || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects if the user is trying to create a specific block type
|
* Detects if the user is trying to create a specific block type
|
||||||
@ -151,25 +172,55 @@ export class WysiwygInputHandler {
|
|||||||
* Handles slash command detection and menu display
|
* Handles slash command detection and menu display
|
||||||
*/
|
*/
|
||||||
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
|
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
|
||||||
if (textContent === '/' || (textContent.startsWith('/') && this.component.showSlashMenu)) {
|
const slashMenu = this.component.slashMenu;
|
||||||
if (!this.component.showSlashMenu && textContent === '/') {
|
const isSlashMenuVisible = slashMenu && slashMenu.visible;
|
||||||
this.component.showSlashMenu = true;
|
|
||||||
this.component.slashMenuSelectedIndex = 0;
|
|
||||||
|
|
||||||
const rect = target.getBoundingClientRect();
|
if (textContent === '/' || (textContent.startsWith('/') && isSlashMenuVisible)) {
|
||||||
const containerRect = this.component.shadowRoot!.querySelector('.wysiwyg-container')!.getBoundingClientRect();
|
if (!isSlashMenuVisible && textContent === '/') {
|
||||||
|
// Get position for menu based on cursor location
|
||||||
|
const rect = this.getCaretCoordinates(target);
|
||||||
|
|
||||||
this.component.slashMenuPosition = {
|
// Show the slash menu at the cursor position
|
||||||
x: rect.left - containerRect.left,
|
slashMenu.show(
|
||||||
y: rect.bottom - containerRect.top + 4
|
{ x: rect.left, y: rect.bottom + 4 },
|
||||||
};
|
(type: string) => {
|
||||||
|
this.component.insertBlock(type);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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('/')) {
|
} else if (!textContent.startsWith('/')) {
|
||||||
this.component.closeSlashMenu();
|
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
|
* Schedules auto-save after a delay
|
||||||
*/
|
*/
|
||||||
@ -177,6 +228,10 @@ export class WysiwygInputHandler {
|
|||||||
if (this.saveTimeout) {
|
if (this.saveTimeout) {
|
||||||
clearTimeout(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.saveTimeout = setTimeout(() => {
|
||||||
this.component.updateValue();
|
this.component.updateValue();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
79
ts_web/elements/wysiwyg/wysiwyg.interfaces.ts
Normal file
79
ts_web/elements/wysiwyg/wysiwyg.interfaces.ts
Normal 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;
|
||||||
|
}
|
@ -1,6 +1,4 @@
|
|||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
|
||||||
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
|
|
||||||
|
|
||||||
export class WysiwygKeyboardHandler {
|
export class WysiwygKeyboardHandler {
|
||||||
private component: any;
|
private component: any;
|
||||||
@ -12,10 +10,10 @@ export class WysiwygKeyboardHandler {
|
|||||||
/**
|
/**
|
||||||
* Handles keyboard events for blocks
|
* Handles keyboard events for blocks
|
||||||
*/
|
*/
|
||||||
handleBlockKeyDown(e: KeyboardEvent, block: IBlock): void {
|
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||||
// Handle slash menu navigation
|
// Handle slash menu navigation
|
||||||
if (this.component.showSlashMenu && this.isSlashMenuKey(e.key)) {
|
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
|
||||||
this.handleSlashMenuKeyboard(e);
|
this.component.handleSlashMenuKeyboard(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,10 +28,22 @@ export class WysiwygKeyboardHandler {
|
|||||||
this.handleTab(e, block);
|
this.handleTab(e, block);
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
this.handleEnter(e, block);
|
await this.handleEnter(e, block);
|
||||||
break;
|
break;
|
||||||
case 'Backspace':
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,19 +64,20 @@ export class WysiwygKeyboardHandler {
|
|||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case 'b':
|
case 'b':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.component.applyFormat('bold');
|
// Use Promise to ensure focus is maintained
|
||||||
|
Promise.resolve().then(() => this.component.applyFormat('bold'));
|
||||||
return true;
|
return true;
|
||||||
case 'i':
|
case 'i':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.component.applyFormat('italic');
|
Promise.resolve().then(() => this.component.applyFormat('italic'));
|
||||||
return true;
|
return true;
|
||||||
case 'u':
|
case 'u':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.component.applyFormat('underline');
|
Promise.resolve().then(() => this.component.applyFormat('underline'));
|
||||||
return true;
|
return true;
|
||||||
case 'k':
|
case 'k':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.component.applyFormat('link');
|
Promise.resolve().then(() => this.component.applyFormat('link'));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -79,7 +90,18 @@ export class WysiwygKeyboardHandler {
|
|||||||
if (block.type === 'code') {
|
if (block.type === 'code') {
|
||||||
// Allow tab in code blocks
|
// Allow tab in code blocks
|
||||||
e.preventDefault();
|
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') {
|
} else if (block.type === 'list') {
|
||||||
// Future: implement list indentation
|
// Future: implement list indentation
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -89,7 +111,7 @@ export class WysiwygKeyboardHandler {
|
|||||||
/**
|
/**
|
||||||
* Handles Enter key
|
* Handles Enter key
|
||||||
*/
|
*/
|
||||||
private handleEnter(e: KeyboardEvent, block: IBlock): void {
|
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||||
const blockOps = this.component.blockOperations;
|
const blockOps = this.component.blockOperations;
|
||||||
|
|
||||||
if (block.type === 'code') {
|
if (block.type === 'code') {
|
||||||
@ -97,7 +119,7 @@ export class WysiwygKeyboardHandler {
|
|||||||
// Shift+Enter in code blocks creates a new block
|
// Shift+Enter in code blocks creates a new block
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newBlock = blockOps.createBlock();
|
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)
|
// Normal Enter in code blocks creates new line (let browser handle it)
|
||||||
return;
|
return;
|
||||||
@ -105,12 +127,42 @@ export class WysiwygKeyboardHandler {
|
|||||||
|
|
||||||
if (!e.shiftKey) {
|
if (!e.shiftKey) {
|
||||||
if (block.type === 'list') {
|
if (block.type === 'list') {
|
||||||
this.handleEnterInList(e, block);
|
await this.handleEnterInList(e, block);
|
||||||
} else {
|
} else {
|
||||||
// Create new paragraph block
|
// Split content at cursor position
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 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();
|
const newBlock = blockOps.createBlock();
|
||||||
blockOps.insertBlockAfter(block, newBlock);
|
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)
|
// Shift+Enter creates line break (let browser handle it)
|
||||||
@ -119,8 +171,7 @@ export class WysiwygKeyboardHandler {
|
|||||||
/**
|
/**
|
||||||
* Handles Enter key in list blocks
|
* Handles Enter key in list blocks
|
||||||
*/
|
*/
|
||||||
private handleEnterInList(e: KeyboardEvent, block: IBlock): void {
|
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||||
const target = e.target as HTMLDivElement;
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
if (selection && selection.rangeCount > 0) {
|
if (selection && selection.rangeCount > 0) {
|
||||||
@ -132,7 +183,7 @@ export class WysiwygKeyboardHandler {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const blockOps = this.component.blockOperations;
|
const blockOps = this.component.blockOperations;
|
||||||
const newBlock = blockOps.createBlock();
|
const newBlock = blockOps.createBlock();
|
||||||
blockOps.insertBlockAfter(block, newBlock);
|
await blockOps.insertBlockAfter(block, newBlock);
|
||||||
}
|
}
|
||||||
// Otherwise, let browser create new list item
|
// Otherwise, let browser create new list item
|
||||||
}
|
}
|
||||||
@ -141,7 +192,7 @@ export class WysiwygKeyboardHandler {
|
|||||||
/**
|
/**
|
||||||
* Handles Backspace key
|
* 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) {
|
if (block.content === '' && this.component.blocks.length > 1) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const blockOps = this.component.blockOperations;
|
const blockOps = this.component.blockOperations;
|
||||||
@ -150,49 +201,184 @@ export class WysiwygKeyboardHandler {
|
|||||||
if (prevBlock) {
|
if (prevBlock) {
|
||||||
blockOps.removeBlock(block.id);
|
blockOps.removeBlock(block.id);
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (prevBlock.type !== 'divider') {
|
if (prevBlock.type !== 'divider') {
|
||||||
blockOps.focusBlock(prevBlock.id, 'end');
|
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 {
|
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||||
const menuItems = this.component.getFilteredMenuItems();
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return;
|
||||||
|
|
||||||
switch(e.key) {
|
const range = selection.getRangeAt(0);
|
||||||
case 'ArrowDown':
|
const target = e.target as HTMLElement;
|
||||||
e.preventDefault();
|
|
||||||
this.component.slashMenuSelectedIndex =
|
|
||||||
(this.component.slashMenuSelectedIndex + 1) % menuItems.length;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'ArrowUp':
|
// Check if cursor is at the beginning of the block
|
||||||
e.preventDefault();
|
const isAtStart = range.startOffset === 0 && range.endOffset === 0;
|
||||||
this.component.slashMenuSelectedIndex =
|
|
||||||
this.component.slashMenuSelectedIndex === 0
|
|
||||||
? menuItems.length - 1
|
|
||||||
: this.component.slashMenuSelectedIndex - 1;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'Enter':
|
if (isAtStart) {
|
||||||
e.preventDefault();
|
const firstNode = target.firstChild;
|
||||||
if (menuItems[this.component.slashMenuSelectedIndex]) {
|
const isReallyAtStart = !firstNode ||
|
||||||
this.component.insertBlock(
|
(range.startContainer === firstNode && range.startOffset === 0) ||
|
||||||
menuItems[this.component.slashMenuSelectedIndex].type as IBlock['type']
|
(range.startContainer === target && range.startOffset === 0);
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'Escape':
|
if (isReallyAtStart) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.component.closeSlashMenu();
|
const blockOps = this.component.blockOperations;
|
||||||
break;
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||||
|
|
||||||
|
if (prevBlock && prevBlock.type !== 'divider') {
|
||||||
|
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
}
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||||
import { DeesModal } from '../dees-modal.js';
|
import { DeesModal } from '../dees-modal.js';
|
||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
|
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||||
|
|
||||||
export class WysiwygModalManager {
|
export class WysiwygModalManager {
|
||||||
/**
|
/**
|
||||||
@ -74,13 +75,55 @@ export class WysiwygModalManager {
|
|||||||
block: IBlock,
|
block: IBlock,
|
||||||
onUpdate: (block: IBlock) => void
|
onUpdate: (block: IBlock) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let content: TemplateResult;
|
const content = html`
|
||||||
|
<style>
|
||||||
if (block.type === 'code') {
|
.settings-container {
|
||||||
content = this.getCodeBlockSettings(block, onUpdate);
|
padding: 16px;
|
||||||
} else {
|
|
||||||
content = html`<div style="padding: 16px;">No settings available for this block type.</div>`;
|
|
||||||
}
|
}
|
||||||
|
.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({
|
DeesModal.createAndShow({
|
||||||
heading: 'Block Settings',
|
heading: 'Block Settings',
|
||||||
@ -170,4 +213,62 @@ export class WysiwygModalManager {
|
|||||||
'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'
|
'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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
@ -248,6 +248,8 @@ export const wysiwygStyles = css`
|
|||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
pointer-events: auto;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slash-menu-item {
|
.slash-menu-item {
|
||||||
|
Reference in New Issue
Block a user