Compare commits

...

76 Commits

Author SHA1 Message Date
1dcaccdb6d 1.9.8
Some checks failed
Default (tags) / security (push) Failing after 33s
Default (tags) / test (push) Failing after 27s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 23:19:44 +00:00
35eb410051 fix(deps, windowlayer): Update dependency versions and adjust dees-windowlayer CSS to add pointer-events fix 2025-06-26 23:19:43 +00:00
10c43ecd59 update 2025-06-26 20:20:34 +00:00
9df4a09414 1.9.7
Some checks failed
Default (tags) / security (push) Failing after 35s
Default (tags) / test (push) Failing after 29s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 19:19:19 +00:00
7cbc941407 update readme 2025-06-26 19:18:58 +00:00
b31f306106 1.9.6
Some checks failed
Default (tags) / security (push) Failing after 36s
Default (tags) / test (push) Failing after 29s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 19:00:57 +00:00
1dbbac450c update dees-tags 2025-06-26 19:00:15 +00:00
b5a2bd7436 fix zindex 2025-06-26 18:37:49 +00:00
931a760ee1 update z-index showcase 2025-06-26 18:13:08 +00:00
27414e0284 1.9.5
Some checks failed
Default (tags) / security (push) Failing after 39s
Default (tags) / test (push) Failing after 31s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 15:57:43 +00:00
d63bc762d0 update 2025-06-26 15:57:27 +00:00
505e40a57f update modals 2025-06-26 15:51:05 +00:00
d1ea10d8c6 update z-index use 2025-06-26 15:46:44 +00:00
1038759d8b 1.9.4
Some checks failed
Default (tags) / security (push) Failing after 40s
Default (tags) / test (push) Failing after 28s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 15:33:18 +00:00
ab9b545c9a update file upload 2025-06-26 15:32:29 +00:00
e1329ecd7a 1.9.3
Some checks failed
Default (tags) / security (push) Failing after 43s
Default (tags) / test (push) Failing after 24s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 15:08:25 +00:00
167df241b7 update 2025-06-26 15:08:14 +00:00
b41e9f31e7 add proper input demo page 2025-06-26 14:46:37 +00:00
02f25aa02e fix(editor-demos): update 2025-06-26 14:27:39 +00:00
312fc4ba90 feat(dees-input-richtext): use lucide icons 2025-06-26 14:15:52 +00:00
56d7b44b01 feat(prosemirror): add prosemirror support 2025-06-26 14:12:06 +00:00
f72c9fad3a update navigation 2025-06-26 13:45:00 +00:00
d48fd667a2 update 2025-06-26 13:38:09 +00:00
979877b3b0 update 2025-06-26 13:32:37 +00:00
342bd7d7c2 update 2025-06-26 13:18:34 +00:00
4d42911198 update 2025-06-26 12:00:35 +00:00
3ea7186d6c update 2025-06-26 11:57:04 +00:00
09e35d0245 update codeblock 2025-06-26 11:41:58 +00:00
4a26307e1b update 2025-06-25 05:30:20 +00:00
113c013ea9 1.9.2
Some checks failed
Default (tags) / security (push) Failing after 26s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-24 23:57:32 +00:00
0571d5bf4b fi(wysiwyg): fix navigation 2025-06-24 23:56:40 +00:00
5f86fdba72 update 2025-06-24 23:46:52 +00:00
474385a939 update 2025-06-24 23:15:56 +00:00
71d64fccb8 1.9.1
Some checks failed
Default (tags) / security (push) Failing after 28s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-24 22:46:24 +00:00
e9541da8ff refactor 2025-06-24 22:45:50 +00:00
68b4e9ec8e feat(wysiwyg): Add more block types 2025-06-24 20:32:03 +00:00
856d354b5a update 2025-06-24 18:52:48 +00:00
89a4a15e78 update 2025-06-24 18:43:51 +00:00
fca3638f7f implement image upload 2025-06-24 17:16:13 +00:00
90fc8bed35 update selection reversal 2025-06-24 17:09:19 +00:00
bd223f77d0 selection manipulation 2025-06-24 16:53:54 +00:00
1041814823 update 2025-06-24 16:49:40 +00:00
366544befc fix(wysiwyg): Improve text selection detection with block-level approach
- Remove global selectionchange listener in favor of block-level detection
- Add comprehensive debugging logs to track selection detection
- Add multiple event listeners (mouseup, keyup, selectstart) for better coverage
- Add debounced selection checking to avoid race conditions
- Add click-outside handler to hide formatting menu
- Simplify selection detection logic by removing complex shadow DOM traversal
2025-06-24 16:31:00 +00:00
3b93bd63a7 fix(wysiwyg): Fix text selection detection for formatting menu in Shadow DOM
- Update selection detection to properly handle Shadow DOM boundaries
- Use getComposedRanges API correctly according to MDN documentation
- Add direct selection detection within block components
- Dispatch custom events from blocks when text is selected
- Fix formatting menu positioning using selection rect from events
2025-06-24 16:17:00 +00:00
ca525ce7e3 feat(wysiwyg): implement backspace 2025-06-24 15:52:28 +00:00
83f153f654 update 2025-06-24 15:17:37 +00:00
75637c7793 fix(wysiwyg): fix demo 2025-06-24 15:15:46 +00:00
e0a125c9bd fix(wysiwyg): cursor position 2025-06-24 13:53:47 +00:00
4b2178cedd fix(wysiwyg):Improve Wysiwyg editor 2025-06-24 13:41:12 +00:00
08a4c361fa fix(wysiwyg):Improve Wysiwyg editor 2025-06-24 12:24:02 +00:00
35a648d450 refactor(wysiwyg): Clean up code and ensure drag-drop works with programmatic rendering
- Update drag handler to work without requestUpdate calls
- Remove duplicate modal methods (using WysiwygModalManager)
- Clean up unused imports and methods
- Ensure all DOM updates are programmatic
- Add comprehensive test files for all features
- Follow separation of concerns principles
2025-06-24 11:12:56 +00:00
1c76ade150 fix(wysiwyg): Implement programmatic rendering to eliminate focus loss during typing
- Convert parent component to use static rendering with programmatic DOM manipulation
- Remove all reactive state that could trigger re-renders during editing
- Delay content sync to avoid interference with typing (2s auto-save)
- Update all block operations to use manual DOM updates instead of Lit re-renders
- Fix specific issue where typing + arrow keys caused focus loss
- Add comprehensive focus management documentation
2025-06-24 11:06:02 +00:00
8b02c5aea3 fix(dees-modal): theming 2025-06-24 10:45:06 +00:00
c82c407350 fix(dees-moadl): theming 2025-06-24 08:23:24 +00:00
169f74aa2e Improve Wysiwyg editor 2025-06-24 08:19:53 +00:00
e4a042907a Improve Wysiwyg editor 2025-06-24 07:21:09 +00:00
7ce282c500 feat(wysiwyg): Add language selection for code blocks, block settings menu, fix cursor navigation, and update demo to use dees-panel
- Added modal to select programming language when creating code blocks
- Added settings button (3 dots) on each block for configuration
- Fixed cursor not jumping to new block after pressing Enter
- Special handling for code blocks: Enter creates new line, Shift+Enter creates new block
- Display language indicator on code blocks
- Updated demo to use dees-panel instead of demo-section divs
- Added language selection in block settings modal for existing code blocks
2025-06-23 21:28:58 +00:00
302777feff feat(editor): Add wysiwyg editor 2025-06-23 21:18:03 +00:00
cdcd4f79c8 feat(editor): Add wysiwyg editor 2025-06-23 21:15:04 +00:00
f2e6342a61 feat(editor): Add wysiwyg editor 2025-06-23 18:12:24 +00:00
0f02e7d00f feat(editor): Add wysiwyg editor 2025-06-23 18:07:46 +00:00
a1079cbbdd feat(editor): Add wysiwyg editor 2025-06-23 18:02:40 +00:00
58af08cb0d feat(editor): Add wysiwyg editor 2025-06-23 17:52:10 +00:00
6626726029 feat(editor): Add wysiwyg editor 2025-06-23 17:36:39 +00:00
f3afbb2e48 feat(editor): Add wysiwyg editor 2025-06-23 17:14:47 +00:00
fbd52ee9a5 feat(editor): Add wysiwyg editor 2025-06-23 17:09:53 +00:00
3284d91c2a feat(editor): Add wysiwyg editor 2025-06-23 17:05:28 +00:00
22e6b74c4f 1.9.0
Some checks failed
Default (tags) / security (push) Failing after 27s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-22 20:32:59 +00:00
4de835474b feat(form-inputs): Improve form input consistency and auto spacing across inputs and buttons 2025-06-22 20:32:59 +00:00
024d8af40d 1.8.20
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 21s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-20 00:11:30 +00:00
808b74fa17 fix(deps): Update dependency versions: bump @design.estate/dees-domtools from ^2.1.1 to ^2.3.3, @design.estate/dees-element from ^2.0.42 to ^2.0.44, lucide from ^0.515.0 to ^0.518.0, and @git.zone/tsbundle from ^2.0.15 to ^2.4.0 2025-06-20 00:11:30 +00:00
202881ef1a 1.8.19
Some checks failed
Default (tags) / security (push) Failing after 42s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 14:01:14 +00:00
7de3d451ad fix: Change import to type for DeesForm in dees-form-submit 2025-06-19 14:01:07 +00:00
f0e0430016 1.8.18
Some checks failed
Default (tags) / security (push) Failing after 44s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 13:48:18 +00:00
873579fc97 fix: Import dees-button in dees-form-submit for button functionality 2025-06-19 13:47:52 +00:00
d321db363d 1.8.17
Some checks failed
Default (tags) / security (push) Failing after 45s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:54:51 +00:00
100 changed files with 22254 additions and 1322 deletions

1
.gitignore vendored
View File

@ -3,7 +3,6 @@
# artifacts # artifacts
coverage/ coverage/
public/ public/
pages/
# installs # installs
node_modules/ node_modules/

View File

@ -1,5 +1,31 @@
# Changelog # Changelog
## 2025-06-26 - 1.9.8 - fix(deps, windowlayer)
Update dependency versions and adjust dees-windowlayer CSS to add pointer-events fix
- Bump @design.estate/dees-wcctools from ^1.0.98 to ^1.0.101
- Bump @tiptap packages from 2.22.3 to 2.23.0
- Bump lucide from ^0.522.0 to ^0.523.0
- Bump @git.zone/tsbundle from ^2.4.0 to ^2.5.1 and tswatch from ^2.0.37 to ^2.1.2
- Add 'pointer-events: none' to dees-windowlayer CSS to improve overlay behavior
## 2025-06-22 - 1.9.0 - feat(form-inputs)
Improve form input consistency and auto spacing across inputs and buttons
- Add an 'insideForm' property to dees-button for auto-detection and proper margin adjustment in forms.
- Update dees-input-radio to include a 'name' property so that radio buttons in the same group are mutually exclusive.
- Enhance dees-form to group radio inputs properly when collecting form data.
- Revise readme.hints.md and readme.plan.md to document changes and provide guidance for dees-input-radio.
- Update demos for dees-button and dees-form to showcase correct spacing in vertical and horizontal layouts.
## 2025-06-20 - 1.8.20 - fix(deps)
Update dependency versions: bump @design.estate/dees-domtools from ^2.1.1 to ^2.3.3, @design.estate/dees-element from ^2.0.42 to ^2.0.44, lucide from ^0.515.0 to ^0.518.0, and @git.zone/tsbundle from ^2.0.15 to ^2.4.0
- Upgrade @design.estate/dees-domtools from ^2.1.1 to ^2.3.3
- Upgrade @design.estate/dees-element from ^2.0.42 to ^2.0.44
- Upgrade lucide from ^0.515.0 to ^0.518.0
- Upgrade @git.zone/tsbundle from ^2.0.15 to ^2.4.0
## 2025-06-10 - 1.8.1 - fix(dees-statsgrid) ## 2025-06-10 - 1.8.1 - fix(dees-statsgrid)
Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles. Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles.

View File

@ -1,23 +1,23 @@
{ {
"name": "@design.estate/dees-catalog", "name": "@design.estate/dees-catalog",
"version": "1.8.16", "version": "1.9.8",
"private": false, "private": false,
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.", "description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"main": "dist_ts_web/index.js", "main": "dist_ts_web/index.js",
"typings": "dist_ts_web/index.d.ts", "typings": "dist_ts_web/index.d.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "tstest test/ --web", "test": "tstest test/ --web --verbose --timeout 30",
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production", "build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild",
"watch": "tswatch element", "watch": "tswatch element",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"author": "Lossless GmbH", "author": "Lossless GmbH",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@design.estate/dees-domtools": "^2.1.1", "@design.estate/dees-domtools": "^2.3.3",
"@design.estate/dees-element": "^2.0.42", "@design.estate/dees-element": "^2.0.45",
"@design.estate/dees-wcctools": "^1.0.98", "@design.estate/dees-wcctools": "^1.0.101",
"@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",
"@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-regular-svg-icons": "^6.7.2",
@ -25,12 +25,18 @@
"@push.rocks/smarti18n": "^1.0.4", "@push.rocks/smarti18n": "^1.0.4",
"@push.rocks/smartpromise": "^4.2.0", "@push.rocks/smartpromise": "^4.2.0",
"@push.rocks/smartstring": "^4.0.15", "@push.rocks/smartstring": "^4.0.15",
"@tiptap/core": "^2.23.0",
"@tiptap/extension-link": "^2.23.0",
"@tiptap/extension-text-align": "^2.23.0",
"@tiptap/extension-typography": "^2.23.0",
"@tiptap/extension-underline": "^2.23.0",
"@tiptap/starter-kit": "^2.23.0",
"@tsclass/tsclass": "^9.2.0", "@tsclass/tsclass": "^9.2.0",
"@webcontainer/api": "1.2.0", "@webcontainer/api": "1.2.0",
"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.515.0", "lucide": "^0.523.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",
@ -38,9 +44,9 @@
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.6.4", "@git.zone/tsbuild": "^2.6.4",
"@git.zone/tsbundle": "^2.0.15", "@git.zone/tsbundle": "^2.5.1",
"@git.zone/tstest": "^2.3.1", "@git.zone/tstest": "^2.3.1",
"@git.zone/tswatch": "^2.0.37", "@git.zone/tswatch": "^2.1.2",
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/tapbundle": "^6.0.3", "@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.0.0" "@types/node": "^22.0.0"

1520
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -58,4 +58,550 @@
- View mode selectors - View mode selectors
- Action grouping - Action grouping
- Navigation options - Navigation options
- Filter controls - Filter controls
## Form Components
### dees-input-radio
- Radio button component with proper group behavior
- Properties:
- `name`: Group name for mutually exclusive selection
- `key`: Unique identifier for the radio option
- `value`: Boolean indicating selection state
- `label`: Display label
- Features:
- Automatic group management (radios with same name are mutually exclusive)
- Cannot be deselected by clicking (proper radio behavior)
- Form integration: Radio groups are collected by name, value is the selected radio's key
- Works both inside and outside forms
- Supports disabled state
- Fixed: Radio buttons now properly deselect others in the group on first click
- Note: When using in forms, set both `name` (for grouping) and `key` (for the value)
## WYSIWYG Editor Architecture
### Recent Refactoring (2025-06-24)
The WYSIWYG editor has been refactored to improve maintainability and separation of concerns:
#### New Handler Classes
1. **WysiwygBlockOperations** (`wysiwyg.blockoperations.ts`)
- Manages all block-related operations
- Methods: createBlock, insertBlockAfter, removeBlock, findBlock, focusBlock, etc.
- Centralized block manipulation logic
2. **WysiwygInputHandler** (`wysiwyg.inputhandler.ts`)
- Handles all input events for blocks
- Manages block content updates based on type
- Detects block type transformations
- Handles slash commands
- Manages auto-save with debouncing
3. **WysiwygKeyboardHandler** (`wysiwyg.keyboardhandler.ts`)
- Handles all keyboard events
- Manages formatting shortcuts (Cmd/Ctrl + B/I/U/K)
- Handles special keys: Tab, Enter, Backspace
- Manages slash menu navigation
4. **WysiwygDragDropHandler** (`wysiwyg.dragdrophandler.ts`)
- Manages drag and drop operations
- Tracks drag state
- Handles visual feedback during drag
- Manages block reordering
5. **WysiwygModalManager** (`wysiwyg.modalmanager.ts`)
- Static methods for showing modals
- Language selection for code blocks
- Block settings modal
- Reusable modal patterns
#### Main Component Updates
The main `DeesInputWysiwyg` component now:
- Instantiates handler classes in `connectedCallback`
- Delegates complex operations to appropriate handlers
- Maintains cleaner, more focused code
- Better separation of concerns
#### Benefits
- Reduced main component size from 1100+ lines
- Each handler class is focused on a single responsibility
- Easier to test individual components
- Better code organization
- Improved maintainability
#### Fixed Issues
- Enter key no longer duplicates content in new blocks
- Removed problematic `setBlockContents()` method
- Content is now managed directly through DOM properties
- Better timing for block creation and focus
- Slash menu no longer disappears immediately on first "/" press
- Focus is properly maintained when slash menu opens
- Removed duplicate event handling methods from main component
- Simplified focus management throughout the editor
#### Additional Refactoring (2025-06-24 - Part 2)
- **Removed duplicate code**: handleBlockInput and handleBlockKeyDown methods removed from main component
- **Simplified focus management**: Removed complex lifecycle methods and timers
- **Fixed slash menu behavior**: Changed to click events and proper event prevention
- **dees-wysiwyg-block component**: Now uses static HTML rendering for better content control
- **Improved formatting preservation**: HTML formatting (bold, italic, etc.) properly preserved in all block types
#### Notes
- All input handling now goes through WysiwygInputHandler
- All keyboard handling goes through WysiwygKeyboardHandler
- The slash menu uses click events instead of mousedown for better UX
- Focus is maintained using requestAnimationFrame for better timing
- The refactoring maintains all existing functionality with improved reliability
### Global Menu Architecture (2025-06-24 - Part 3)
The slash menu and formatting menu have been refactored to render globally instead of inside the wysiwyg component. This fixes focus loss issues that were occurring when the menus were re-rendered with the component.
#### Key Components:
1. **DeesSlashMenu** (`dees-slash-menu.ts`)
- Singleton component that renders globally in the document body
- Accessed via `DeesSlashMenu.getInstance()`
- Manages its own visibility, position, and filtering
- Emits callbacks when items are selected
2. **DeesFormattingMenu** (`dees-formatting-menu.ts`)
- Singleton component that renders globally in the document body
- Accessed via `DeesFormattingMenu.getInstance()`
- Shows when text is selected
- Applies formatting commands via callback
3. **Integration in DeesInputWysiwyg**
- Stores singleton instances: `private slashMenu = DeesSlashMenu.getInstance()`
- Shows menus with absolute positioning
- Menus handle their own rendering and state management
#### Benefits:
- No focus loss when menus appear/disappear
- Better performance (menus don't re-render with component)
- Cleaner separation of concerns
- Menus persist across component updates
#### Usage:
```typescript
// Show slash menu
this.slashMenu.show(
{ x: cursorX, y: cursorY },
(type: string) => this.insertBlock(type)
);
// Show formatting menu
this.formattingMenu.show(
{ x: selectionX, y: selectionY },
(command: string) => this.applyFormat(command)
);
```
#### Previous Issues Fixed:
- Slash menu was disappearing immediately on first "/" press
- Focus was lost when menus appeared
- Text selection was not working properly
- Cursor position was lost after menu interactions
### Arrow Key Navigation (2025-06-24 - Part 4)
Enhanced arrow key handling for seamless navigation between blocks:
#### Features:
1. **ArrowUp at block start**: Automatically navigates to the end of the previous block
2. **ArrowDown at block end**: Automatically navigates to the beginning of the next block
3. **Smart detection**: Checks actual cursor position within the block content
4. **Slash menu integration**: When slash menu is open, arrow keys navigate menu items instead
5. **No focus loss**: Navigation maintains focus throughout
#### Implementation:
- Added `handleArrowUp()` and `handleArrowDown()` methods to `WysiwygKeyboardHandler`
- Smart cursor position detection for different block types (text, lists, etc.)
- Helper method `getLastTextNode()` for finding the last text position in complex HTML
- Prevents default behavior only when navigating between blocks
- Skips divider blocks during navigation
### Focus Management Improvements (2025-06-24 - Part 5)
Enhanced focus management to prevent focus loss during various operations:
#### Key Improvements:
1. **Formatting Without execCommand**:
- Replaced deprecated `document.execCommand` with modern DOM manipulation
- Proper selection restoration after formatting
- Async formatting operations to maintain focus
2. **Link Dialog**:
- Replaced `prompt()` with custom modal dialog
- Maintains focus context during async operations
- Auto-focuses input field in modal
3. **Robust Focus Methods**:
- Double `requestAnimationFrame` for DOM update timing
- Fallback focus attempts with microtasks
- Contenteditable attribute verification
4. **Cursor Positioning**:
- Enhanced `setCursorToStart/End` with edge case handling
- Zero-width space insertion for empty elements
- Recursive node traversal for complex HTML structures
5. **Async Keyboard Shortcuts**:
- Formatting shortcuts use Promise resolution
- Prevents focus loss during rapid keyboard input
#### Implementation Details:
- `focusWithCursor()` method now handles empty blocks and complex HTML
- `applyFormat()` is async and properly restores selection
- Link creation no longer uses blocking `prompt()` dialog
- All focus operations use proper timing with RAF and microtasks
### Focus Loss Prevention for Menus (2025-06-24 - Part 6)
Fixed focus loss issues when slash menu and formatting menu appear:
#### Key Fixes:
1. **Timeout Reduction**:
- Replaced 50ms setTimeout with requestAnimationFrame
- Immediate focus attempt before falling back to RAF
- Reduced delay when inserting blocks
2. **Menu Focus Prevention**:
- Added `tabindex="-1"` to prevent menus from taking focus
- Added focus event prevention on menus
- Menus now use mousedown prevention consistently
3. **Blur Event Handling**:
- Skip value updates when slash menu is visible
- Prevent auto-save during slash menu interaction
- Maintain focus after menu appears with RAF
4. **Block Focus Optimization**:
- Try immediate focus if block element exists
- Fall back to RAF only when necessary
- Consistent focus handling across all block types
#### Implementation:
- `handleBlockBlur()` checks if slash menu is visible before updating
- `scheduleAutoSave()` skips saving when slash menu is open
- Slash menu show adds RAF to restore focus if lost
- Reduced timing delays throughout the focus chain
### Slash Command Cleanup (2025-06-24 - Part 7)
Fixed the issue where "/" remained in the editor after selecting a block type:
#### The Fix:
1. **In `insertBlock()`**:
- Clear slash command before transforming block type
- Use regex `/^\/[^\s]*\s*/` to match slash + filter text
- Trim the result to ensure clean content
- Set content to empty for transformed blocks
2. **Improved Content Handling**:
- Wait for `updateComplete` before focusing
- Ensure lists start with empty content
- Consistent cleanup in both `insertBlock` and `closeSlashMenu`
3. **Edge Cases**:
- Handle filtered commands (e.g., "/hea" for heading)
- Clear content even with partial matches
- Proper content reset for all block types
Now when selecting a block type from the slash menu, the "/" and any filter text is properly removed before the block transformation occurs.
### Enhanced Enter Key and Block Settings (2025-06-24 - Part 8)
Added two major improvements to the wysiwyg editor:
#### 1. Smart Enter Key Behavior:
When pressing Enter, content after the cursor is now moved to the next block:
- **Content Splitting**: Uses Range API to extract content after cursor
- **HTML Preservation**: Maintains formatting when splitting blocks
- **Clean Split**: Current block keeps content before cursor, new block gets content after
- **Empty Block**: If cursor is at end, creates empty new block
Implementation in `WysiwygKeyboardHandler.handleEnter()`:
```typescript
// Clone the range to extract content after cursor
const afterRange = range.cloneRange();
afterRange.selectNodeContents(target);
afterRange.setStart(range.endContainer, range.endOffset);
// Extract content after cursor
const afterContent = afterRange.extractContents();
```
#### 2. Block Type Changing via Settings Menu:
The block settings menu (three dots) now includes block type selection:
- **Type Selector Grid**: Shows all available block types with icons
- **Smart Metadata Handling**:
- Clears code language when changing from code block
- Clears list type when changing from list
- Prompts for language when changing to code block
- **Visual Feedback**: Currently selected type is highlighted
- **Instant Update**: Block transforms immediately on selection
Features:
- Works for all block types (not just code blocks)
- Preserves content during type transformation
- Handles special cases like code block language selection
- Modal closes automatically after selection
### Complete WYSIWYG Refactoring (2025-06-24 - Part 9)
Major architectural improvements to fix Enter key behavior and left arrow focus loss:
#### 1. Async Operation Architecture:
- All focus operations are now async with proper Promise handling
- `insertBlockAfter()` waits for component updates before focusing
- `focusBlock()` ensures DOM is ready with `updateComplete`
- Eliminated arbitrary timeouts in favor of proper async/await
#### 2. Enter Key Split Content Fix:
- Added `getSplitContent()` method to block component
- Properly extracts content before/after cursor using Range API
- Updates current block and creates new block atomically
- Content after cursor correctly moves to new block
```typescript
// In block component
public getSplitContent(): { before: string; after: string } | null {
const beforeRange = range.cloneRange();
beforeRange.selectNodeContents(this.blockElement);
beforeRange.setEnd(range.startContainer, range.startOffset);
// ... extract and return split content
}
```
#### 3. Arrow Key Navigation:
- Added ArrowLeft/ArrowRight handlers for block boundaries
- Prevents focus loss when navigating between blocks
- Only intercepts at block boundaries, normal navigation otherwise
- All arrow key operations are async for proper timing
#### 4. Interface Architecture:
Created `wysiwyg.interfaces.ts` with proper typing:
- `IWysiwygComponent` - Main component contract
- `IBlockOperations` - Block operation methods
- `IWysiwygBlockComponent` - Block component interface
- `IBlockEventHandlers` - Event handler signatures
#### 5. Focus Management Improvements:
- Eliminated double RAF in favor of single async flow
- Focus operations wait for DOM updates via `updateComplete`
- Proper cursor positioning after all operations
- No more focus loss during navigation
#### Key Changes:
1. Keyboard handler methods are now async
2. Block operations return Promises
3. Enter key properly splits content at cursor
4. Arrow keys handle block navigation without focus loss
5. All timing is handled via proper async/await patterns
The refactoring eliminates race conditions and timing issues that were causing focus loss and content duplication problems.
### Programmatic Rendering Solution (2025-06-24 - Part 10)
Fixed persistent focus loss issue by implementing fully programmatic rendering:
#### The Problem:
- User would click in a block, type text, then press arrow keys and lose focus
- Root cause: Lit was re-rendering components when block content was mutated
- Even with shouldUpdate() preventing re-renders, parent re-evaluation caused focus loss
#### The Solution:
1. **Static Parent Rendering**:
- Parent component renders only once with empty editor content div
- All blocks are created and managed programmatically via DOM manipulation
- No Lit re-renders triggered by state changes
2. **Manual Block Management**:
- `renderBlocksProgrammatically()` creates all block elements manually
- `createBlockElement()` builds block wrapper with all event handlers
- `updateBlockElement()` replaces individual blocks when needed
- No reactive properties trigger parent re-renders
3. **Content Update Strategy**:
- During typing, content is NOT immediately synced to data model
- Auto-save delayed to 2 seconds to avoid interference
- Content synced from DOM only on blur or before save
- `syncAllBlockContent()` reads from DOM when needed
4. **Focus Preservation**:
- Block components prevent re-renders with `shouldUpdate()`
- Parent never re-renders after initial load
- Focus remains stable during all editing operations
- Arrow key navigation works without focus loss
5. **Implementation Details**:
```typescript
// Parent render method - static after first render
render(): TemplateResult {
return html`
<div class="editor-content" id="editor-content">
<!-- Blocks rendered programmatically -->
</div>
`;
}
// All block operations use DOM manipulation
private renderBlocksProgrammatically() {
this.editorContentRef.innerHTML = '';
this.blocks.forEach(block => {
const blockWrapper = this.createBlockElement(block);
this.editorContentRef.appendChild(blockWrapper);
});
}
```
This approach completely eliminates focus loss by taking full control of the DOM and preventing any framework-induced re-renders during editing.
### Code Refactoring and Cleanup (2025-06-24 - Part 11)
Completed comprehensive refactoring to ensure clean, maintainable code with separated concerns:
#### Refactoring Changes:
1. **Drag and Drop Handler Cleanup**:
- Removed all `requestUpdate()` calls from drag handler
- Handler now only updates internal state
- Parent component handles DOM updates programmatically
- Simplified drag state management
2. **Unused Code Removal**:
- Removed duplicate `showBlockSettingsModal` method (using WysiwygModalManager)
- Removed duplicate `showLanguageSelectionModal` method
- Removed unused `renderBlock` method
- Cleaned up unused imports (WysiwygBlocks, ISlashMenuItem)
3. **Import Cleanup**:
- Removed unused type imports
- Organized imports logically
- Kept only necessary dependencies
4. **Separated Concerns**:
- Modal management in WysiwygModalManager
- Block operations in WysiwygBlockOperations
- Input handling in WysiwygInputHandler
- Keyboard handling in WysiwygKeyboardHandler
- Drag/drop in WysiwygDragDropHandler
- Each class has a single responsibility
5. **Programmatic DOM Management**:
- All DOM updates happen through explicit methods
- No reactive re-renders during user interaction
- Manual class management for drag states
- Direct DOM manipulation for performance
6. **Test Files Created**:
- `test-focus-fix.html` - Verifies focus management
- `test-drag-drop.html` - Tests drag and drop functionality
- `test-comprehensive.html` - Tests all features together
The refactoring follows the principles in instructions.md:
- Uses static templates with manual DOM operations
- Maintains separated concerns in different classes
- Results in clean, concise, and manageable code
## Z-Index Management System (2025-12-24)
A comprehensive z-index management system has been implemented to fix overlay stacking conflicts:
### The Problem:
- Modals were hiding dropdown overlays
- Context menus appeared behind modals
- Inconsistent z-index values across components
- No clear hierarchy for overlay stacking
### The Solution:
#### 1. Central Z-Index Constants (`00zindex.ts`):
Created a centralized file defining all z-index layers:
```typescript
export const zIndexLayers = {
// Base layer: Regular content
base: {
content: 'auto',
inputElements: 1,
},
// Fixed UI elements
fixed: {
appBar: 10,
sideMenu: 10,
mobileNav: 250,
},
// Overlay backdrops
backdrop: {
dropdown: 1999,
modal: 2999,
contextMenu: 3999,
},
// Interactive overlays
overlay: {
dropdown: 2000, // Dropdowns and select menus
modal: 3000, // Modal dialogs
contextMenu: 4000, // Context menus and tooltips
toast: 5000, // Toast notifications
},
// Special cases
modalDropdown: 3500, // Dropdowns inside modals
wysiwygMenus: 4500, // Editor formatting menus
}
```
#### 2. Updated Components:
- **dees-modal**: Changed from 2000 to 3000
- **dees-windowlayer**: Changed from 200-201 to 1999-2000 (used by dropdowns)
- **dees-contextmenu**: Changed from 10000 to 4000
- **dees-toast**: Changed from 10000 to 5000
- **wysiwyg menus**: Changed from 10000 to 4500
- **dees-appui-profiledropdown**: Uses new dropdown z-index (2000)
#### 3. Stacking Order (bottom to top):
1. Regular page content (auto)
2. Fixed navigation elements (10-250)
3. Dropdown backdrop (1999)
4. Dropdown content (2000)
5. Modal backdrop (2999)
6. Modal content (3000)
7. Context menu (4000)
8. WYSIWYG menus (4500)
9. Toast notifications (5000)
#### 4. Key Benefits:
- Dropdowns now appear above modals
- Context menus appear above dropdowns and modals
- Toast notifications always appear on top
- Consistent and predictable stacking behavior
- Easy to adjust hierarchy by modifying central constants
#### 5. Testing:
Created `test-zindex.demo.ts` to verify stacking behavior with:
- Modal containing dropdown
- Context menu on modal
- Toast notifications
- Complex overlay combinations
### Usage:
Import and use the z-index constants in any component:
```typescript
import { zIndexLayers } from './00zindex.js';
// In styles
z-index: ${zIndexLayers.overlay.modal};
```
This system ensures proper stacking order for all overlay components and prevents z-index conflicts.

471
readme.md
View File

@ -12,14 +12,15 @@ npm install @design.estate/dees-catalog
| Category | Components | | Category | Components |
|----------|------------| |----------|------------|
| Core UI | `DeesButton`, `DeesBadge`, `DeesChips`, `DeesIcon`, `DeesLabel`, `DeesSpinner`, `DeesToast` | | Core UI | `DeesButton`, `DeesButtonExit`, `DeesButtonGroup`, `DeesBadge`, `DeesChips`, `DeesHeading`, `DeesHint`, `DeesIcon`, `DeesLabel`, `DeesPanel`, `DeesSearchbar`, `DeesSpinner`, `DeesToast`, `DeesWindowcontrols` |
| Forms | `DeesForm`, `DeesInputText`, `DeesInputCheckbox`, `DeesInputDropdown`, `DeesInputRadio`, `DeesInputFileupload`, `DeesInputIban`, `DeesInputPhone`, `DeesInputQuantitySelector`, `DeesInputMultitoggle`, `DeesFormSubmit` | | Forms | `DeesForm`, `DeesInputText`, `DeesInputCheckbox`, `DeesInputDropdown`, `DeesInputRadiogroup`, `DeesInputFileupload`, `DeesInputIban`, `DeesInputPhone`, `DeesInputQuantitySelector`, `DeesInputMultitoggle`, `DeesInputTags`, `DeesInputTypelist`, `DeesInputRichtext`, `DeesInputWysiwyg`, `DeesFormSubmit` |
| Layout | `DeesAppuiBase`, `DeesAppuiMainmenu`, `DeesAppuiMainselector`, `DeesAppuiMaincontent`, `DeesAppuiAppbar`, `DeesMobileNavigation` | | Layout | `DeesAppuiBase`, `DeesAppuiMainmenu`, `DeesAppuiMainselector`, `DeesAppuiMaincontent`, `DeesAppuiAppbar`, `DeesAppuiActivitylog`, `DeesAppuiProfiledropdown`, `DeesAppuiTabs`, `DeesAppuiView`, `DeesMobileNavigation` |
| Data Display | `DeesTable`, `DeesDataviewCodebox`, `DeesDataviewStatusobject`, `DeesPdf`, `DeesStatsGrid` | | Data Display | `DeesTable`, `DeesDataviewCodebox`, `DeesDataviewStatusobject`, `DeesPdf`, `DeesStatsGrid`, `DeesPagination` |
| Visualization | `DeesChartArea`, `DeesChartLog` | | Visualization | `DeesChartArea`, `DeesChartLog` |
| Dialogs & Overlays | `DeesModal`, `DeesContextmenu`, `DeesSpeechbubble`, `DeesWindowlayer` | | Dialogs & Overlays | `DeesModal`, `DeesContextmenu`, `DeesSpeechbubble`, `DeesWindowlayer` |
| Navigation | `DeesStepper`, `DeesProgressbar` | | Navigation | `DeesStepper`, `DeesProgressbar`, `DeesMobileNavigation` |
| Development | `DeesEditor`, `DeesEditorMarkdown`, `DeesTerminal`, `DeesUpdater` | | Development | `DeesEditor`, `DeesEditorMarkdown`, `DeesEditorMarkdownoutlet`, `DeesTerminal`, `DeesUpdater` |
| Auth & Utilities | `DeesSimpleAppdash`, `DeesSimpleLogin` |
## Detailed Component Documentation ## Detailed Component Documentation
@ -149,6 +150,93 @@ Key Features:
- Theme-aware styling - Theme-aware styling
- Programmatic control - Programmatic control
#### `DeesButtonExit`
Exit/close button component with consistent styling.
```typescript
<dees-button-exit
@click=${handleClose}
></dees-button-exit>
```
#### `DeesButtonGroup`
Container for grouping related buttons together.
```typescript
<dees-button-group
.buttons=${[
{ text: 'Save', type: 'highlighted', action: handleSave },
{ text: 'Cancel', type: 'normal', action: handleCancel }
]}
spacing="medium" // Options: small, medium, large
></dees-button-group>
```
#### `DeesHeading`
Consistent heading component with level and styling options.
```typescript
<dees-heading
level={1} // 1-6 for H1-H6
text="Page Title"
.subheading=${'Optional subtitle'}
centered // Optional: center alignment
></dees-heading>
```
#### `DeesHint`
Hint/tooltip component for providing contextual help.
```typescript
<dees-hint
text="This field is required"
type="info" // Options: info, warning, error, success
position="top" // Options: top, bottom, left, right
></dees-hint>
```
#### `DeesPanel`
Container component for grouping related content with optional title and actions.
```typescript
<dees-panel
.title=${'Panel Title'}
.subtitle=${'Optional subtitle'}
collapsible // Optional: allow collapse/expand
collapsed={false} // Initial collapsed state
.actions=${[
{ icon: 'settings', action: handleSettings }
]}
>
<!-- Panel content -->
</dees-panel>
```
#### `DeesSearchbar`
Search input component with suggestions and search handling.
```typescript
<dees-searchbar
placeholder="Search..."
.suggestions=${['item1', 'item2', 'item3']}
showClearButton // Show clear button when has value
@search=${handleSearch}
@suggestion-select=${handleSuggestionSelect}
></dees-searchbar>
```
#### `DeesWindowcontrols`
Window control buttons (minimize, maximize, close) for desktop-like applications.
```typescript
<dees-windowcontrols
.controls=${['minimize', 'maximize', 'close']}
@minimize=${handleMinimize}
@maximize=${handleMaximize}
@close=${handleClose}
></dees-windowcontrols>
```
### Form Components ### Form Components
#### `DeesForm` #### `DeesForm`
@ -207,22 +295,6 @@ Dropdown selection component with search and filtering capabilities.
></dees-input-dropdown> ></dees-input-dropdown>
``` ```
#### `DeesInputRadio`
Radio button group for single-choice selections.
```typescript
<dees-input-radio
key="gender"
label="Gender"
.options=${[
{ key: 'male', option: 'Male' },
{ key: 'female', option: 'Female' },
{ key: 'other', option: 'Other' }
]}
required
></dees-input-radio>
```
#### `DeesInputFileupload` #### `DeesInputFileupload`
File upload component with drag-and-drop support. File upload component with drag-and-drop support.
@ -293,6 +365,121 @@ Multi-state toggle button group.
></dees-input-multitoggle> ></dees-input-multitoggle>
``` ```
#### `DeesInputRadiogroup`
Radio button group for single-choice selections with internal state management.
```typescript
<dees-input-radiogroup
key="plan"
label="Select Plan"
.options=${['Free', 'Pro', 'Enterprise']}
selectedOption="Pro"
required
@change=${handlePlanChange}
></dees-input-radiogroup>
// With custom option objects
<dees-input-radiogroup
key="priority"
label="Priority Level"
.options=${[
{ key: 'low', label: 'Low Priority' },
{ key: 'medium', label: 'Medium Priority' },
{ key: 'high', label: 'High Priority' }
]}
selectedOption="medium"
></dees-input-radiogroup>
```
#### `DeesInputTags`
Tag input component for managing lists of tags with auto-complete and validation.
```typescript
<dees-input-tags
key="skills"
label="Skills"
.value=${['JavaScript', 'TypeScript', 'CSS']}
placeholder="Add a skill..."
.suggestions=${[
'JavaScript', 'TypeScript', 'Python', 'Go', 'Rust',
'React', 'Vue', 'Angular', 'Node.js', 'Docker'
]}
maxTags={10} // Optional: limit number of tags
required
@change=${handleTagsChange}
></dees-input-tags>
```
Key Features:
- Add tags by pressing Enter or typing comma/semicolon
- Remove tags with click or backspace
- Auto-complete suggestions with keyboard navigation
- Maximum tag limit support
- Full theme support
- Form validation integration
#### `DeesInputTypelist`
Dynamic list input for managing arrays of typed values.
```typescript
<dees-input-typelist
key="features"
label="Product Features"
placeholder="Add a feature..."
.value=${['Feature 1', 'Feature 2']}
@change=${handleFeaturesChange}
></dees-input-typelist>
```
#### `DeesInputRichtext`
Rich text editor with formatting toolbar powered by TipTap.
```typescript
<dees-input-richtext
key="content"
label="Article Content"
.value=${htmlContent}
placeholder="Start writing..."
minHeight={300} // Minimum editor height
showWordCount={true} // Show word/character count
@change=${handleContentChange}
></dees-input-richtext>
```
Key Features:
- Full formatting toolbar (bold, italic, underline, strike, etc.)
- Heading levels (H1-H6)
- Lists (bullet, ordered)
- Links with URL editing
- Code blocks and inline code
- Blockquotes
- Horizontal rules
- Undo/redo support
- Word and character count
- HTML output
#### `DeesInputWysiwyg`
Advanced block-based editor with slash commands and rich content blocks.
```typescript
<dees-input-wysiwyg
key="document"
label="Document Editor"
.value=${documentContent}
outputFormat="html" // Options: html, markdown, json
@change=${handleDocumentChange}
></dees-input-wysiwyg>
```
Key Features:
- Slash commands for quick formatting
- Block-based editing (paragraphs, headings, lists, etc.)
- Drag and drop block reordering
- Multiple output formats
- Keyboard shortcuts
- Collaborative editing ready
- Extensible block types
#### `DeesFormSubmit` #### `DeesFormSubmit`
Submit button component specifically designed for `DeesForm`. Submit button component specifically designed for `DeesForm`.
@ -622,6 +809,91 @@ Best Practices:
- Test with screen readers - Test with screen readers
- Maintain focus management - Maintain focus management
#### `DeesAppuiActivitylog`
Activity log component for displaying system events and user actions.
```typescript
<dees-appui-activitylog
.entries=${[
{
timestamp: new Date(),
type: 'info',
message: 'User logged in',
details: { userId: '123' }
},
{
timestamp: new Date(),
type: 'error',
message: 'Failed to save document',
details: { error: 'Network error' }
}
]}
maxEntries={100} // Maximum entries to display
@entry-click=${handleEntryClick}
></dees-appui-activitylog>
```
#### `DeesAppuiProfiledropdown`
User profile dropdown component with status and menu options.
```typescript
<dees-appui-profiledropdown
.user=${{
name: 'John Doe',
email: 'john@example.com',
avatar: '/path/to/avatar.jpg',
status: 'online' // Options: online, offline, busy, away
}}
.menuItems=${[
{ name: 'Profile', iconName: 'user', action: async () => {} },
{ name: 'Settings', iconName: 'settings', action: async () => {} },
{ divider: true },
{ name: 'Logout', iconName: 'logOut', action: async () => {} }
]}
@status-change=${handleStatusChange}
></dees-appui-profiledropdown>
```
#### `DeesAppuiTabs`
Tab navigation component for organizing content sections.
```typescript
<dees-appui-tabs
.tabs=${[
{
key: 'overview',
label: 'Overview',
icon: 'home',
content: html`<div>Overview content</div>`
},
{
key: 'details',
label: 'Details',
icon: 'info',
content: html`<div>Details content</div>`
}
]}
selectedTab="overview"
@tab-change=${handleTabChange}
></dees-appui-tabs>
```
#### `DeesAppuiView`
View container component for consistent page layouts.
```typescript
<dees-appui-view
viewTitle="Dashboard"
viewSubtitle="System Overview"
.headerActions=${[
{ icon: 'refresh', action: handleRefresh },
{ icon: 'settings', action: handleSettings }
]}
>
<!-- View content -->
</dees-appui-view>
```
#### `DeesMobileNavigation` #### `DeesMobileNavigation`
Responsive navigation component for mobile devices. Responsive navigation component for mobile devices.
@ -982,6 +1254,27 @@ setInterval(() => {
}, 3000); }, 3000);
``` ```
#### `DeesPagination`
Pagination component for navigating through large datasets.
```typescript
<dees-pagination
totalItems={500}
itemsPerPage={20}
currentPage={1}
maxVisiblePages={7} // Maximum page numbers to display
@page-change=${handlePageChange}
></dees-pagination>
```
Key Features:
- Page number navigation
- Previous/next buttons
- Jump to first/last page
- Configurable items per page
- Responsive design
- Keyboard navigation support
### Visualization Components ### Visualization Components
#### `DeesChartArea` #### `DeesChartArea`
@ -1306,52 +1599,6 @@ Key Features:
- Animation support - Animation support
- Accessibility features - Accessibility features
#### `DeesMobileNavigation`
Mobile-optimized navigation component with touch support.
```typescript
// Programmatic usage
DeesMobilenavigation.createAndShow([
{
name: 'Home',
action: async (nav) => {
// Handle navigation
return null;
}
},
{
name: 'Settings',
action: async (nav) => {
// Handle navigation
return null;
}
}
]);
// Component usage
<dees-mobilenavigation
heading="MENU"
.menuItems=${[
{
name: 'Profile',
action: (nav) => handleNavigation('profile')
},
{
name: 'Settings',
action: (nav) => handleNavigation('settings')
}
]}
></dees-mobilenavigation>
```
Key Features:
- Touch-friendly interface
- Slide-in animation
- Backdrop overlay
- Single instance management
- Custom menu items
- Responsive design
Best Practices: Best Practices:
1. Stepper Implementation 1. Stepper Implementation
@ -1368,13 +1615,6 @@ Best Practices:
- Performance monitoring - Performance monitoring
- Error state handling - Error state handling
3. Mobile Navigation
- Touch-optimized targets
- Clear visual hierarchy
- Smooth animations
- Gesture support
- Responsive behavior
Common Use Cases: Common Use Cases:
1. Stepper 1. Stepper
@ -1391,13 +1631,6 @@ Common Use Cases:
- Task completion - Task completion
- Step progression - Step progression
3. Mobile Navigation
- Responsive menus
- App navigation
- Settings access
- User actions
- Context switching
Accessibility Considerations: Accessibility Considerations:
- Keyboard navigation support - Keyboard navigation support
- ARIA labels and roles - ARIA labels and roles
@ -1461,6 +1694,26 @@ Key Features:
- Spellcheck integration - Spellcheck integration
- Auto-save functionality - Auto-save functionality
#### `DeesEditorMarkdownoutlet`
Markdown preview component for rendering markdown content.
```typescript
<dees-editor-markdownoutlet
.markdown=${markdownContent}
.theme=${'github'} // Options: github, dark, custom
.plugins=${['mermaid', 'highlight']} // Optional plugins
allowHtml={false} // Security: disable raw HTML
></dees-editor-markdownoutlet>
```
Key Features:
- Safe markdown rendering
- Multiple themes
- Plugin support (mermaid diagrams, syntax highlighting)
- XSS protection
- Custom CSS injection
- Responsive images
#### `DeesTerminal` #### `DeesTerminal`
Terminal emulator component for command-line interface. Terminal emulator component for command-line interface.
@ -1606,6 +1859,60 @@ Accessibility Features:
- Focus management - Focus management
- ARIA attributes - ARIA attributes
### Auth & Utilities Components
#### `DeesSimpleAppdash`
Simple application dashboard component for quick prototyping.
```typescript
<dees-simple-appdash
.appTitle=${'My Application'}
.menuItems=${[
{ name: 'Dashboard', icon: 'home', route: '/dashboard' },
{ name: 'Settings', icon: 'settings', route: '/settings' }
]}
.user=${{
name: 'John Doe',
role: 'Administrator'
}}
@menu-select=${handleMenuSelect}
>
<!-- Dashboard content -->
</dees-simple-appdash>
```
Key Features:
- Quick setup dashboard layout
- Built-in navigation
- User profile section
- Responsive design
- Minimal configuration
#### `DeesSimpleLogin`
Simple login form component with validation and customization.
```typescript
<dees-simple-login
.appName=${'My Application'}
.logo=${'./assets/logo.png'}
.backgroundImage=${'./assets/background.jpg'}
.fields=${['username', 'password']} // Options: username, email, password
showForgotPassword
showRememberMe
@login=${handleLogin}
@forgot-password=${handleForgotPassword}
></dees-simple-login>
```
Key Features:
- Customizable fields
- Built-in validation
- Remember me option
- Forgot password link
- Custom branding
- Responsive layout
- Loading states
## License and Legal Information ## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the license file within this repository. This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the license file within this repository.

Binary file not shown.

View File

@ -0,0 +1,138 @@
# WYSIWYG Editor Refactoring Progress Summary
## Latest Updates
### Selection Highlighting Fix ✅
- **Issue**: "Paragraphs are not highlighted consistently, headings are always highlighted"
- **Root Cause**: The `shouldUpdate` method in `dees-wysiwyg-block.ts` was using a generic `.block` selector that would match the first element with that class, not necessarily the correct block element
- **Solution**: Changed the selector to be more specific: `.block.${blockType}` which ensures the correct element is found for each block type
- **Result**: All block types now highlight consistently when selected
### Enter Key Block Creation Fix ✅
- **Issue**: "When pressing enter and jumping to new block then typing something: The cursor is not at the beginning of the new block and there is content"
- **Root Cause**: Block handlers were rendering content with template syntax `${block.content || ''}` in their render methods, which violates the static HTML principle
- **Solution**:
- Removed all `${block.content}` from render methods in paragraph, heading, quote, and code block handlers
- Content is now set programmatically in the setup() method only when needed
- Fixed `setCursorToStart` and `setCursorToEnd` to always find elements fresh instead of relying on cached `blockElement`
- **Result**: New empty blocks remain truly empty, cursor positioning works correctly
### Backspace Key Deletion Fix ✅
- **Issue**: "After typing in a new block, pressing backspace deletes the whole block instead of just the last character"
- **Root Cause**:
1. `getCursorPositionInElement` was using `element.contains()` which doesn't work across Shadow DOM boundaries
2. The backspace handler was checking `block.content === ''` which only contains the stored content, not the actual DOM content
- **Solution**:
1. Fixed `getCursorPositionInElement` to use `containsAcrossShadowDOM` for proper Shadow DOM support
2. Updated backspace handler to get actual content from DOM using `blockComponent.getContent()` instead of relying on stored `block.content`
3. Added debug logging to track cursor position and content state
- **Result**: Backspace now correctly deletes individual characters instead of the whole block
### Arrow Left Navigation Fix ✅
- **Issue**: "When jumping to the previous block from the beginning of a block with arrow left, the cursor should be at the end of the previous block, not at the start"
- **Root Cause**: Browser's default focus behavior places cursor at the beginning of contenteditable elements, overriding our cursor positioning
- **Solution**: For 'end' position, set up the selection range BEFORE focusing the element:
1. Create a range pointing to the end of content
2. Apply the selection
3. Then focus the element (which preserves the existing selection)
4. Only use setCursorToEnd for empty blocks
- **Result**: Arrow left navigation now correctly places cursor at the end of the previous block
## Completed Phases
### Phase 1: Infrastructure ✅
- Created modular block handler architecture
- Implemented `IBlockHandler` interface and `BaseBlockHandler` class
- Created `BlockRegistry` for dynamic block type registration
- Set up proper file structure under `blocks/` directory
### Phase 2: Proof of Concept ✅
- Successfully migrated divider block as the simplest example
- Validated the architecture works correctly
- Established patterns for block migration
### Phase 3: Text Blocks ✅
- **Paragraph Block**: Full editing support with text splitting, selection handling, and cursor tracking
- **Heading Blocks**: All three heading levels (h1, h2, h3) with unified handler
- **Quote Block**: Italic styling with border, full editing capabilities
- **Code Block**: Monospace font, tab handling, plain text paste support
- **List Block**: Bullet/numbered lists with proper list item management
## Key Achievements
### 1. Preserved Critical Knowledge
- **Static Rendering**: Blocks use `innerHTML` in `firstUpdated` to prevent focus loss during typing
- **Shadow DOM Selection**: Implemented `containsAcrossShadowDOM` utility for proper selection detection
- **Cursor Position Tracking**: All editable blocks track cursor position across multiple events
- **Content Splitting**: HTML-aware splitting using Range API preserves formatting
- **Focus Management**: Microtask-based focus restoration ensures reliable cursor placement
### 2. Enhanced Architecture
- Each block type is now self-contained in its own file
- Block handlers are dynamically registered and loaded
- Common functionality is shared through base classes
- Styles are co-located with their block handlers
### 3. Maintained Functionality
- All keyboard navigation works (arrows, backspace, delete, enter)
- Text selection across Shadow DOM boundaries functions correctly
- Block merging and splitting behave as before
- IME (Input Method Editor) support is preserved
- Formatting shortcuts (Cmd/Ctrl+B/I/U/K) continue to work
## Code Organization
```
ts_web/elements/wysiwyg/
├── dees-wysiwyg-block.ts (simplified main component)
├── wysiwyg.selection.ts (Shadow DOM selection utilities)
├── wysiwyg.blockregistration.ts (handler registration)
└── blocks/
├── index.ts (exports and registry)
├── block.base.ts (base handler interface)
├── decorative/
│ └── divider.block.ts
└── text/
├── paragraph.block.ts
├── heading.block.ts
├── quote.block.ts
├── code.block.ts
└── list.block.ts
```
## Next Steps
### Phase 4: Media Blocks (In Progress)
- Image block with upload/drag-drop support
- YouTube block with video embedding
- Attachment block for file uploads
### Phase 5: Content Blocks
- Markdown block with preview toggle
- HTML block with raw HTML editing
### Phase 6: Cleanup
- Remove old code from main component
- Optimize bundle size
- Update documentation
## Technical Improvements
1. **Modularity**: Each block type is now completely self-contained
2. **Extensibility**: New blocks can be added by creating a handler and registering it
3. **Maintainability**: Files are smaller and focused on single responsibilities
4. **Type Safety**: Strong TypeScript interfaces ensure consistent implementation
5. **Performance**: No degradation in performance; potential for lazy loading in future
## Migration Pattern
For future block migrations, follow this pattern:
1. Create block handler extending `BaseBlockHandler`
2. Implement required methods: `render()`, `setup()`, `getStyles()`
3. Add helper methods for cursor/content management
4. Handle Shadow DOM selection properly using utilities
5. Register handler in `wysiwyg.blockregistration.ts`
6. Test all interactions (typing, selection, navigation)
The refactoring has been successful in making the codebase more maintainable while preserving all the hard-won functionality and edge case handling from the original implementation.

82
readme.refactoring.md Normal file
View File

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

View File

@ -1,6 +1,6 @@
import { tap, expect, webhelpers } from '@push.rocks/tapbundle'; import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import * as deesCatalog from '../ts_web'; import * as deesCatalog from '../ts_web/index.js';
tap.test('should create a working button', async () => { tap.test('should create a working button', async () => {
const button: deesCatalog.DeesButton = await webhelpers.fixture( const button: deesCatalog.DeesButton = await webhelpers.fixture(
@ -9,4 +9,4 @@ tap.test('should create a working button', async () => {
expect(button).toBeInstanceOf(deesCatalog.DeesButton); expect(button).toBeInstanceOf(deesCatalog.DeesButton);
}); });
tap.start(); export default tap.start();

View File

@ -0,0 +1,175 @@
import { expect, tap, webhelpers } from '@push.rocks/tapbundle';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
import { WysiwygSelection } from '../ts_web/elements/wysiwyg/wysiwyg.selection.js';
tap.test('Shadow DOM containment should work correctly', async () => {
console.log('=== Testing Shadow DOM Containment ===');
// Create a WYSIWYG block component
const block = await webhelpers.fixture<DeesWysiwygBlock>(
'<dees-wysiwyg-block></dees-wysiwyg-block>'
);
// Set the block data
block.block = {
id: 'test-1',
type: 'paragraph',
content: 'Hello world test content'
};
block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
await block.updateComplete;
// Get the paragraph element inside Shadow DOM
const container = block.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
expect(paragraphBlock).toBeTruthy();
console.log('Found paragraph block:', paragraphBlock);
console.log('Paragraph text content:', paragraphBlock.textContent);
// Focus the paragraph
paragraphBlock.focus();
// Manually set cursor position
const textNode = paragraphBlock.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
// Set cursor at position 11 (after "Hello world")
range.setStart(textNode, 11);
range.setEnd(textNode, 11);
selection?.removeAllRanges();
selection?.addRange(range);
console.log('Set cursor at position 11');
// Test the containment check
console.log('\n--- Testing containment ---');
const currentSelection = window.getSelection();
if (currentSelection && currentSelection.rangeCount > 0) {
const selRange = currentSelection.getRangeAt(0);
console.log('Selection range:', {
startContainer: selRange.startContainer,
startOffset: selRange.startOffset,
containerText: selRange.startContainer.textContent
});
// Test regular contains (should fail across Shadow DOM)
const regularContains = paragraphBlock.contains(selRange.startContainer);
console.log('Regular contains:', regularContains);
// Test Shadow DOM-aware contains
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selRange.startContainer);
console.log('Shadow DOM contains:', shadowDOMContains);
// Since we're setting selection within the same shadow DOM, both should be true
expect(regularContains).toBeTrue();
expect(shadowDOMContains).toBeTrue();
}
// Test getSplitContent
console.log('\n--- Testing getSplitContent ---');
const splitResult = block.getSplitContent();
console.log('Split result:', splitResult);
expect(splitResult).toBeTruthy();
if (splitResult) {
console.log('Before:', JSON.stringify(splitResult.before));
console.log('After:', JSON.stringify(splitResult.after));
// Expected split at position 11
expect(splitResult.before).toEqual('Hello world');
expect(splitResult.after).toEqual(' test content');
}
}
});
tap.test('Shadow DOM containment across different shadow roots', async () => {
console.log('=== Testing Cross Shadow Root Containment ===');
// Create parent component with WYSIWYG editor
const parentDiv = document.createElement('div');
parentDiv.innerHTML = `
<dees-input-wysiwyg>
<dees-wysiwyg-block></dees-wysiwyg-block>
</dees-input-wysiwyg>
`;
document.body.appendChild(parentDiv);
// Wait for components to be ready
await new Promise(resolve => setTimeout(resolve, 100));
const wysiwygInput = parentDiv.querySelector('dees-input-wysiwyg') as any;
const blockElement = wysiwygInput?.shadowRoot?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
if (blockElement) {
// Set block data
blockElement.block = {
id: 'test-2',
type: 'paragraph',
content: 'Cross shadow DOM test'
};
blockElement.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
await blockElement.updateComplete;
// Get the paragraph inside the nested shadow DOM
const container = blockElement.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
if (paragraphBlock) {
console.log('Found nested paragraph block');
// Focus and set selection
paragraphBlock.focus();
const textNode = paragraphBlock.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
range.setStart(textNode, 5);
range.setEnd(textNode, 5);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
// Test containment from parent's perspective
const selRange = selection?.getRangeAt(0);
if (selRange) {
// This should fail because it crosses shadow DOM boundary
const regularContains = wysiwygInput.contains(selRange.startContainer);
console.log('Parent regular contains:', regularContains);
expect(regularContains).toBeFalse();
// This should work with our Shadow DOM-aware method
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(wysiwygInput, selRange.startContainer);
console.log('Parent shadow DOM contains:', shadowDOMContains);
expect(shadowDOMContains).toBeTrue();
}
}
}
}
// Clean up
document.body.removeChild(parentDiv);
});
export default tap.start();

View File

@ -0,0 +1,9 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
tap.test('should create wysiwyg editor', async () => {
const editor = new DeesInputWysiwyg();
expect(editor).toBeInstanceOf(DeesInputWysiwyg);
});
export default tap.start();

View File

@ -0,0 +1,69 @@
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
tap.test('Debug: should create empty wysiwyg block component', async () => {
try {
console.log('Creating DeesWysiwygBlock...');
const block: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
console.log('Block created:', block);
expect(block).toBeDefined();
expect(block).toBeInstanceOf(DeesWysiwygBlock);
console.log('Initial block property:', block.block);
console.log('Initial handlers property:', block.handlers);
} catch (error) {
console.error('Error creating block:', error);
throw error;
}
});
tap.test('Debug: should set properties step by step', async () => {
try {
console.log('Step 1: Creating component...');
const block: DeesWysiwygBlock = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
expect(block).toBeDefined();
console.log('Step 2: Setting handlers...');
block.handlers = {
onInput: () => console.log('onInput'),
onKeyDown: () => console.log('onKeyDown'),
onFocus: () => console.log('onFocus'),
onBlur: () => console.log('onBlur'),
onCompositionStart: () => console.log('onCompositionStart'),
onCompositionEnd: () => console.log('onCompositionEnd')
};
console.log('Handlers set:', block.handlers);
console.log('Step 3: Setting block data...');
block.block = {
id: 'test-block',
type: 'divider',
content: ' '
};
console.log('Block set:', block.block);
console.log('Step 4: Appending to body...');
document.body.appendChild(block);
console.log('Step 5: Waiting for update...');
await block.updateComplete;
console.log('Update complete');
console.log('Step 6: Checking shadowRoot...');
expect(block.shadowRoot).toBeDefined();
console.log('ShadowRoot exists');
} catch (error) {
console.error('Error in step-by-step test:', error);
throw error;
}
});
export default tap.start();

View File

@ -0,0 +1,205 @@
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
tap.test('BlockRegistry should have registered handlers', async () => {
// Test divider handler
const dividerHandler = BlockRegistry.getHandler('divider');
expect(dividerHandler).toBeDefined();
expect(dividerHandler?.type).toEqual('divider');
// Test paragraph handler
const paragraphHandler = BlockRegistry.getHandler('paragraph');
expect(paragraphHandler).toBeDefined();
expect(paragraphHandler?.type).toEqual('paragraph');
// Test heading handlers
const heading1Handler = BlockRegistry.getHandler('heading-1');
expect(heading1Handler).toBeDefined();
expect(heading1Handler?.type).toEqual('heading-1');
const heading2Handler = BlockRegistry.getHandler('heading-2');
expect(heading2Handler).toBeDefined();
expect(heading2Handler?.type).toEqual('heading-2');
const heading3Handler = BlockRegistry.getHandler('heading-3');
expect(heading3Handler).toBeDefined();
expect(heading3Handler?.type).toEqual('heading-3');
// Test that getAllTypes returns all registered types
const allTypes = BlockRegistry.getAllTypes();
expect(allTypes).toContain('divider');
expect(allTypes).toContain('paragraph');
expect(allTypes).toContain('heading-1');
expect(allTypes).toContain('heading-2');
expect(allTypes).toContain('heading-3');
});
tap.test('should render divider block using handler', async () => {
const dividerBlock: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
dividerBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
// Set a divider block
dividerBlock.block = {
id: 'test-divider',
type: 'divider',
content: ' '
};
await dividerBlock.updateComplete;
// Check that the divider is rendered
const dividerElement = dividerBlock.shadowRoot?.querySelector('.block.divider');
expect(dividerElement).toBeDefined();
expect(dividerElement?.getAttribute('tabindex')).toEqual('0');
// Check for the divider icon
const icon = dividerBlock.shadowRoot?.querySelector('.divider-icon');
expect(icon).toBeDefined();
});
tap.test('should render paragraph block using handler', async () => {
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
paragraphBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
// Set a paragraph block
paragraphBlock.block = {
id: 'test-paragraph',
type: 'paragraph',
content: 'Test paragraph content'
};
await paragraphBlock.updateComplete;
// Check that the paragraph is rendered
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
expect(paragraphElement).toBeDefined();
expect(paragraphElement?.getAttribute('contenteditable')).toEqual('true');
expect(paragraphElement?.textContent).toEqual('Test paragraph content');
});
tap.test('should render heading blocks using handler', async () => {
// Test heading-1
const heading1Block: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
heading1Block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
heading1Block.block = {
id: 'test-h1',
type: 'heading-1',
content: 'Heading 1 Test'
};
await heading1Block.updateComplete;
const h1Element = heading1Block.shadowRoot?.querySelector('.block.heading-1');
expect(h1Element).toBeDefined();
expect(h1Element?.textContent).toEqual('Heading 1 Test');
// Test heading-2
const heading2Block: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
heading2Block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
heading2Block.block = {
id: 'test-h2',
type: 'heading-2',
content: 'Heading 2 Test'
};
await heading2Block.updateComplete;
const h2Element = heading2Block.shadowRoot?.querySelector('.block.heading-2');
expect(h2Element).toBeDefined();
expect(h2Element?.textContent).toEqual('Heading 2 Test');
});
tap.test('paragraph block handler methods should work', async () => {
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
paragraphBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
paragraphBlock.block = {
id: 'test-methods',
type: 'paragraph',
content: 'Initial content'
};
await paragraphBlock.updateComplete;
// Test getContent
const content = paragraphBlock.getContent();
expect(content).toEqual('Initial content');
// Test setContent
paragraphBlock.setContent('Updated content');
await paragraphBlock.updateComplete;
expect(paragraphBlock.getContent()).toEqual('Updated content');
// Test that the DOM is updated
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
expect(paragraphElement?.textContent).toEqual('Updated content');
});
export default tap.start();

View File

@ -0,0 +1,341 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('Keyboard: Arrow navigation between blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import multiple blocks
editor.importBlocks([
{ id: 'block-1', type: 'paragraph', content: 'First paragraph' },
{ id: 'block-2', type: 'paragraph', content: 'Second paragraph' },
{ id: 'block-3', type: 'paragraph', content: 'Third paragraph' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus first block at end
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const firstBlockContainer = firstBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const firstParagraph = firstBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
// Focus and set cursor at end of first block
firstParagraph.focus();
const textNode = firstParagraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, textNode.textContent?.length || 0);
range.setEnd(textNode, textNode.textContent?.length || 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press ArrowRight to move to second block
const arrowRightEvent = new KeyboardEvent('keydown', {
key: 'ArrowRight',
code: 'ArrowRight',
bubbles: true,
cancelable: true,
composed: true
});
firstParagraph.dispatchEvent(arrowRightEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if second block is focused
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
// Check if the second paragraph has focus
const activeElement = secondBlockComponent.shadowRoot?.activeElement;
expect(activeElement).toEqual(secondParagraph);
console.log('Arrow navigation test complete');
});
tap.test('Keyboard: Backspace merges blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'merge-1', type: 'paragraph', content: 'First' },
{ id: 'merge-2', type: 'paragraph', content: 'Second' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus second block at beginning
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="merge-2"]');
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
// Focus and set cursor at beginning
secondParagraph.focus();
const textNode = secondParagraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Backspace to merge with previous block
const backspaceEvent = new KeyboardEvent('keydown', {
key: 'Backspace',
code: 'Backspace',
bubbles: true,
cancelable: true,
composed: true
});
secondParagraph.dispatchEvent(backspaceEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if blocks were merged
expect(editor.blocks.length).toEqual(1);
expect(editor.blocks[0].content).toContain('First');
expect(editor.blocks[0].content).toContain('Second');
console.log('Backspace merge test complete');
});
tap.test('Keyboard: Delete key on non-editable blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import blocks including a divider
editor.importBlocks([
{ id: 'para-1', type: 'paragraph', content: 'Before divider' },
{ id: 'div-1', type: 'divider', content: '' },
{ id: 'para-2', type: 'paragraph', content: 'After divider' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus the divider block
const dividerBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="div-1"]');
const dividerBlockComponent = dividerBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const dividerBlockContainer = dividerBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const dividerElement = dividerBlockContainer?.querySelector('.block.divider') as HTMLElement;
// Non-editable blocks need to be focused differently
dividerElement?.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Press Delete to remove the divider
const deleteEvent = new KeyboardEvent('keydown', {
key: 'Delete',
code: 'Delete',
bubbles: true,
cancelable: true,
composed: true
});
dividerElement.dispatchEvent(deleteEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if divider was removed
expect(editor.blocks.length).toEqual(2);
expect(editor.blocks.find(b => b.type === 'divider')).toBeUndefined();
console.log('Delete key on non-editable block test complete');
});
tap.test('Keyboard: Tab key in code block', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a code block
editor.importBlocks([
{ id: 'code-1', type: 'code', content: 'function test() {', metadata: { language: 'javascript' } }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus code block
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const codeBlockContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const codeElement = codeBlockContainer?.querySelector('.block.code') as HTMLElement;
// Focus and set cursor at end
codeElement.focus();
const textNode = codeElement.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, textNode.textContent?.length || 0);
range.setEnd(textNode, textNode.textContent?.length || 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Tab to insert spaces
const tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
bubbles: true,
cancelable: true,
composed: true
});
codeElement.dispatchEvent(tabEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if spaces were inserted
const updatedContent = codeElement.textContent || '';
expect(updatedContent).toContain(' '); // Tab should insert 2 spaces
console.log('Tab in code block test complete');
});
tap.test('Keyboard: ArrowUp/Down navigation', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import multiple blocks
editor.importBlocks([
{ id: 'nav-1', type: 'paragraph', content: 'First line' },
{ id: 'nav-2', type: 'paragraph', content: 'Second line' },
{ id: 'nav-3', type: 'paragraph', content: 'Third line' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus second block
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-2"]');
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
secondParagraph.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Press ArrowUp to move to first block
const arrowUpEvent = new KeyboardEvent('keydown', {
key: 'ArrowUp',
code: 'ArrowUp',
bubbles: true,
cancelable: true,
composed: true
});
secondParagraph.dispatchEvent(arrowUpEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if first block is focused
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-1"]');
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const firstParagraph = firstBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
expect(firstBlockComponent.shadowRoot?.activeElement).toEqual(firstParagraph);
// Now press ArrowDown twice to get to third block
const arrowDownEvent = new KeyboardEvent('keydown', {
key: 'ArrowDown',
code: 'ArrowDown',
bubbles: true,
cancelable: true,
composed: true
});
firstParagraph.dispatchEvent(arrowDownEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Second block should be focused, dispatch again
const secondActiveElement = secondBlockComponent.shadowRoot?.activeElement;
if (secondActiveElement) {
secondActiveElement.dispatchEvent(arrowDownEvent);
await new Promise(resolve => setTimeout(resolve, 200));
}
// Check if third block is focused
const thirdBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-3"]');
const thirdBlockComponent = thirdBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const thirdParagraph = thirdBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
expect(thirdBlockComponent.shadowRoot?.activeElement).toEqual(thirdParagraph);
console.log('ArrowUp/Down navigation test complete');
});
tap.test('Keyboard: Formatting shortcuts', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a paragraph
editor.importBlocks([
{ id: 'format-1', type: 'paragraph', content: 'Test formatting' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus and select text
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="format-1"]');
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const blockContainer = blockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraph = blockContainer?.querySelector('.block.paragraph') as HTMLElement;
paragraph.focus();
// Select "formatting"
const textNode = paragraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 5); // After "Test "
range.setEnd(textNode, 15); // After "formatting"
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Cmd/Ctrl+B for bold
const boldEvent = new KeyboardEvent('keydown', {
key: 'b',
code: 'KeyB',
metaKey: true, // Use metaKey for Mac, ctrlKey for Windows/Linux
bubbles: true,
cancelable: true,
composed: true
});
paragraph.dispatchEvent(boldEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if bold was applied
const content = paragraph.innerHTML;
expect(content).toContain('<strong>') || expect(content).toContain('<b>');
console.log('Formatting shortcuts test complete');
});
export default tap.start();

View File

@ -0,0 +1,150 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('Phase 3: Quote block should render and work correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a quote block
editor.importBlocks([
{ id: 'quote-1', type: 'quote', content: 'This is a famous quote' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if quote block was rendered
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
expect(quoteBlockComponent).toBeTruthy();
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
expect(quoteElement).toBeTruthy();
expect(quoteElement?.textContent).toEqual('This is a famous quote');
// Check if styles are applied (border-left for quote)
const computedStyle = window.getComputedStyle(quoteElement);
expect(computedStyle.borderLeftStyle).toEqual('solid');
expect(computedStyle.fontStyle).toEqual('italic');
});
tap.test('Phase 3: Code block should render and handle tab correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a code block
editor.importBlocks([
{ id: 'code-1', type: 'code', content: 'const x = 42;', metadata: { language: 'javascript' } }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if code block was rendered
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const codeContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
expect(codeElement).toBeTruthy();
expect(codeElement?.textContent).toEqual('const x = 42;');
// Check if language label is shown
const languageLabel = codeContainer?.querySelector('.code-language');
expect(languageLabel?.textContent).toEqual('javascript');
// Check if monospace font is applied
const computedStyle = window.getComputedStyle(codeElement);
expect(computedStyle.fontFamily).toContain('monospace');
});
tap.test('Phase 3: List block should render correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a list block
editor.importBlocks([
{ id: 'list-1', type: 'list', content: 'First item\nSecond item\nThird item' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if list block was rendered
const listBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="list-1"]');
const listBlockComponent = listBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const listContainer = listBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const listElement = listContainer?.querySelector('.block.list') as HTMLElement;
expect(listElement).toBeTruthy();
// Check if list items were created
const listItems = listElement?.querySelectorAll('li');
expect(listItems?.length).toEqual(3);
expect(listItems?.[0].textContent).toEqual('First item');
expect(listItems?.[1].textContent).toEqual('Second item');
expect(listItems?.[2].textContent).toEqual('Third item');
// Check if it's an unordered list by default
const ulElement = listElement?.querySelector('ul');
expect(ulElement).toBeTruthy();
});
tap.test('Phase 3: Quote block split should work', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a quote block
editor.importBlocks([
{ id: 'quote-split', type: 'quote', content: 'To be or not to be' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get the quote block
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-split"]');
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
// Focus and set cursor after "To be"
quoteElement.focus();
const textNode = quoteElement.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 5); // After "To be"
range.setEnd(textNode, 5);
selection?.removeAllRanges();
selection?.addRange(range);
await new Promise(resolve => setTimeout(resolve, 100));
// Press Enter to split
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true,
composed: true
});
quoteElement.dispatchEvent(enterEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if split happened correctly
expect(editor.blocks.length).toEqual(2);
expect(editor.blocks[0].content).toEqual('To be');
expect(editor.blocks[1].content).toEqual(' or not to be');
expect(editor.blocks[1].type).toEqual('paragraph'); // New block should be paragraph
}
});
export default tap.start();

View File

@ -0,0 +1,105 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
import { DividerBlockHandler } from '../ts_web/elements/wysiwyg/blocks/content/divider.block.js';
import { ParagraphBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/paragraph.block.js';
import { HeadingBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/heading.block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
tap.test('BlockRegistry should register and retrieve handlers', async () => {
// Test divider handler
const dividerHandler = BlockRegistry.getHandler('divider');
expect(dividerHandler).toBeDefined();
expect(dividerHandler).toBeInstanceOf(DividerBlockHandler);
expect(dividerHandler?.type).toEqual('divider');
// Test paragraph handler
const paragraphHandler = BlockRegistry.getHandler('paragraph');
expect(paragraphHandler).toBeDefined();
expect(paragraphHandler).toBeInstanceOf(ParagraphBlockHandler);
expect(paragraphHandler?.type).toEqual('paragraph');
// Test heading handlers
const heading1Handler = BlockRegistry.getHandler('heading-1');
expect(heading1Handler).toBeDefined();
expect(heading1Handler).toBeInstanceOf(HeadingBlockHandler);
expect(heading1Handler?.type).toEqual('heading-1');
const heading2Handler = BlockRegistry.getHandler('heading-2');
expect(heading2Handler).toBeDefined();
expect(heading2Handler).toBeInstanceOf(HeadingBlockHandler);
expect(heading2Handler?.type).toEqual('heading-2');
const heading3Handler = BlockRegistry.getHandler('heading-3');
expect(heading3Handler).toBeDefined();
expect(heading3Handler).toBeInstanceOf(HeadingBlockHandler);
expect(heading3Handler?.type).toEqual('heading-3');
});
tap.test('Block handlers should render content correctly', async () => {
const testBlock = {
id: 'test-1',
type: 'paragraph' as const,
content: 'Test paragraph content'
};
const handler = BlockRegistry.getHandler('paragraph');
expect(handler).toBeDefined();
if (handler) {
const rendered = handler.render(testBlock, false);
expect(rendered).toContain('contenteditable="true"');
expect(rendered).toContain('data-block-type="paragraph"');
expect(rendered).toContain('Test paragraph content');
}
});
tap.test('Divider handler should render correctly', async () => {
const dividerBlock = {
id: 'test-divider',
type: 'divider' as const,
content: ' '
};
const handler = BlockRegistry.getHandler('divider');
expect(handler).toBeDefined();
if (handler) {
const rendered = handler.render(dividerBlock, false);
expect(rendered).toContain('class="block divider"');
expect(rendered).toContain('tabindex="0"');
expect(rendered).toContain('divider-icon');
}
});
tap.test('Heading handlers should render with correct levels', async () => {
const headingBlock = {
id: 'test-h1',
type: 'heading-1' as const,
content: 'Test Heading'
};
const handler = BlockRegistry.getHandler('heading-1');
expect(handler).toBeDefined();
if (handler) {
const rendered = handler.render(headingBlock, false);
expect(rendered).toContain('class="block heading-1"');
expect(rendered).toContain('contenteditable="true"');
expect(rendered).toContain('Test Heading');
}
});
tap.test('getAllTypes should return all registered types', async () => {
const allTypes = BlockRegistry.getAllTypes();
expect(allTypes).toContain('divider');
expect(allTypes).toContain('paragraph');
expect(allTypes).toContain('heading-1');
expect(allTypes).toContain('heading-2');
expect(allTypes).toContain('heading-3');
expect(allTypes.length).toBeGreaterThanOrEqual(5);
});
export default tap.start();

View File

@ -0,0 +1,156 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('Selection highlighting should work consistently for all block types', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import various block types
editor.importBlocks([
{ id: 'para-1', type: 'paragraph', content: 'This is a paragraph' },
{ id: 'heading-1', type: 'heading-1', content: 'This is a heading' },
{ id: 'quote-1', type: 'quote', content: 'This is a quote' },
{ id: 'code-1', type: 'code', content: 'const x = 42;' },
{ id: 'list-1', type: 'list', content: 'Item 1\nItem 2' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Test paragraph highlighting
console.log('Testing paragraph highlighting...');
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const paraContainer = paraComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paraElement = paraContainer?.querySelector('.block.paragraph') as HTMLElement;
// Focus paragraph to select it
paraElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if paragraph has selected class
const paraHasSelected = paraElement.classList.contains('selected');
console.log('Paragraph has selected class:', paraHasSelected);
// Check computed styles
const paraStyle = window.getComputedStyle(paraElement);
console.log('Paragraph background:', paraStyle.background);
console.log('Paragraph box-shadow:', paraStyle.boxShadow);
// Test heading highlighting
console.log('\nTesting heading highlighting...');
const headingWrapper = editor.shadowRoot?.querySelector('[data-block-id="heading-1"]');
const headingComponent = headingWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const headingContainer = headingComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const headingElement = headingContainer?.querySelector('.block.heading-1') as HTMLElement;
// Focus heading to select it
headingElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if heading has selected class
const headingHasSelected = headingElement.classList.contains('selected');
console.log('Heading has selected class:', headingHasSelected);
// Check computed styles
const headingStyle = window.getComputedStyle(headingElement);
console.log('Heading background:', headingStyle.background);
console.log('Heading box-shadow:', headingStyle.boxShadow);
// Test quote highlighting
console.log('\nTesting quote highlighting...');
const quoteWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
const quoteComponent = quoteWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const quoteContainer = quoteComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
// Focus quote to select it
quoteElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if quote has selected class
const quoteHasSelected = quoteElement.classList.contains('selected');
console.log('Quote has selected class:', quoteHasSelected);
// Test code highlighting
console.log('\nTesting code highlighting...');
const codeWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
const codeComponent = codeWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const codeContainer = codeComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
// Focus code to select it
codeElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if code has selected class
const codeHasSelected = codeElement.classList.contains('selected');
console.log('Code has selected class:', codeHasSelected);
// Focus back on paragraph and check if others are deselected
console.log('\nFocusing back on paragraph...');
paraElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check that only paragraph is selected
expect(paraElement.classList.contains('selected')).toBeTrue();
expect(headingElement.classList.contains('selected')).toBeFalse();
expect(quoteElement.classList.contains('selected')).toBeFalse();
expect(codeElement.classList.contains('selected')).toBeFalse();
console.log('Selection highlighting test complete');
});
tap.test('Selected class should toggle correctly when clicking between blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'block-1', type: 'paragraph', content: 'First block' },
{ id: 'block-2', type: 'paragraph', content: 'Second block' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get both blocks
const block1Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
const block1Component = block1Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const block1Container = block1Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const block1Element = block1Container?.querySelector('.block.paragraph') as HTMLElement;
const block2Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
const block2Component = block2Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const block2Container = block2Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const block2Element = block2Container?.querySelector('.block.paragraph') as HTMLElement;
// Initially neither should be selected
expect(block1Element.classList.contains('selected')).toBeFalse();
expect(block2Element.classList.contains('selected')).toBeFalse();
// Click on first block
block1Element.click();
block1Element.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// First block should be selected
expect(block1Element.classList.contains('selected')).toBeTrue();
expect(block2Element.classList.contains('selected')).toBeFalse();
// Click on second block
block2Element.click();
block2Element.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Second block should be selected, first should not
expect(block1Element.classList.contains('selected')).toBeFalse();
expect(block2Element.classList.contains('selected')).toBeTrue();
console.log('Toggle test complete');
});
export default tap.start();

View File

@ -0,0 +1,62 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('Selection highlighting basic test', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'para-1', type: 'paragraph', content: 'First paragraph' },
{ id: 'head-1', type: 'heading-1', content: 'First heading' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 500));
// Get paragraph element
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const paraBlock = paraComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
// Get heading element
const headWrapper = editor.shadowRoot?.querySelector('[data-block-id="head-1"]');
const headComponent = headWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const headBlock = headComponent?.shadowRoot?.querySelector('.block.heading-1') as HTMLElement;
console.log('Found elements:', {
paraBlock: !!paraBlock,
headBlock: !!headBlock
});
// Focus paragraph
paraBlock.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check classes
console.log('Paragraph classes:', paraBlock.className);
console.log('Heading classes:', headBlock.className);
// Check isSelected property
console.log('Paragraph component isSelected:', paraComponent.isSelected);
console.log('Heading component isSelected:', headComponent.isSelected);
// Focus heading
headBlock.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check classes again
console.log('\nAfter focusing heading:');
console.log('Paragraph classes:', paraBlock.className);
console.log('Heading classes:', headBlock.className);
console.log('Paragraph component isSelected:', paraComponent.isSelected);
console.log('Heading component isSelected:', headComponent.isSelected);
// Check that heading is selected
expect(headBlock.classList.contains('selected')).toBeTrue();
expect(paraBlock.classList.contains('selected')).toBeFalse();
});
export default tap.start();

View File

@ -0,0 +1,98 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('should split paragraph content on Enter key', async () => {
// Create the wysiwyg editor
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a test paragraph
editor.importBlocks([{
id: 'test-para-1',
type: 'paragraph',
content: 'Hello World'
}]);
await editor.updateComplete;
// Wait for blocks to render
await new Promise(resolve => setTimeout(resolve, 100));
// Get the block wrapper and component
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-para-1"]');
expect(blockWrapper).toBeDefined();
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
expect(blockComponent).toBeDefined();
expect(blockComponent.block.type).toEqual('paragraph');
// Wait for block to render
await blockComponent.updateComplete;
// Test getSplitContent
console.log('Testing getSplitContent...');
const splitResult = blockComponent.getSplitContent();
console.log('Split result:', splitResult);
// Since we haven't set cursor position, it might return null or split at start
// This is just to test if the method is callable
expect(typeof blockComponent.getSplitContent).toEqual('function');
});
tap.test('should handle Enter key press in paragraph', async () => {
// Create the wysiwyg editor
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a test paragraph
editor.importBlocks([{
id: 'test-enter-1',
type: 'paragraph',
content: 'First part|Second part' // | marks where we'll simulate cursor
}]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check initial state
expect(editor.blocks.length).toEqual(1);
expect(editor.blocks[0].content).toEqual('First part|Second part');
// Get the block element
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-enter-1"]');
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const blockElement = blockComponent.shadowRoot?.querySelector('.block.paragraph') as HTMLDivElement;
expect(blockElement).toBeDefined();
// Set content without the | marker
blockElement.textContent = 'First partSecond part';
// Focus the block
blockElement.focus();
// Create and dispatch Enter key event
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true,
composed: true
});
// Dispatch the event
blockElement.dispatchEvent(enterEvent);
// Wait for processing
await new Promise(resolve => setTimeout(resolve, 200));
// Check if block was split (this might not work perfectly in test environment)
console.log('Blocks after Enter:', editor.blocks.length);
console.log('Block contents:', editor.blocks.map(b => b.content));
});
export default tap.start();

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@design.estate/dees-catalog', name: '@design.estate/dees-catalog',
version: '1.8.1', version: '1.9.8',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
} }

161
ts_web/elements/00zindex.ts Normal file
View File

@ -0,0 +1,161 @@
/**
* Central z-index management for consistent stacking order
* Higher numbers appear on top of lower numbers
*/
export const zIndexLayers = {
// Base layer: Regular content
base: {
content: 'auto',
inputElements: 1,
},
// Fixed UI elements
fixed: {
appBar: 10,
sideMenu: 10,
mobileNav: 250,
},
// Overlay backdrops (semi-transparent backgrounds)
backdrop: {
dropdown: 1999, // Below modals but above fixed elements
modal: 2999, // Below dropdowns on modals
contextMenu: 3999, // Below critical overlays
},
// Interactive overlays
overlay: {
dropdown: 2000, // Dropdowns and select menus
modal: 3000, // Modal dialogs
contextMenu: 4000, // Context menus and tooltips
toast: 5000, // Toast notifications (highest priority)
},
// Special cases for nested elements
modalDropdown: 3500, // Dropdowns inside modals
wysiwygMenus: 4500, // Editor formatting menus
} as const;
// Helper function to get z-index value
export function getZIndex(category: keyof typeof zIndexLayers, subcategory?: string): number | string {
const categoryObj = zIndexLayers[category];
if (typeof categoryObj === 'object' && subcategory) {
return categoryObj[subcategory as keyof typeof categoryObj] || 'auto';
}
return typeof categoryObj === 'number' ? categoryObj : 'auto';
}
// Z-index assignments for components
export const componentZIndex = {
'dees-modal': zIndexLayers.overlay.modal,
'dees-windowlayer': zIndexLayers.overlay.dropdown,
'dees-contextmenu': zIndexLayers.overlay.contextMenu,
'dees-toast': zIndexLayers.overlay.toast,
'dees-appui-mainmenu': zIndexLayers.fixed.appBar,
'dees-mobilenavigation': zIndexLayers.fixed.mobileNav,
'dees-slash-menu': zIndexLayers.wysiwygMenus,
'dees-formatting-menu': zIndexLayers.wysiwygMenus,
} as const;
/**
* Z-Index Registry for managing stacked elements
* Simple incremental z-index assignment based on creation order
*/
export class ZIndexRegistry {
private static instance: ZIndexRegistry;
private activeElements = new Set<HTMLElement>();
private elementZIndexMap = new WeakMap<HTMLElement, number>();
private currentZIndex = 1000; // Starting z-index
private constructor() {}
public static getInstance(): ZIndexRegistry {
if (!ZIndexRegistry.instance) {
ZIndexRegistry.instance = new ZIndexRegistry();
}
return ZIndexRegistry.instance;
}
/**
* Get the next available z-index
* @returns The next available z-index
*/
public getNextZIndex(): number {
this.currentZIndex += 10;
return this.currentZIndex;
}
/**
* Register an element with the z-index registry
* @param element - The HTML element to register
* @param zIndex - The z-index assigned to this element
*/
public register(element: HTMLElement, zIndex: number): void {
this.activeElements.add(element);
this.elementZIndexMap.set(element, zIndex);
}
/**
* Unregister an element from the z-index registry
* @param element - The HTML element to unregister
*/
public unregister(element: HTMLElement): void {
this.activeElements.delete(element);
this.elementZIndexMap.delete(element);
// If no more active elements, reset counter to base
if (this.activeElements.size === 0) {
this.currentZIndex = 1000;
}
}
/**
* Get the z-index for a specific element
* @param element - The HTML element
* @returns The z-index or undefined if not registered
*/
public getElementZIndex(element: HTMLElement): number | undefined {
return this.elementZIndexMap.get(element);
}
/**
* Get count of active elements
* @returns Number of active elements
*/
public getActiveCount(): number {
return this.activeElements.size;
}
/**
* Get the current highest z-index
* @returns The current z-index value
*/
public getCurrentZIndex(): number {
return this.currentZIndex;
}
/**
* Clear all registrations (useful for testing)
*/
public clear(): void {
this.activeElements.clear();
this.elementZIndexMap = new WeakMap();
this.currentZIndex = 1000;
}
/**
* Get all active elements in z-index order
* @returns Array of elements sorted by z-index
*/
public getActiveElementsInOrder(): HTMLElement[] {
return Array.from(this.activeElements).sort((a, b) => {
const aZ = this.elementZIndexMap.get(a) || 0;
const bZ = this.elementZIndexMap.get(b) || 0;
return aZ - bZ;
});
}
}
// Export singleton instance for convenience
export const zIndexRegistry = ZIndexRegistry.getInstance();

View File

@ -1,5 +1,6 @@
import * as plugins from './00plugins.js'; import * as plugins from './00plugins.js';
import * as interfaces from './interfaces/index.js'; import * as interfaces from './interfaces/index.js';
import { zIndexLayers } from './00zindex.js';
import { import {
DeesElement, DeesElement,
@ -46,7 +47,7 @@ export class DeesAppuiMainmenu extends DeesElement {
.mainContainer { .mainContainer {
--menuSize: 60px; --menuSize: 60px;
color: ${cssManager.bdTheme('#666', '#ccc')}; color: ${cssManager.bdTheme('#666', '#ccc')};
z-index: 10; z-index: ${zIndexLayers.fixed.appBar};
display: block; display: block;
position: relative; position: relative;
width: var(--menuSize); width: var(--menuSize);

View File

@ -1,4 +1,5 @@
import * as plugins from './00plugins.js'; import * as plugins from './00plugins.js';
import { zIndexLayers } from './00zindex.js';
import { import {
DeesElement, DeesElement,
@ -73,7 +74,7 @@ export class DeesAppuiProfileDropdown extends DeesElement {
'0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.15)',
'0 4px 12px rgba(0, 0, 0, 0.3)' '0 4px 12px rgba(0, 0, 0, 0.3)'
)}; )};
z-index: 1000; z-index: ${zIndexLayers.overlay.dropdown};
opacity: 0; opacity: 0;
transform: scale(0.95) translateY(-10px); transform: scale(0.95) translateY(-10px);
transition: opacity 0.2s, transform 0.2s; transition: opacity 0.2s, transform 0.2s;
@ -258,7 +259,7 @@ export class DeesAppuiProfileDropdown extends DeesElement {
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
z-index: 999; z-index: ${zIndexLayers.backdrop.dropdown};
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
display: none; display: none;

View File

@ -1,6 +1,42 @@
import { html } from '@design.estate/dees-element'; import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => html` export const demoFunc = () => html`
<style>
${css`
h3 {
margin-top: 32px;
margin-bottom: 16px;
color: #333;
}
@media (prefers-color-scheme: dark) {
h3 {
color: #ccc;
}
}
.form-demo {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
@media (prefers-color-scheme: dark) {
.form-demo {
background: #1a1a1a;
}
}
.button-group {
display: flex;
gap: 16px;
margin: 20px 0;
}
`}
</style>
<h3>Button Types</h3>
<dees-button>This is a slotted Text</dees-button> <dees-button>This is a slotted Text</dees-button>
<p> <p>
<dees-button text="Highlighted: This text shows" type="highlighted">Highlighted</dees-button> <dees-button text="Highlighted: This text shows" type="highlighted">Highlighted</dees-button>
@ -8,8 +44,34 @@ export const demoFunc = () => html`
<p><dees-button type="discreet">This is discreete button</dees-button></p> <p><dees-button type="discreet">This is discreete button</dees-button></p>
<p><dees-button disabled>This is a disabled button</dees-button></p> <p><dees-button disabled>This is a disabled button</dees-button></p>
<p><dees-button type="big">This is a slotted Text</dees-button></p> <p><dees-button type="big">This is a slotted Text</dees-button></p>
<h3>Button States</h3>
<p><dees-button status="normal">Normal Status</dees-button></p> <p><dees-button status="normal">Normal Status</dees-button></p>
<p><dees-button disabled status="pending">Pending Status</dees-button></p> <p><dees-button disabled status="pending">Pending Status</dees-button></p>
<p><dees-button disabled status="success">Success Status</dees-button></p> <p><dees-button disabled status="success">Success Status</dees-button></p>
<p><dees-button disabled status="error">Error Status</dees-button></p> <p><dees-button disabled status="error">Error Status</dees-button></p>
<h3>Buttons in Forms (Auto-spacing)</h3>
<div class="form-demo">
<dees-form>
<dees-input-text label="Name" key="name"></dees-input-text>
<dees-input-text label="Email" key="email"></dees-input-text>
<dees-button>Save Draft</dees-button>
<dees-button type="highlighted">Save and Continue</dees-button>
<dees-form-submit>Submit Form</dees-form-submit>
</dees-form>
</div>
<h3>Buttons Outside Forms (No auto-spacing)</h3>
<div class="button-group">
<dees-button>Button 1</dees-button>
<dees-button>Button 2</dees-button>
<dees-button>Button 3</dees-button>
</div>
<h3>Manual Form Spacing</h3>
<div>
<dees-button inside-form="true">Manually spaced button 1</dees-button>
<dees-button inside-form="true">Manually spaced button 2</dees-button>
</div>
`; `;

View File

@ -55,10 +55,24 @@ export class DeesButton extends DeesElement {
}) })
public status: 'normal' | 'pending' | 'success' | 'error' = 'normal'; public status: 'normal' | 'pending' | 'success' | 'error' = 'normal';
@property({
type: Boolean,
reflect: true
})
public insideForm: boolean = false;
constructor() { constructor() {
super(); super();
} }
public async connectedCallback() {
await super.connectedCallback();
// Auto-detect if inside a form
if (!this.insideForm && this.closest('dees-form')) {
this.insideForm = true;
}
}
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
css` css`
@ -71,6 +85,27 @@ export class DeesButton extends DeesElement {
display: none; display: none;
} }
/* Form spacing styles */
/* Default vertical form layout */
:host([inside-form]) {
margin-bottom: 16px; /* Using standard 16px like inputs */
}
:host([inside-form]:last-child) {
margin-bottom: 0;
}
/* Horizontal form layout - auto-detected via parent */
dees-form[horizontal-layout] :host([inside-form]) {
display: inline-block;
margin-right: 16px;
margin-bottom: 0;
}
dees-form[horizontal-layout] :host([inside-form]:last-child) {
margin-right: 0;
}
.button { .button {
transition: all 0.1s , color 0s; transition: all 0.1s , color 0s;
position: relative; position: relative;

View File

@ -14,6 +14,7 @@ import {
import * as domtools from '@design.estate/dees-domtools'; import * as domtools from '@design.estate/dees-domtools';
import { DeesWindowLayer } from './dees-windowlayer.js'; import { DeesWindowLayer } from './dees-windowlayer.js';
import { zIndexLayers } from './00zindex.js';
import './dees-icon.js'; import './dees-icon.js';
declare global { declare global {
@ -74,7 +75,7 @@ export class DeesContextmenu extends DeesElement {
eventArg.stopPropagation(); eventArg.stopPropagation();
const contextMenu = new DeesContextmenu(); const contextMenu = new DeesContextmenu();
contextMenu.style.position = 'fixed'; contextMenu.style.position = 'fixed';
contextMenu.style.zIndex = '10000'; contextMenu.style.zIndex = String(zIndexLayers.overlay.contextMenu);
contextMenu.style.opacity = '0'; contextMenu.style.opacity = '0';
contextMenu.style.transform = 'scale(0.95) translateY(-10px)'; contextMenu.style.transform = 'scale(0.95) translateY(-10px)';
contextMenu.menuItems = menuItemsArg; contextMenu.menuItems = menuItemsArg;

View File

@ -0,0 +1,3 @@
import { html } from '@design.estate/dees-element';
export const demoFunc = () => html`<dees-form-submit>Submit Form</dees-form-submit>`;

View File

@ -1,3 +1,4 @@
import { demoFunc } from './dees-form-submit.demo.js';
import { import {
customElement, customElement,
html, html,
@ -6,7 +7,7 @@ import {
cssManager, cssManager,
property, property,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import { DeesForm } from './dees-form.js'; import type { DeesForm } from './dees-form.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@ -16,7 +17,7 @@ declare global {
@customElement('dees-form-submit') @customElement('dees-form-submit')
export class DeesFormSubmit extends DeesElement { export class DeesFormSubmit extends DeesElement {
public static demo = () => html`<dees-form-submit>Submit Form</dees-form-submit>`; public static demo = demoFunc;
@property({ @property({
type: Boolean, type: Boolean,
@ -57,13 +58,15 @@ export class DeesFormSubmit extends DeesElement {
return; return;
} }
const parentElement: DeesForm = this.parentElement as DeesForm; const parentElement: DeesForm = this.parentElement as DeesForm;
parentElement.gatherAndDispatch(); if (parentElement && parentElement.gatherAndDispatch) {
parentElement.gatherAndDispatch();
}
} }
public async focus() { public async focus() {
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
if (!this.disabled) { if (!this.disabled) {
domtools.convenience.smartdelay.delayFor(0); await domtools.convenience.smartdelay.delayFor(0);
this.submit(); this.submit();
} }
} }

View File

@ -15,234 +15,173 @@ export const demoFunc = () => html`
margin: 0 auto; margin: 0 auto;
} }
.demo-section { dees-panel {
background: #f8f9fa; margin-bottom: 24px;
border-radius: 8px;
padding: 24px;
} }
@media (prefers-color-scheme: dark) { dees-panel:last-child {
.demo-section { margin-bottom: 0;
background: #1a1a1a;
}
}
.demo-section h3 {
margin-top: 0;
margin-bottom: 16px;
color: #0069f2;
font-size: 18px;
}
.demo-section p {
margin-top: 0;
margin-bottom: 16px;
color: #666;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.demo-section p {
color: #999;
}
}
.form-container {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
@media (prefers-color-scheme: dark) {
.form-container {
background: #222;
border-color: #333;
}
}
.horizontal-form {
display: flex;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
} }
`} `}
</style> </style>
<div class="demo-container"> <div class="demo-container">
<div class="demo-section"> <dees-panel .heading="Complete Form Example" .description="A comprehensive form with various input types, validation, and form submission handling">
<h3>Complete Form Example</h3> <dees-form
<p>A comprehensive form with various input types, validation, and form submission handling</p> @formData=${async (eventArg) => {
const form: DeesForm = eventArg.currentTarget;
<div class="form-container"> form.setStatus('pending', 'Processing...');
<dees-form await domtools.plugins.smartdelay.delayFor(2000);
@formData=${async (eventArg) => { form.setStatus('success', 'Form submitted successfully!');
const form: DeesForm = eventArg.currentTarget; await domtools.plugins.smartdelay.delayFor(2000);
form.setStatus('pending', 'Processing...'); form.reset();
await domtools.plugins.smartdelay.delayFor(2000); }}
form.setStatus('success', 'Form submitted successfully!'); >
await domtools.plugins.smartdelay.delayFor(2000); <dees-input-text
form.reset(); .required=${true}
}} key="firstName"
> label="First Name"
<dees-input-text .description=${'Your given name'}
.required=${true} ></dees-input-text>
key="firstName"
label="First Name" <dees-input-text
.description=${'Your given name'} .required=${true}
></dees-input-text> key="lastName"
label="Last Name"
<dees-input-text ></dees-input-text>
.required=${true}
key="lastName" <dees-input-text
label="Last Name" .required=${true}
></dees-input-text> key="email"
label="Email Address"
<dees-input-text .description=${'We will use this to contact you'}
.required=${true} ></dees-input-text>
key="email"
label="Email Address" <dees-input-dropdown
.description=${'We will use this to contact you'} .required=${true}
></dees-input-text> key="country"
.label=${'Country'}
<dees-input-dropdown .options=${[
.required=${true} { option: 'United States', key: 'us' },
key="country" { option: 'Canada', key: 'ca' },
.label=${'Country'} { option: 'Germany', key: 'de' },
.options=${[ { option: 'France', key: 'fr' },
{ option: 'United States', key: 'us' }, { option: 'United Kingdom', key: 'uk' },
{ option: 'Canada', key: 'ca' }, ]}
{ option: 'Germany', key: 'de' }, ></dees-input-dropdown>
{ option: 'France', key: 'fr' },
{ option: 'United Kingdom', key: 'uk' }, <dees-input-text
]} .required=${true}
></dees-input-dropdown> key="password"
label="Password"
<dees-input-text isPasswordBool
.required=${true} .description=${'Minimum 8 characters'}
key="password" ></dees-input-text>
label="Password"
isPasswordBool <dees-input-checkbox
.description=${'Minimum 8 characters'} .required=${true}
></dees-input-text> key="terms"
label="I agree to the Terms and Conditions"
<dees-input-checkbox ></dees-input-checkbox>
.required=${true}
key="terms" <dees-input-checkbox
label="I agree to the Terms and Conditions" key="newsletter"
></dees-input-checkbox> label="Send me promotional emails"
.value=${true}
<dees-input-checkbox ></dees-input-checkbox>
key="newsletter"
label="Send me promotional emails" <dees-form-submit>Create Account</dees-form-submit>
.value=${true} </dees-form>
></dees-input-checkbox> </dees-panel>
<dees-form-submit>Create Account</dees-form-submit>
</dees-form>
</div>
</div>
<div class="demo-section"> <dees-panel .heading="Horizontal Form Layout" .description="Compact form with inputs arranged horizontally - perfect for filters and quick forms">
<h3>Horizontal Form Layout</h3> <dees-form horizontal-layout>
<p>Compact form with inputs arranged horizontally - perfect for filters and quick forms</p> <dees-input-text
key="search"
<div class="form-container"> label="Search"
<dees-form horizontal-layout> ></dees-input-text>
<dees-input-text
key="search" <dees-input-dropdown
label="Search" key="category"
></dees-input-text> .label=${'Category'}
.enableSearch=${false}
<dees-input-dropdown .options=${[
key="category" { option: 'All', key: 'all' },
.label=${'Category'} { option: 'Products', key: 'products' },
.enableSearch=${false} { option: 'Services', key: 'services' },
.options=${[ { option: 'Support', key: 'support' },
{ option: 'All', key: 'all' }, ]}
{ option: 'Products', key: 'products' }, ></dees-input-dropdown>
{ option: 'Services', key: 'services' },
{ option: 'Support', key: 'support' }, <dees-input-dropdown
]} key="sort"
></dees-input-dropdown> .label=${'Sort By'}
.enableSearch=${false}
<dees-input-dropdown .options=${[
key="sort" { option: 'Newest', key: 'newest' },
.label=${'Sort By'} { option: 'Popular', key: 'popular' },
.enableSearch=${false} { option: 'Price: Low to High', key: 'price_asc' },
.options=${[ { option: 'Price: High to Low', key: 'price_desc' },
{ option: 'Newest', key: 'newest' }, ]}
{ option: 'Popular', key: 'popular' }, ></dees-input-dropdown>
{ option: 'Price: Low to High', key: 'price_asc' },
{ option: 'Price: High to Low', key: 'price_desc' }, <dees-input-checkbox
]} key="inStock"
></dees-input-dropdown> label="In Stock Only"
.value=${true}
<dees-input-checkbox ></dees-input-checkbox>
key="inStock" </dees-form>
label="In Stock Only" </dees-panel>
.value=${true}
></dees-input-checkbox>
</dees-form>
</div>
</div>
<div class="demo-section"> <dees-panel .heading="Advanced Form Features" .description="Form with specialized input types and complex validation">
<h3>Advanced Form Features</h3> <dees-form
<p>Form with specialized input types and complex validation</p> @formData=${async (eventArg) => {
const form: DeesForm = eventArg.currentTarget;
<div class="form-container"> const data = eventArg.detail.data;
<dees-form console.log('Form data:', data);
@formData=${async (eventArg) => { form.setStatus('success', 'Data logged to console!');
const form: DeesForm = eventArg.currentTarget; }}
const data = eventArg.detail.data; >
console.log('Form data:', data); <dees-input-iban
form.setStatus('success', 'Data logged to console!'); key="iban"
}} label="IBAN"
> .required=${true}
<dees-input-iban ></dees-input-iban>
key="iban"
label="IBAN" <dees-input-phone
.required=${true} key="phone"
></dees-input-iban> label="Phone Number"
.required=${true}
<dees-input-phone ></dees-input-phone>
key="phone"
label="Phone Number" <dees-input-multitoggle
.required=${true} key="preferences"
></dees-input-phone> .label=${'Notification Preferences'}
.options=${['Email', 'SMS', 'Push', 'In-App']}
<dees-input-multitoggle .selectedOption=${'Email'}
key="preferences" ></dees-input-multitoggle>
.label=${'Notification Preferences'}
.options=${['Email', 'SMS', 'Push', 'In-App']} <dees-input-multiselect
.selectedOption=${'Email'} key="interests"
></dees-input-multitoggle> .label=${'Areas of Interest'}
.options=${[
<dees-input-multiselect { option: 'Technology', key: 'tech' },
key="interests" { option: 'Design', key: 'design' },
.label=${'Areas of Interest'} { option: 'Business', key: 'business' },
.options=${[ { option: 'Marketing', key: 'marketing' },
{ option: 'Technology', key: 'tech' }, { option: 'Sales', key: 'sales' },
{ option: 'Design', key: 'design' }, ]}
{ option: 'Business', key: 'business' }, ></dees-input-multiselect>
{ option: 'Marketing', key: 'marketing' },
{ option: 'Sales', key: 'sales' }, <dees-input-fileupload
]} key="documents"
></dees-input-multiselect> .label=${'Upload Documents'}
.description=${'PDF, DOC, or DOCX files up to 10MB'}
<dees-input-fileupload ></dees-input-fileupload>
key="documents"
.label=${'Upload Documents'} <dees-form-submit>Submit Application</dees-form-submit>
.description=${'PDF, DOC, or DOCX files up to 10MB'} </dees-form>
></dees-input-fileupload> </dees-panel>
<dees-form-submit>Submit Application</dees-form-submit>
</dees-form>
</div>
</div>
</div> </div>
</dees-demowrapper> </dees-demowrapper>
`; `;

View File

@ -11,7 +11,7 @@ import * as domtools from '@design.estate/dees-domtools';
import { DeesInputCheckbox } from './dees-input-checkbox.js'; import { DeesInputCheckbox } from './dees-input-checkbox.js';
import { DeesInputText } from './dees-input-text.js'; import { DeesInputText } from './dees-input-text.js';
import { DeesInputQuantitySelector } from './dees-input-quantityselector.js'; import { DeesInputQuantitySelector } from './dees-input-quantityselector.js';
import { DeesInputRadio } from './dees-input-radio.js'; import { DeesInputRadiogroup } from './dees-input-radiogroup.js';
import { DeesInputDropdown } from './dees-input-dropdown.js'; import { DeesInputDropdown } from './dees-input-dropdown.js';
import { DeesInputFileupload } from './dees-input-fileupload.js'; import { DeesInputFileupload } from './dees-input-fileupload.js';
import { DeesInputIban } from './dees-input-iban.js'; import { DeesInputIban } from './dees-input-iban.js';
@ -31,7 +31,7 @@ const FORM_INPUT_TYPES = [
DeesInputMultitoggle, DeesInputMultitoggle,
DeesInputPhone, DeesInputPhone,
DeesInputQuantitySelector, DeesInputQuantitySelector,
DeesInputRadio, DeesInputRadiogroup,
DeesInputText, DeesInputText,
DeesInputTypelist, DeesInputTypelist,
DeesTable, DeesTable,
@ -45,7 +45,7 @@ export type TFormInputElement =
| DeesInputMultitoggle | DeesInputMultitoggle
| DeesInputPhone | DeesInputPhone
| DeesInputQuantitySelector | DeesInputQuantitySelector
| DeesInputRadio | DeesInputRadiogroup
| DeesInputText | DeesInputText
| DeesInputTypelist | DeesInputTypelist
| DeesTable<any>; | DeesTable<any>;
@ -132,12 +132,16 @@ export class DeesForm extends DeesElement {
public async collectFormData() { public async collectFormData() {
const children = this.getFormElements(); const children = this.getFormElements();
const valueObject: { [key: string]: string | number | boolean | any[] | File[] | { option: string; key: string; payload?: any } } = {}; const valueObject: { [key: string]: string | number | boolean | any[] | File[] | { option: string; key: string; payload?: any } } = {};
for (const child of children) { for (const child of children) {
if (!child.key) { if (!child.key) {
console.log(`form element with label "${child.label}" has no key. skipping.`); console.log(`form element with label "${child.label}" has no key. skipping.`);
continue;
} }
valueObject[child.key] = child.value; valueObject[child.key] = child.value;
} }
return valueObject; return valueObject;
} }

View File

@ -50,14 +50,24 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
} }
.maincontainer { .maincontainer {
padding: 5px 0px; display: flex;
align-items: center;
gap: 12px;
padding: 8px 0px;
color: ${cssManager.bdTheme('#333', '#ccc')}; color: ${cssManager.bdTheme('#333', '#ccc')};
cursor: pointer;
user-select: none;
transition: all 0.2s;
} }
.maincontainer:hover { .maincontainer:hover {
color: ${cssManager.bdTheme('#000', '#fff')}; color: ${cssManager.bdTheme('#000', '#fff')};
} }
.maincontainer:hover .checkbox {
border-color: ${cssManager.bdTheme('#999', '#888')};
}
input:focus { input:focus {
outline: none; outline: none;
border-bottom: 1px solid #e4002b; border-bottom: 1px solid #e4002b;
@ -72,6 +82,7 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
width: 24px; width: 24px;
display: inline-block; display: inline-block;
background: ${cssManager.bdTheme('#fafafa', '#222')}; background: ${cssManager.bdTheme('#fafafa', '#222')};
flex-shrink: 0;
} }
.checkbox.selected { .checkbox.selected {
@ -118,13 +129,43 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
img { img {
padding: 4px; padding: 4px;
} }
.checkbox-label {
font-size: 14px;
transition: color 0.2s ease;
}
.maincontainer:hover .checkbox-label {
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
}
.maincontainer.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.maincontainer.disabled:hover {
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.maincontainer.disabled:hover .checkbox {
border-color: ${cssManager.bdTheme('#ccc', '#333')};
}
.description-text {
font-size: 12px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-top: 4px;
line-height: 1.4;
padding-left: 36px;
}
`, `,
]; ];
public render(): TemplateResult { public render(): TemplateResult {
return html` return html`
<div class="input-wrapper"> <div class="input-wrapper">
<div class="maincontainer" @click="${this.toggleSelected}"> <div class="maincontainer ${this.disabled ? 'disabled' : ''}" @click="${this.toggleSelected}">
<div class="checkbox ${this.value ? 'selected' : ''} ${this.disabled ? 'disabled' : ''}" tabindex="0"> <div class="checkbox ${this.value ? 'selected' : ''} ${this.disabled ? 'disabled' : ''}" tabindex="0">
${this.value ${this.value
? html` ? html`
@ -135,8 +176,11 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
` `
: html``} : html``}
</div> </div>
${this.label ? html`<div class="checkbox-label">${this.label}</div>` : ''}
</div> </div>
<dees-label .label=${this.label}></dees-label> ${this.description ? html`
<div class="description-text">${this.description}</div>
` : ''}
</div> </div>
`; `;
} }

View File

@ -1,5 +1,8 @@
import { html, css } from '@design.estate/dees-element'; import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools'; import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
import './dees-form.js';
import './dees-form-submit.js';
export const demoFunc = () => html` export const demoFunc = () => html`
<dees-demowrapper> <dees-demowrapper>
@ -14,37 +17,12 @@ export const demoFunc = () => html`
margin: 0 auto; margin: 0 auto;
} }
.demo-section { dees-panel {
background: #f8f9fa; margin-bottom: 24px;
border-radius: 8px;
padding: 24px;
position: relative;
} }
@media (prefers-color-scheme: dark) { dees-panel:last-child {
.demo-section { margin-bottom: 0;
background: #1a1a1a;
}
}
.demo-section h3 {
margin-top: 0;
margin-bottom: 16px;
color: #0069f2;
font-size: 18px;
}
.demo-section p {
margin-top: 0;
margin-bottom: 16px;
color: #666;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.demo-section p {
color: #999;
}
} }
.horizontal-group { .horizontal-group {
@ -66,10 +44,7 @@ export const demoFunc = () => html`
</style> </style>
<div class="demo-container"> <div class="demo-container">
<div class="demo-section"> <dees-panel .title=${'1. Basic Dropdowns'} .subtitle=${'Standard dropdown with search functionality and various options'}>
<h3>Basic Dropdowns</h3>
<p>Standard dropdown with search functionality and various options</p>
<dees-input-dropdown <dees-input-dropdown
.label=${'Select Country'} .label=${'Select Country'}
.options=${[ .options=${[
@ -94,12 +69,9 @@ export const demoFunc = () => html`
{ option: 'Guest', key: 'guest' } { option: 'Guest', key: 'guest' }
]} ]}
></dees-input-dropdown> ></dees-input-dropdown>
</div> </dees-panel>
<div class="demo-section"> <dees-panel .title=${'2. Without Search'} .subtitle=${'Dropdown with search functionality disabled for simpler selection'}>
<h3>Without Search</h3>
<p>Dropdown with search functionality disabled for simpler selection</p>
<dees-input-dropdown <dees-input-dropdown
.label=${'Priority Level'} .label=${'Priority Level'}
.enableSearch=${false} .enableSearch=${false}
@ -110,12 +82,9 @@ export const demoFunc = () => html`
]} ]}
.selectedOption=${{ option: 'Medium', key: 'medium' }} .selectedOption=${{ option: 'Medium', key: 'medium' }}
></dees-input-dropdown> ></dees-input-dropdown>
</div> </dees-panel>
<div class="demo-section"> <dees-panel .title=${'3. Horizontal Layout'} .subtitle=${'Multiple dropdowns in a horizontal layout for compact forms'}>
<h3>Horizontal Layout</h3>
<p>Multiple dropdowns in a horizontal layout for compact forms</p>
<div class="horizontal-group"> <div class="horizontal-group">
<dees-input-dropdown <dees-input-dropdown
.label=${'Department'} .label=${'Department'}
@ -150,12 +119,9 @@ export const demoFunc = () => html`
]} ]}
></dees-input-dropdown> ></dees-input-dropdown>
</div> </div>
</div> </dees-panel>
<div class="demo-section"> <dees-panel .title=${'4. States'} .subtitle=${'Different states and configurations'}>
<h3>States</h3>
<p>Different states and configurations</p>
<dees-input-dropdown <dees-input-dropdown
.label=${'Required Field'} .label=${'Required Field'}
.required=${true} .required=${true}
@ -174,16 +140,13 @@ export const demoFunc = () => html`
]} ]}
.selectedOption=${{ option: 'Cannot Select', key: 'disabled' }} .selectedOption=${{ option: 'Cannot Select', key: 'disabled' }}
></dees-input-dropdown> ></dees-input-dropdown>
</div> </dees-panel>
<div class="spacer"> <div class="spacer">
(Spacer to test dropdown positioning) (Spacer to test dropdown positioning)
</div> </div>
<div class="demo-section"> <dees-panel .title=${'5. Bottom Positioning'} .subtitle=${'Dropdown that opens upward when near bottom of viewport'}>
<h3>Bottom Positioning</h3>
<p>Dropdown that opens upward when near bottom of viewport</p>
<dees-input-dropdown <dees-input-dropdown
.label=${'Opens Upward'} .label=${'Opens Upward'}
.options=${[ .options=${[
@ -194,7 +157,65 @@ export const demoFunc = () => html`
{ option: 'Fifth Option', key: 'fifth' } { option: 'Fifth Option', key: 'fifth' }
]} ]}
></dees-input-dropdown> ></dees-input-dropdown>
</div> </dees-panel>
<dees-panel .title=${'6. Event Handling & Payload'} .subtitle=${'Dropdown with payload data and change event handling'}>
<dees-input-dropdown
.label=${'Select Product'}
.options=${[
{ option: 'Basic Plan', key: 'basic', payload: { price: 9.99, features: ['Feature A'] } },
{ option: 'Pro Plan', key: 'pro', payload: { price: 19.99, features: ['Feature A', 'Feature B'] } },
{ option: 'Enterprise Plan', key: 'enterprise', payload: { price: 49.99, features: ['Feature A', 'Feature B', 'Feature C'] } }
]}
@change=${(e: CustomEvent) => {
const output = document.querySelector('#selection-output');
if (output && e.detail.value) {
output.innerHTML = `
<strong>Selected:</strong> ${e.detail.value.option}<br>
<strong>Key:</strong> ${e.detail.value.key}<br>
<strong>Price:</strong> $${e.detail.value.payload?.price || 'N/A'}<br>
<strong>Features:</strong> ${e.detail.value.payload?.features?.join(', ') || 'N/A'}
`;
}
}}
></dees-input-dropdown>
<div id="selection-output" style="margin-top: 16px; padding: 12px; background: rgba(0, 105, 242, 0.1); border-radius: 4px; font-size: 14px;">
<em>Select a product to see details...</em>
</div>
</dees-panel>
<dees-panel .title=${'7. Form Integration'} .subtitle=${'Dropdown working within a form with validation'}>
<dees-form>
<dees-input-dropdown
.label=${'Project Type'}
.key=${'projectType'}
.required=${true}
.options=${[
{ option: 'Web Application', key: 'web' },
{ option: 'Mobile Application', key: 'mobile' },
{ option: 'Desktop Application', key: 'desktop' },
{ option: 'API Service', key: 'api' }
]}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Development Framework'}
.key=${'framework'}
.required=${true}
.options=${[
{ option: 'React', key: 'react', payload: { type: 'web' } },
{ option: 'Vue.js', key: 'vue', payload: { type: 'web' } },
{ option: 'Angular', key: 'angular', payload: { type: 'web' } },
{ option: 'React Native', key: 'react-native', payload: { type: 'mobile' } },
{ option: 'Flutter', key: 'flutter', payload: { type: 'mobile' } },
{ option: 'Electron', key: 'electron', payload: { type: 'desktop' } }
]}
></dees-input-dropdown>
<dees-form-submit .text=${'Create Project'}></dees-form-submit>
</dees-form>
</dees-panel>
</div> </div>
</dees-demowrapper> </dees-demowrapper>
` `

View File

@ -280,6 +280,11 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
elevatedDropdown.style.top = this.getBoundingClientRect().top + 'px'; elevatedDropdown.style.top = this.getBoundingClientRect().top + 'px';
elevatedDropdown.style.left = this.getBoundingClientRect().left + 'px'; elevatedDropdown.style.left = this.getBoundingClientRect().left + 'px';
elevatedDropdown.style.width = this.clientWidth + 'px'; elevatedDropdown.style.width = this.clientWidth + 'px';
// Get z-index from registry for the elevated dropdown
const dropdownZIndex = (await import('./00zindex.js')).zIndexRegistry.getNextZIndex();
elevatedDropdown.style.zIndex = dropdownZIndex.toString();
(await import('./00zindex.js')).zIndexRegistry.register(elevatedDropdown, dropdownZIndex);
elevatedDropdown.options = this.options; elevatedDropdown.options = this.options;
elevatedDropdown.selectedOption = this.selectedOption; elevatedDropdown.selectedOption = this.selectedOption;
elevatedDropdown.highlightedIndex = elevatedDropdown.selectedOption ? elevatedDropdown.options.indexOf( elevatedDropdown.highlightedIndex = elevatedDropdown.selectedOption ? elevatedDropdown.options.indexOf(
@ -289,6 +294,12 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
console.log(elevatedDropdown.selectedOption); console.log(elevatedDropdown.selectedOption);
console.log(elevatedDropdown.highlightedIndex); console.log(elevatedDropdown.highlightedIndex);
this.windowOverlay.appendChild(elevatedDropdown); this.windowOverlay.appendChild(elevatedDropdown);
// Prevent clicks on the dropdown from closing it
elevatedDropdown.addEventListener('click', (e: Event) => {
e.stopPropagation();
});
await domtoolsInstance.convenience.smartdelay.delayFor(0); await domtoolsInstance.convenience.smartdelay.delayFor(0);
elevatedDropdown.toggleSelectionBox(); elevatedDropdown.toggleSelectionBox();
const destroyOverlay = async () => { const destroyOverlay = async () => {
@ -296,9 +307,13 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
'0'; '0';
elevatedDropdown.removeEventListener('selectedOption', handleSelection); elevatedDropdown.removeEventListener('selectedOption', handleSelection);
this.windowOverlay.removeEventListener('clicked', destroyOverlay); this.windowOverlay.removeEventListener('clicked', destroyOverlay);
// Unregister elevated dropdown from z-index registry
(await import('./00zindex.js')).zIndexRegistry.unregister(elevatedDropdown);
this.windowOverlay.destroy(); this.windowOverlay.destroy();
}; };
const handleSelection = async (event) => { const handleSelection = async () => {
await this.updateSelection(elevatedDropdown.selectedOption); await this.updateSelection(elevatedDropdown.selectedOption);
destroyOverlay(); destroyOverlay();
}; };
@ -323,10 +338,20 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
await domtoolsInstance.convenience.smartdelay.delayFor(0); await domtoolsInstance.convenience.smartdelay.delayFor(0);
const searchInput = selectionBox.querySelector('input'); const searchInput = selectionBox.querySelector('input');
searchInput?.focus(); searchInput?.focus();
// Get z-index from registry for the selection box
const selectionBoxZIndex = (await import('./00zindex.js')).zIndexRegistry.getNextZIndex();
selectionBox.style.zIndex = selectionBoxZIndex.toString();
(await import('./00zindex.js')).zIndexRegistry.register(selectionBox as HTMLElement, selectionBoxZIndex);
selectionBox.classList.add('show'); selectionBox.classList.add('show');
} else { } else {
selectedBox.style.pointerEvents = 'none'; selectedBox.style.pointerEvents = 'none';
selectionBox.classList.remove('show'); selectionBox.classList.remove('show');
// Unregister selection box from z-index registry
(await import('./00zindex.js')).zIndexRegistry.unregister(selectionBox as HTMLElement);
// selectedBox.style.opacity = '0'; // selectedBox.style.opacity = '0';
} }
} }

View File

@ -51,85 +51,151 @@ export const demoFunc = () => html`
</style> </style>
<div class="demo-container"> <div class="demo-container">
<dees-panel .title=${'Basic File Upload'} .subtitle=${'Simple file upload with drag and drop support'}> <dees-panel .title=${'1. Basic File Upload'} .subtitle=${'Simple file upload with drag and drop support'}>
<dees-input-fileupload <dees-input-fileupload
.label=${'Attachments'} .label=${'Attachments'}
.description=${'Upload files by clicking or dragging'} .description=${'Upload any files by clicking or dragging them here'}
></dees-input-fileupload> ></dees-input-fileupload>
<dees-input-fileupload <dees-input-fileupload
.label=${'Resume'} .label=${'Single File Only'}
.description=${'Upload your CV in PDF format'} .description=${'Only one file can be uploaded at a time'}
.buttonText=${'Choose Resume...'} .multiple=${false}
.buttonText=${'Choose File'}
></dees-input-fileupload> ></dees-input-fileupload>
</dees-panel> </dees-panel>
<dees-panel .title=${'Multiple Upload Areas'} .subtitle=${'Different upload zones for various file types'}> <dees-panel .title=${'2. File Type Restrictions'} .subtitle=${'Upload areas with specific file type requirements'}>
<div class="upload-grid"> <div class="upload-grid">
<div class="upload-box"> <div class="upload-box">
<h4>Profile Picture</h4> <h4>Images Only</h4>
<dees-input-fileupload <dees-input-fileupload
.label=${'Avatar'} .label=${'Profile Picture'}
.description=${'JPG, PNG or GIF'} .description=${'JPG, PNG or GIF (max 5MB)'}
.buttonText=${'Select Image...'} .accept=${'image/jpeg,image/png,image/gif'}
.maxSize=${5 * 1024 * 1024}
.multiple=${false}
.buttonText=${'Select Image'}
></dees-input-fileupload> ></dees-input-fileupload>
</div> </div>
<div class="upload-box"> <div class="upload-box">
<h4>Cover Image</h4> <h4>Documents Only</h4>
<dees-input-fileupload <dees-input-fileupload
.label=${'Banner'} .label=${'Resume'}
.description=${'Recommended: 1200x400px'} .description=${'PDF or Word documents only'}
.buttonText=${'Select Banner...'} .accept=${".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
.buttonText=${'Select Document'}
></dees-input-fileupload> ></dees-input-fileupload>
</div> </div>
</div> </div>
</dees-panel> </dees-panel>
<dees-panel .title=${'Required & Disabled States'} .subtitle=${'Different upload states for validation'}> <dees-panel .title=${'3. Validation & Limits'} .subtitle=${'File size limits and validation examples'}>
<dees-input-fileupload <dees-input-fileupload
.label=${'Identity Document'} .label=${'Small Files Only'}
.description=${'Required for verification'} .description=${'Maximum file size: 1MB'}
.required=${true} .maxSize=${1024 * 1024}
.buttonText=${'Upload Document...'} .buttonText=${'Upload Small File'}
></dees-input-fileupload> ></dees-input-fileupload>
<dees-input-fileupload <dees-input-fileupload
.label=${'System Files'} .label=${'Limited Upload'}
.description=${'File upload is disabled'} .description=${'Maximum 3 files, each up to 2MB'}
.disabled=${true} .maxFiles=${3}
.value=${[]} .maxSize=${2 * 1024 * 1024}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Required Upload'}
.description=${'This field is required'}
.required=${true}
></dees-input-fileupload> ></dees-input-fileupload>
</dees-panel> </dees-panel>
<dees-panel .title=${'Application Form'} .subtitle=${'Complete form with file upload integration'}> <dees-panel .title=${'4. States & Styling'} .subtitle=${'Different states and validation feedback'}>
<dees-input-fileupload
.label=${'Disabled Upload'}
.description=${'File upload is currently disabled'}
.disabled=${true}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Pre-filled Example'}
.description=${'Component with pre-loaded files'}
.value=${[
new File(['Hello World'], 'example.txt', { type: 'text/plain' }),
new File(['Test Data'], 'data.json', { type: 'application/json' })
]}
></dees-input-fileupload>
</dees-panel>
<dees-panel .title=${'5. Form Integration'} .subtitle=${'Complete form with various file upload scenarios'}>
<dees-form> <dees-form>
<dees-input-text .label=${'Full Name'} .required=${true}></dees-input-text> <h3 style="margin-top: 0; margin-bottom: 24px; color: ${cssManager.bdTheme('#333', '#fff')};">Job Application Form</h3>
<dees-input-text .label=${'Email'} .inputType=${'email'} .required=${true}></dees-input-text>
<dees-input-text
.label=${'Full Name'}
.required=${true}
.key=${'fullName'}
></dees-input-text>
<dees-input-text
.label=${'Email'}
.inputType=${'email'}
.required=${true}
.key=${'email'}
></dees-input-text>
<dees-input-fileupload <dees-input-fileupload
.label=${'Resume'} .label=${'Resume'}
.description=${'Upload your CV (PDF preferred)'} .description=${'Required: PDF format only (max 10MB)'}
.required=${true} .required=${true}
.accept=${'application/pdf'}
.maxSize=${10 * 1024 * 1024}
.multiple=${false}
.key=${'resume'}
></dees-input-fileupload> ></dees-input-fileupload>
<dees-input-fileupload <dees-input-fileupload
.label=${'Portfolio'} .label=${'Portfolio'}
.description=${'Optional: Upload work samples'} .description=${'Optional: Upload up to 5 work samples (images or PDFs, max 5MB each)'}
.accept=${'image/*,application/pdf'}
.maxFiles=${5}
.maxSize=${5 * 1024 * 1024}
.key=${'portfolio'}
></dees-input-fileupload> ></dees-input-fileupload>
<dees-input-fileupload
.label=${'References'}
.description=${'Upload reference letters (optional)'}
.accept=${".pdf,.doc,.docx"}
.key=${'references'}
></dees-input-fileupload>
<dees-input-text <dees-input-text
.label=${'Cover Letter'} .label=${'Additional Comments'}
.inputType=${'textarea'} .inputType=${'textarea'}
.description=${'Tell us why you would be a great fit'} .description=${'Any additional information you would like to share'}
.key=${'comments'}
></dees-input-text> ></dees-input-text>
<dees-form-submit .text=${'Submit Application'}></dees-form-submit>
</dees-form> </dees-form>
<div class="info-section"> <div class="info-section">
<h4>Features:</h4> <h4 style="margin-top: 0;">Enhanced Features:</h4>
<ul> <ul style="margin: 0; padding-left: 20px;">
<li>Click to select files or drag & drop</li> <li>Drag & drop with visual feedback</li>
<li>Multiple file selection support</li> <li>File type restrictions via accept attribute</li>
<li>Visual feedback for drag operations</li> <li>File size validation with custom limits</li>
<li>Right-click files to remove them</li> <li>Maximum file count restrictions</li>
<li>Integrates seamlessly with forms</li> <li>Image preview thumbnails</li>
<li>File type-specific icons</li>
<li>Clear all button for multiple files</li>
<li>Proper validation states and messages</li>
<li>Keyboard accessible</li>
<li>Single or multiple file modes</li>
</ul> </ul>
</div> </div>
</dees-panel> </dees-panel>

View File

@ -42,6 +42,21 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
}) })
public buttonText: string = 'Upload File...'; public buttonText: string = 'Upload File...';
@property({ type: String })
public accept: string = '';
@property({ type: Boolean })
public multiple: boolean = true;
@property({ type: Number })
public maxSize: number = 0; // 0 means no limit
@property({ type: Number })
public maxFiles: number = 0; // 0 means no limit
@property({ type: String, reflect: true })
public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
constructor() { constructor() {
super(); super();
} }
@ -52,7 +67,7 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
css` css`
:host { :host {
position: relative; position: relative;
display: grid; display: block;
color: ${cssManager.bdTheme('#333', '#ccc')}; color: ${cssManager.bdTheme('#333', '#ccc')};
} }
@ -60,13 +75,42 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
display: none; display: none;
} }
.input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.maincontainer { .maincontainer {
position: relative; position: relative;
border-radius: 3px; border-radius: 8px;
padding: 8px; padding: 16px;
background: ${cssManager.bdTheme('#fafafa', '#222222')}; background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
color: ${cssManager.bdTheme('#333', '#ccc')}; color: ${cssManager.bdTheme('#333', '#ccc')};
border-top: 1px solid #ffffff10; border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
transition: all 0.2s ease;
}
.maincontainer:hover {
border-color: ${cssManager.bdTheme('#ccc', '#444')};
}
:host([disabled]) .maincontainer {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
:host([validationState="invalid"]) .maincontainer {
border-color: #e74c3c;
}
:host([validationState="valid"]) .maincontainer {
border-color: #27ae60;
}
:host([validationState="warn"]) .maincontainer {
border-color: #f39c12;
} }
.maincontainer::after { .maincontainer::after {
@ -78,115 +122,385 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
position: absolute; position: absolute;
content: ''; content: '';
display: block; display: block;
border: 2px dashed rgba(255, 255, 255, 0); border: 2px dashed transparent;
transition: all 0.2s; border-radius: 6px;
transition: all 0.3s ease;
pointer-events: none; pointer-events: none;
background: #00000000; background: transparent;
} }
.maincontainer.dragOver {
border-color: ${cssManager.bdTheme('#0084ff', '#0084ff')};
background: ${cssManager.bdTheme('#f0f8ff', '#001933')};
}
.maincontainer.dragOver::after { .maincontainer.dragOver::after {
transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1);
border: 2px dashed rgba(255, 255, 255, 0.3); border: 2px dashed ${cssManager.bdTheme('#0084ff', '#0084ff')};
background: #00000080;
} }
.uploadButton { .uploadButton {
position: relative; position: relative;
padding: 8px; padding: 12px 24px;
max-width: 600px; background: ${cssManager.bdTheme('#0084ff', '#0084ff')};
background: ${cssManager.bdTheme('#fafafa', '#333333')}; color: white;
border-radius: 3px; border-radius: 6px;
text-align: center; text-align: center;
font-size: 14px; font-size: 14px;
cursor: default; font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
} }
.uploadButton:hover { .uploadButton:hover {
color: #fff; background: ${cssManager.bdTheme('#0073e6', '#0073e6')};
background: ${unsafeCSS(colors.dark.blue)}; transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 132, 255, 0.3);
}
.uploadButton:active {
transform: translateY(0);
}
.uploadButton dees-icon {
font-size: 16px;
}
.files-container {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
} }
.uploadCandidate { .uploadCandidate {
display: grid; display: grid;
grid-template-columns: 48px auto; grid-template-columns: 40px 1fr auto;
background: #333; background: ${cssManager.bdTheme('#ffffff', '#2a2a2a')};
padding: 8px 8px 8px 0px; padding: 12px;
margin-bottom: 8px;
text-align: left; text-align: left;
border-radius: 3px; border-radius: 6px;
color: ${cssManager.bdTheme('#666', '#ccc')}; color: ${cssManager.bdTheme('#333', '#ccc')};
font-family: 'Geist Sans', sans-serif;
cursor: default; cursor: default;
transition: all 0.2s; transition: all 0.2s;
border-top: 1px solid #ffffff10; border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
position: relative;
overflow: hidden;
} }
.uploadCandidate:last-child { .uploadCandidate:hover {
margin-bottom: 8px; background: ${cssManager.bdTheme('#f5f5f5', '#333')};
border-color: ${cssManager.bdTheme('#ccc', '#444')};
} }
.uploadCandidate .icon { .uploadCandidate .icon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 16px; font-size: 20px;
color: ${cssManager.bdTheme('#666', '#999')};
} }
.uploadCandidate:hover { .uploadCandidate.image-file .icon {
background: #393939; color: #4CAF50;
}
.uploadCandidate.pdf-file .icon {
color: #f44336;
}
.uploadCandidate.doc-file .icon {
color: #2196F3;
} }
.uploadCandidate .description { .uploadCandidate .info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.uploadCandidate .filename {
font-weight: 500;
font-size: 14px; font-size: 14px;
border-left: 1px solid #ffffff10; white-space: nowrap;
padding-left: 8px; overflow: hidden;
text-overflow: ellipsis;
}
.uploadCandidate .filesize {
font-size: 12px;
color: ${cssManager.bdTheme('#666', '#999')};
}
.uploadCandidate .actions {
display: flex;
align-items: center;
gap: 8px;
}
.remove-button {
width: 32px;
height: 32px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
color: ${cssManager.bdTheme('#666', '#999')};
}
.remove-button:hover {
background: ${cssManager.bdTheme('#fee', '#4a1c1c')};
color: ${cssManager.bdTheme('#e74c3c', '#ff6b6b')};
}
.clear-all-button {
margin-bottom: 8px;
text-align: right;
}
.clear-all-button button {
background: none;
border: none;
color: ${cssManager.bdTheme('#666', '#999')};
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.clear-all-button button:hover {
background: ${cssManager.bdTheme('#fee', '#4a1c1c')};
color: ${cssManager.bdTheme('#e74c3c', '#ff6b6b')};
}
.validation-message {
font-size: 12px;
margin-top: 4px;
color: #e74c3c;
}
.drop-hint {
text-align: center;
padding: 40px 20px;
color: ${cssManager.bdTheme('#999', '#666')};
font-size: 14px;
}
.drop-hint dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.3;
}
.image-preview {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
.description-text {
font-size: 12px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-top: 4px;
line-height: 1.4;
} }
`, `,
]; ];
public render(): TemplateResult { public render(): TemplateResult {
const hasFiles = this.value.length > 0;
const showClearAll = hasFiles && this.value.length > 1;
return html` return html`
<div class="input-wrapper"> <div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description}></dees-label> ${this.label ? html`
<dees-label .label=${this.label}></dees-label>
` : ''}
<div class="hidden"> <div class="hidden">
<input type="file"> <input
type="file"
?multiple=${this.multiple}
accept="${this.accept}"
>
</div> </div>
<div class="maincontainer ${this.state === 'dragOver' ? 'dragOver' : ''}"> <div class="maincontainer ${this.state === 'dragOver' ? 'dragOver' : ''}">
${this.value.map( ${hasFiles ? html`
(fileArg) => html` ${showClearAll ? html`
<div class="uploadCandidate" @contextmenu=${eventArg => { <div class="clear-all-button">
DeesContextmenu.openContextMenuWithOptions(eventArg, [{ <button @click=${this.clearAll}>Clear All</button>
iconName: 'trash', </div>
name: 'Remove', ` : ''}
action: async () => { <div class="files-container">
this.value.splice(this.value.indexOf(fileArg), 1); ${this.value.map((fileArg) => {
this.requestUpdate(); const fileType = this.getFileType(fileArg);
} const isImage = fileType === 'image';
}]); return html`
}}> <div class="uploadCandidate ${fileType}-file">
<div class="icon"> <div class="icon">
<dees-icon .iconFA=${'paperclip'}></dees-icon> ${isImage && this.canShowPreview(fileArg) ? html`
<img class="image-preview" src="${URL.createObjectURL(fileArg)}" alt="${fileArg.name}">
` : html`
<dees-icon .iconName=${this.getFileIcon(fileArg)}></dees-icon>
`}
</div>
<div class="info">
<div class="filename" title="${fileArg.name}">${fileArg.name}</div>
<div class="filesize">${this.formatFileSize(fileArg.size)}</div>
</div>
<div class="actions">
<button
class="remove-button"
@click=${() => this.removeFile(fileArg)}
title="Remove file"
>
<dees-icon .iconName=${'lucide:x'}></dees-icon>
</button>
</div>
</div>
`;
})}
</div> </div>
<div class="description"> ` : html`
<span style="font-weight: 600">${fileArg.name}</span><br /> <div class="drop-hint">
<span style="font-weight: 400">${fileArg.size}</span> <dees-icon .iconName=${'lucide:cloud-upload'}></dees-icon>
<div>Drag files here or click to browse</div>
</div> </div>
</div> ` `}
)} <div class="uploadButton" @click=${this.openFileSelector}>
<div class="uploadButton" @click=${ <dees-icon .iconName=${'lucide:upload'}></dees-icon>
this.openFileSelector ${this.buttonText}
}> </div>
${this.buttonText}
</div> </div>
</div> ${this.description ? html`
<div class="description-text">${this.description}</div>
` : ''}
${this.validationState === 'invalid' && this.validationMessage ? html`
<div class="validation-message">${this.validationMessage}</div>
` : ''}
</div> </div>
`; `;
} }
private validationMessage: string = '';
// Utility methods
private formatFileSize(bytes: number): string {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
private getFileType(file: File): string {
const type = file.type.toLowerCase();
if (type.startsWith('image/')) return 'image';
if (type === 'application/pdf') return 'pdf';
if (type.includes('word') || type.includes('document')) return 'doc';
if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet';
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
if (type.startsWith('video/')) return 'video';
if (type.startsWith('audio/')) return 'audio';
if (type.includes('zip') || type.includes('compressed')) return 'archive';
return 'file';
}
private getFileIcon(file: File): string {
const type = this.getFileType(file);
const iconMap = {
'image': 'lucide:image',
'pdf': 'lucide:file-text',
'doc': 'lucide:file-text',
'spreadsheet': 'lucide:table',
'presentation': 'lucide:presentation',
'video': 'lucide:video',
'audio': 'lucide:music',
'archive': 'lucide:archive',
'file': 'lucide:file'
};
return iconMap[type] || 'lucide:file';
}
private canShowPreview(file: File): boolean {
return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; // 5MB limit for previews
}
private validateFile(file: File): boolean {
// Check file size
if (this.maxSize > 0 && file.size > this.maxSize) {
this.validationMessage = `File "${file.name}" exceeds maximum size of ${this.formatFileSize(this.maxSize)}`;
this.validationState = 'invalid';
return false;
}
// Check file type
if (this.accept) {
const acceptedTypes = this.accept.split(',').map(s => s.trim());
let isAccepted = false;
for (const acceptType of acceptedTypes) {
if (acceptType.startsWith('.')) {
// Extension check
if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) {
isAccepted = true;
break;
}
} else if (acceptType.endsWith('/*')) {
// MIME type wildcard check
const mimePrefix = acceptType.slice(0, -2);
if (file.type.startsWith(mimePrefix)) {
isAccepted = true;
break;
}
} else if (file.type === acceptType) {
// Exact MIME type check
isAccepted = true;
break;
}
}
if (!isAccepted) {
this.validationMessage = `File type not accepted. Please upload: ${this.accept}`;
this.validationState = 'invalid';
return false;
}
}
return true;
}
public async openFileSelector() { public async openFileSelector() {
if (this.disabled) return;
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]'); const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
inputFile.click(); inputFile.click();
this.state = 'idle'; }
this.buttonText = 'Upload more files...';
private removeFile(file: File) {
const index = this.value.indexOf(file);
if (index > -1) {
this.value.splice(index, 1);
this.requestUpdate();
this.validate();
this.changeSubject.next(this);
}
}
private clearAll() {
this.value = [];
this.requestUpdate();
this.validate();
this.changeSubject.next(this);
} }
public async updateValue(eventArg: Event) { public async updateValue(eventArg: Event) {
@ -198,52 +512,131 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) { public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties); super.firstUpdated(_changedProperties);
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]'); const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
inputFile.addEventListener('change', (event: Event) => { inputFile.addEventListener('change', async (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
for (const file of Array.from(target.files)) { const newFiles = Array.from(target.files);
this.value.push(file); await this.addFiles(newFiles);
}
this.requestUpdate();
console.log(`Got ${this.value.length} files!`);
// Reset the input value to allow selecting the same file again if needed // Reset the input value to allow selecting the same file again if needed
target.value = ''; target.value = '';
}); });
// lets handle drag and drop // Handle drag and drop
const dropArea = this.shadowRoot.querySelector('.maincontainer'); const dropArea = this.shadowRoot.querySelector('.maincontainer');
const handlerFunction = (eventArg: DragEvent) => { const handlerFunction = async (eventArg: DragEvent) => {
eventArg.preventDefault(); eventArg.preventDefault();
eventArg.stopPropagation();
switch (eventArg.type) { switch (eventArg.type) {
case 'dragenter':
case 'dragover': case 'dragover':
this.state = 'dragOver'; this.state = 'dragOver';
this.buttonText = 'release to upload file...';
break; break;
case 'dragleave': case 'dragleave':
this.state = 'idle'; // Check if we're actually leaving the drop area
this.buttonText = 'Upload File...'; const rect = dropArea.getBoundingClientRect();
const x = eventArg.clientX;
const y = eventArg.clientY;
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
this.state = 'idle';
}
break; break;
case 'drop': case 'drop':
this.state = 'idle'; this.state = 'idle';
this.buttonText = 'Upload more files...'; const files = Array.from(eventArg.dataTransfer.files);
await this.addFiles(files);
break;
} }
console.log(eventArg);
for (const file of Array.from(eventArg.dataTransfer.files)) {
this.value.push(file);
this.requestUpdate();
}
console.log(`Got ${this.value.length} files!`);
}; };
dropArea.addEventListener('dragenter', handlerFunction, false); dropArea.addEventListener('dragenter', handlerFunction, false);
dropArea.addEventListener('dragleave', handlerFunction, false); dropArea.addEventListener('dragleave', handlerFunction, false);
dropArea.addEventListener('dragover', handlerFunction, false); dropArea.addEventListener('dragover', handlerFunction, false);
dropArea.addEventListener('drop', handlerFunction, false); dropArea.addEventListener('drop', handlerFunction, false);
} }
private async addFiles(files: File[]) {
const filesToAdd: File[] = [];
for (const file of files) {
if (this.validateFile(file)) {
filesToAdd.push(file);
}
}
if (filesToAdd.length === 0) return;
// Check max files limit
if (this.maxFiles > 0) {
const totalFiles = this.value.length + filesToAdd.length;
if (totalFiles > this.maxFiles) {
const allowedCount = this.maxFiles - this.value.length;
if (allowedCount <= 0) {
this.validationMessage = `Maximum ${this.maxFiles} files allowed`;
this.validationState = 'invalid';
return;
}
filesToAdd.splice(allowedCount);
this.validationMessage = `Only ${allowedCount} more file(s) can be added`;
this.validationState = 'warn';
}
}
// Add files
if (!this.multiple && filesToAdd.length > 0) {
this.value = [filesToAdd[0]];
} else {
this.value.push(...filesToAdd);
}
this.requestUpdate();
this.validate();
this.changeSubject.next(this);
// Update button text
if (this.value.length > 0) {
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
}
}
public async validate(): Promise<boolean> {
this.validationMessage = '';
if (this.required && this.value.length === 0) {
this.validationState = 'invalid';
this.validationMessage = 'Please select at least one file';
return false;
}
// Validate all files
for (const file of this.value) {
if (!this.validateFile(file)) {
return false;
}
}
this.validationState = 'valid';
return true;
}
public getValue(): File[] { public getValue(): File[] {
return this.value; return this.value;
} }
public setValue(value: File[]): void { public setValue(value: File[]): void {
this.value = value; this.value = value;
this.requestUpdate();
if (value.length > 0) {
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
} else {
this.buttonText = 'Upload File...';
}
}
public updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('value')) {
this.validate();
}
} }
} }

View File

@ -1,277 +0,0 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import type { DeesInputRadio } from './dees-input-radio.js';
export const demoFunc = () => html`
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
// Implement radio group behavior
const radioGroups = new Map<string, DeesInputRadio[]>();
// Group radios by their container
const radioContainers = elementArg.querySelectorAll('.radio-group');
radioContainers.forEach((container) => {
const radios = Array.from(container.querySelectorAll('dees-input-radio')) as DeesInputRadio[];
const groupName = container.getAttribute('data-group') || 'default';
radioGroups.set(groupName, radios);
// Add click handlers for radio group behavior
radios.forEach((radio) => {
radio.addEventListener('click', () => {
if (!radio.disabled && !radio.value) {
// Uncheck all other radios in the group
radios.forEach((r) => {
if (r !== radio) {
r.value = false;
}
});
radio.value = true;
}
});
});
});
}}>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
background: #f8f9fa;
border-radius: 8px;
padding: 24px;
}
@media (prefers-color-scheme: dark) {
.demo-section {
background: #1a1a1a;
}
}
.demo-section h3 {
margin-top: 0;
margin-bottom: 16px;
color: #0069f2;
font-size: 18px;
}
.demo-section p {
margin-top: 0;
margin-bottom: 16px;
color: #666;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.demo-section p {
color: #999;
}
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: #f0f0f0;
border-radius: 4px;
margin-bottom: 16px;
}
@media (prefers-color-scheme: dark) {
.radio-group {
background: #0a0a0a;
}
}
.radio-group-title {
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
@media (prefers-color-scheme: dark) {
.radio-group-title {
color: #ccc;
}
}
.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
`}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Basic Radio Groups</h3>
<p>Radio buttons for single-choice selections</p>
<div class="radio-group" data-group="plan">
<div class="radio-group-title">Select your subscription plan:</div>
<dees-input-radio
.label=${'Basic Plan - $9/month'}
.value=${true}
.key=${'plan-basic'}
></dees-input-radio>
<dees-input-radio
.label=${'Pro Plan - $29/month'}
.key=${'plan-pro'}
></dees-input-radio>
<dees-input-radio
.label=${'Enterprise Plan - $99/month'}
.key=${'plan-enterprise'}
></dees-input-radio>
</div>
<div class="radio-group" data-group="priority">
<div class="radio-group-title">Task Priority:</div>
<dees-input-radio
.label=${'High Priority'}
.key=${'priority-high'}
></dees-input-radio>
<dees-input-radio
.label=${'Medium Priority'}
.value=${true}
.key=${'priority-medium'}
></dees-input-radio>
<dees-input-radio
.label=${'Low Priority'}
.key=${'priority-low'}
></dees-input-radio>
</div>
</div>
<div class="demo-section">
<h3>Horizontal Layout</h3>
<p>Radio buttons arranged horizontally for yes/no questions</p>
<div class="radio-group" data-group="agreement" style="flex-direction: row;">
<div style="margin-right: 16px;">Do you agree?</div>
<dees-input-radio
.label=${'Yes'}
.layoutMode=${'horizontal'}
.value=${true}
.key=${'agree-yes'}
></dees-input-radio>
<dees-input-radio
.label=${'No'}
.layoutMode=${'horizontal'}
.key=${'agree-no'}
></dees-input-radio>
<dees-input-radio
.label=${'Maybe'}
.layoutMode=${'horizontal'}
.key=${'agree-maybe'}
></dees-input-radio>
</div>
<div class="radio-group" data-group="experience" style="flex-direction: row;">
<div style="margin-right: 16px;">Experience Level:</div>
<dees-input-radio
.label=${'Beginner'}
.layoutMode=${'horizontal'}
.key=${'exp-beginner'}
></dees-input-radio>
<dees-input-radio
.label=${'Intermediate'}
.layoutMode=${'horizontal'}
.value=${true}
.key=${'exp-intermediate'}
></dees-input-radio>
<dees-input-radio
.label=${'Expert'}
.layoutMode=${'horizontal'}
.key=${'exp-expert'}
></dees-input-radio>
</div>
</div>
<div class="demo-section">
<h3>Survey Example</h3>
<p>Multiple radio groups in a survey format</p>
<div class="grid-layout">
<div class="radio-group" data-group="satisfaction">
<div class="radio-group-title">How satisfied are you?</div>
<dees-input-radio .label=${'Very Satisfied'} .key=${'sat-very'}></dees-input-radio>
<dees-input-radio .label=${'Satisfied'} .value=${true} .key=${'sat-normal'}></dees-input-radio>
<dees-input-radio .label=${'Neutral'} .key=${'sat-neutral'}></dees-input-radio>
<dees-input-radio .label=${'Dissatisfied'} .key=${'sat-dis'}></dees-input-radio>
<dees-input-radio .label=${'Very Dissatisfied'} .key=${'sat-verydis'}></dees-input-radio>
</div>
<div class="radio-group" data-group="recommend">
<div class="radio-group-title">Would you recommend us?</div>
<dees-input-radio .label=${'Definitely'} .key=${'rec-def'}></dees-input-radio>
<dees-input-radio .label=${'Probably'} .value=${true} .key=${'rec-prob'}></dees-input-radio>
<dees-input-radio .label=${'Not Sure'} .key=${'rec-unsure'}></dees-input-radio>
<dees-input-radio .label=${'Probably Not'} .key=${'rec-probnot'}></dees-input-radio>
<dees-input-radio .label=${'Definitely Not'} .key=${'rec-defnot'}></dees-input-radio>
</div>
</div>
</div>
<div class="demo-section">
<h3>States</h3>
<p>Different radio button states</p>
<div class="radio-group" data-group="states">
<dees-input-radio
.label=${'Normal Radio'}
.key=${'state-normal'}
></dees-input-radio>
<dees-input-radio
.label=${'Selected Radio'}
.value=${true}
.key=${'state-selected'}
></dees-input-radio>
<dees-input-radio
.label=${'Disabled Unchecked'}
.disabled=${true}
.key=${'state-disabled1'}
></dees-input-radio>
<dees-input-radio
.label=${'Disabled Checked'}
.disabled=${true}
.value=${true}
.key=${'state-disabled2'}
></dees-input-radio>
</div>
</div>
<div class="demo-section">
<h3>Settings Example</h3>
<p>Common radio button patterns in settings</p>
<div class="radio-group" data-group="theme">
<div class="radio-group-title">Theme Preference:</div>
<dees-input-radio .label=${'Light Theme'} .key=${'theme-light'}></dees-input-radio>
<dees-input-radio .label=${'Dark Theme'} .value=${true} .key=${'theme-dark'}></dees-input-radio>
<dees-input-radio .label=${'System Default'} .key=${'theme-system'}></dees-input-radio>
</div>
<div class="radio-group" data-group="notifications">
<div class="radio-group-title">Notification Frequency:</div>
<dees-input-radio .label=${'All Notifications'} .key=${'notif-all'}></dees-input-radio>
<dees-input-radio .label=${'Important Only'} .value=${true} .key=${'notif-important'}></dees-input-radio>
<dees-input-radio .label=${'None'} .key=${'notif-none'}></dees-input-radio>
</div>
</div>
</div>
</dees-demowrapper>
`;

View File

@ -1,113 +0,0 @@
import {customElement, type TemplateResult, property, html, css, cssManager} from '@design.estate/dees-element';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-radio.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-radio': DeesInputRadio;
}
}
@customElement('dees-input-radio')
export class DeesInputRadio extends DeesInputBase<DeesInputRadio> {
public static demo = demoFunc;
// INSTANCE
@property()
public value: boolean = false;
constructor() {
super();
this.labelPosition = 'right'; // Radio buttons default to label on the right
}
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
box-sizing: border-box;
}
:host {
position: relative;
}
.maincontainer {
transition: all 0.3s;
padding: 5px 0px;
color: #ccc;
}
.maincontainer:hover {
color: #fff;
}
input:focus {
outline: none;
border-bottom: 1px solid #e4002b;
}
.checkbox {
transition: all 0.3s;
box-sizing: border-box;
border-radius: 20px;
border: 1px solid #999;
height: 24px;
width: 24px;
display: inline-block;
background: #222;
}
.checkbox.selected {
background: #0050b9;
border: 1px solid #0050b9;
}
.maincontainer:hover .checkbox.selected {
background: #03A9F4;
}
.innercircle {
transition: all 0.3s;
margin: 6px 0px 0px 6px;
background: #222;
width: 10px;
height: 10px;
border-radius: 10px;
}
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<div class="maincontainer" @click="${this.toggleSelected}">
<div class="checkbox ${this.value ? 'selected' : ''}">
${this.value ? html`<div class="innercircle"></div>`: html``}
</div>
</div>
<dees-label .label=${this.label}></dees-label>
</div>
`;
}
public async toggleSelected () {
this.value = !this.value;
this.dispatchEvent(new CustomEvent('newValue', {
detail: this.value,
bubbles: true
}));
this.changeSubject.next(this);
}
public getValue(): boolean {
return this.value;
}
public setValue(value: boolean): void {
this.value = value;
}
}

View File

@ -0,0 +1,200 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.result-display {
margin-top: 16px;
padding: 12px;
background: rgba(0, 105, 242, 0.1);
border-radius: 4px;
font-family: monospace;
font-size: 14px;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic Radio Groups'} .subtitle=${'Simple string options for common use cases'}>
<div class="demo-grid">
<dees-input-radiogroup
.label=${'Subscription Plan'}
.options=${['Basic - $9/month', 'Pro - $29/month', 'Enterprise - $99/month']}
.selectedOption=${'Pro - $29/month'}
.description=${'Choose your subscription tier'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Priority Level'}
.options=${['High', 'Medium', 'Low']}
.selectedOption=${'Medium'}
.required=${true}
></dees-input-radiogroup>
</div>
</dees-panel>
<dees-panel .title=${'2. Horizontal Layout'} .subtitle=${'Radio groups with horizontal arrangement'}>
<dees-input-radiogroup
.label=${'Do you agree with the terms?'}
.options=${['Yes', 'No', 'Maybe']}
.direction=${'horizontal'}
.selectedOption=${'Yes'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Experience Level'}
.options=${['Beginner', 'Intermediate', 'Expert']}
.direction=${'horizontal'}
.selectedOption=${'Intermediate'}
.description=${'Select your experience level with web development'}
></dees-input-radiogroup>
</dees-panel>
<dees-panel .title=${'3. Advanced Options'} .subtitle=${'Using object format with keys and payloads'}>
<dees-input-radiogroup
id="advanced-radio"
.label=${'Select Region'}
.options=${[
{ option: 'United States (US East)', key: 'us-east', payload: { region: 'us-east-1', latency: 20 } },
{ option: 'Europe (Frankfurt)', key: 'eu-central', payload: { region: 'eu-central-1', latency: 50 } },
{ option: 'Asia Pacific (Singapore)', key: 'ap-southeast', payload: { region: 'ap-southeast-1', latency: 120 } }
]}
.selectedOption=${'eu-central'}
.description=${'Choose the closest region for optimal performance'}
@change=${(e: CustomEvent) => {
const display = document.querySelector('#region-result');
if (display) {
display.textContent = 'Selected: ' + JSON.stringify(e.detail.value, null, 2);
}
}}
></dees-input-radiogroup>
<div id="region-result" class="result-display">Selected: { "region": "eu-central-1", "latency": 50 }</div>
</dees-panel>
<dees-panel .title=${'4. Survey Example'} .subtitle=${'Multiple radio groups for surveys and forms'}>
<div class="demo-grid">
<dees-input-radiogroup
.label=${'How satisfied are you?'}
.options=${['Very Satisfied', 'Satisfied', 'Neutral', 'Dissatisfied', 'Very Dissatisfied']}
.selectedOption=${'Satisfied'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Would you recommend us?'}
.options=${['Definitely', 'Probably', 'Not Sure', 'Probably Not', 'Definitely Not']}
.selectedOption=${'Probably'}
></dees-input-radiogroup>
</div>
</dees-panel>
<dees-panel .title=${'5. States & Validation'} .subtitle=${'Different states and validation examples'}>
<div class="demo-grid">
<dees-input-radiogroup
.label=${'Required Selection'}
.options=${['Option A', 'Option B', 'Option C']}
.required=${true}
.description=${'This field is required'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Disabled State'}
.options=${['Disabled Option 1', 'Disabled Option 2', 'Disabled Option 3']}
.selectedOption=${'Disabled Option 2'}
.disabled=${true}
></dees-input-radiogroup>
</div>
</dees-panel>
<dees-panel .title=${'6. Settings Example'} .subtitle=${'Common patterns in application settings'}>
<dees-input-radiogroup
.label=${'Theme Preference'}
.options=${[
{ option: 'Light Theme', key: 'light', payload: 'light' },
{ option: 'Dark Theme', key: 'dark', payload: 'dark' },
{ option: 'System Default', key: 'system', payload: 'auto' }
]}
.selectedOption=${'dark'}
.description=${'Choose how the application should appear'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Notification Frequency'}
.options=${['All Notifications', 'Important Only', 'None']}
.selectedOption=${'Important Only'}
.description=${'Control how often you receive notifications'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Language'}
.options=${['English', 'German', 'French', 'Spanish', 'Japanese']}
.selectedOption=${'English'}
.direction=${'horizontal'}
></dees-input-radiogroup>
</dees-panel>
<dees-panel .title=${'7. Form Integration'} .subtitle=${'Works seamlessly with dees-form'}>
<dees-form>
<dees-input-text
.label=${'Product Name'}
.required=${true}
.key=${'productName'}
></dees-input-text>
<dees-input-radiogroup
.label=${'Product Category'}
.options=${['Electronics', 'Clothing', 'Books', 'Home & Garden', 'Sports']}
.required=${true}
.key=${'category'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Condition'}
.options=${['New', 'Like New', 'Good', 'Fair', 'Poor']}
.direction=${'horizontal'}
.key=${'condition'}
.selectedOption=${'New'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Shipping Speed'}
.options=${[
{ option: 'Standard (5-7 days)', key: 'standard', payload: { days: 7, price: 0 } },
{ option: 'Express (2-3 days)', key: 'express', payload: { days: 3, price: 10 } },
{ option: 'Overnight', key: 'overnight', payload: { days: 1, price: 25 } }
]}
.selectedOption=${'standard'}
.key=${'shipping'}
></dees-input-radiogroup>
<dees-form-submit .text=${'Submit Product'}></dees-form-submit>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -0,0 +1,295 @@
import {
customElement,
type TemplateResult,
property,
html,
css,
cssManager,
} from '@design.estate/dees-element';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-radiogroup.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-radiogroup': DeesInputRadiogroup;
}
}
type RadioOption = string | { option: string; key: string; payload?: any };
@customElement('dees-input-radiogroup')
export class DeesInputRadiogroup extends DeesInputBase<string | object> {
public static demo = demoFunc;
// INSTANCE
@property({ type: Array })
public options: RadioOption[] = [];
@property()
public selectedOption: string = '';
@property({ type: String })
public direction: 'vertical' | 'horizontal' = 'vertical';
@property({ type: String, reflect: true })
public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
// Form compatibility
public get value() {
const option = this.getOptionByKey(this.selectedOption);
if (typeof option === 'object' && option.payload !== undefined) {
return option.payload;
}
return this.selectedOption;
}
public set value(val: string | any) {
if (typeof val === 'string') {
this.selectedOption = val;
} else {
// Try to find option by payload
const option = this.options.find(opt =>
typeof opt === 'object' && opt.payload === val
);
if (option && typeof option === 'object') {
this.selectedOption = option.key;
}
}
}
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
position: relative;
}
.maincontainer {
display: flex;
flex-direction: column;
gap: 8px;
}
.maincontainer.horizontal {
flex-direction: row;
flex-wrap: wrap;
gap: 16px;
}
.radio-option {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
position: relative;
}
.maincontainer.horizontal .radio-option {
padding: 8px 16px 8px 0;
}
.radio-option:hover .radio-circle {
border-color: ${cssManager.bdTheme('#0050b9', '#0084ff')};
}
.radio-option:hover .radio-label {
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
}
.radio-circle {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('#999', '#666')};
background: ${cssManager.bdTheme('#fff', '#1a1a1a')};
transition: all 0.2s ease;
position: relative;
flex-shrink: 0;
}
.radio-option.selected .radio-circle {
border-color: ${cssManager.bdTheme('#0050b9', '#0084ff')};
background: ${cssManager.bdTheme('#0050b9', '#0084ff')};
}
.radio-option.selected .radio-circle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
}
.radio-label {
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#999')};
transition: color 0.2s ease;
}
.radio-option.selected .radio-label {
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
font-weight: 500;
}
:host([disabled]) .radio-option {
cursor: not-allowed;
opacity: 0.5;
}
:host([disabled]) .radio-option:hover .radio-circle {
border-color: ${cssManager.bdTheme('#999', '#666')};
}
:host([disabled]) .radio-option:hover .radio-label {
color: ${cssManager.bdTheme('#666', '#999')};
}
.label-text {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#333', '#ccc')};
margin-bottom: 8px;
}
.description-text {
font-size: 12px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-top: 8px;
line-height: 1.4;
}
/* Validation styles */
:host([validationState="invalid"]) .radio-circle {
border-color: #e74c3c;
}
:host([validationState="valid"]) .radio-option.selected .radio-circle {
border-color: #27ae60;
background: #27ae60;
}
:host([validationState="warn"]) .radio-option.selected .radio-circle {
border-color: #f39c12;
background: #f39c12;
}
/* Override base grid layout for radiogroup to prevent large gaps */
:host([label-position="left"]) .input-wrapper {
grid-template-columns: auto auto;
}
:host([label-position="right"]) .input-wrapper {
grid-template-columns: auto auto;
}
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
${this.label ? html`<div class="label-text">${this.label}</div>` : ''}
<div class="maincontainer ${this.direction}">
${this.options.map((option) => {
const optionKey = this.getOptionKey(option);
const optionLabel = this.getOptionLabel(option);
const isSelected = this.selectedOption === optionKey;
return html`
<div
class="radio-option ${isSelected ? 'selected' : ''}"
@click="${() => this.selectOption(optionKey)}"
>
<div class="radio-circle"></div>
<div class="radio-label">${optionLabel}</div>
</div>
`;
})}
</div>
${this.description ? html`<div class="description-text">${this.description}</div>` : ''}
</div>
`;
}
private getOptionKey(option: RadioOption): string {
if (typeof option === 'string') {
return option;
}
return option.key;
}
private getOptionLabel(option: RadioOption): string {
if (typeof option === 'string') {
return option;
}
return option.option;
}
private getOptionByKey(key: string): RadioOption | undefined {
return this.options.find(opt => this.getOptionKey(opt) === key);
}
private selectOption(key: string): void {
if (this.disabled) {
return;
}
const oldValue = this.selectedOption;
this.selectedOption = key;
if (oldValue !== key) {
this.dispatchEvent(new CustomEvent('change', {
detail: { value: this.value },
bubbles: true,
composed: true,
}));
this.dispatchEvent(new CustomEvent('input', {
detail: { value: this.value },
bubbles: true,
composed: true,
}));
this.changeSubject.next(this);
}
}
public getValue(): string | any {
return this.value;
}
public setValue(val: string | any): void {
this.value = val;
}
public async validate(): Promise<boolean> {
if (this.required && !this.selectedOption) {
this.validationState = 'invalid';
return false;
}
this.validationState = 'valid';
return true;
}
public async firstUpdated() {
// Auto-select first option if none selected and not required
if (!this.selectedOption && this.options.length > 0 && !this.required) {
const firstOption = this.options[0];
this.selectedOption = this.getOptionKey(firstOption);
}
}
}

View File

@ -0,0 +1,133 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
.output-preview {
margin-top: 16px;
padding: 16px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
color: #374151;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
@media (prefers-color-scheme: dark) {
.output-preview {
background: #2c2c2c;
color: #e4e4e7;
}
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic Rich Text Editor'} .subtitle=${'A full-featured rich text editor with formatting toolbar'}>
<dees-input-richtext
.label=${'Article Content'}
.value=${'<h1>Welcome to the Rich Text Editor!</h1><p>This is a feature-rich editor built with TipTap. You can:</p><ul><li><strong>Format text</strong> with <em>various</em> <u>styles</u></li><li>Create different heading levels</li><li>Add <a href="https://example.com">links</a> to external resources</li><li>Write <code>inline code</code> or code blocks</li></ul><blockquote><p>Use the toolbar above to explore all the formatting options available!</p></blockquote><p>Start typing to see the magic happen...</p>'}
.description=${'Use the toolbar to format your content with headings, lists, links, and more'}
.showWordCount=${true}
></dees-input-richtext>
</dees-panel>
<dees-panel .title=${'2. With Placeholder'} .subtitle=${'Empty editor with placeholder text'}>
<dees-input-richtext
.label=${'Blog Post'}
.placeholder=${'Start writing your blog post here...'}
.showWordCount=${true}
></dees-input-richtext>
</dees-panel>
<dees-panel .title=${'3. Different Heights'} .subtitle=${'Editors with different minimum heights for various use cases'}>
<div class="grid-layout">
<dees-input-richtext
.label=${'Short Note'}
.minHeight=${150}
.placeholder=${'Quick note...'}
.showWordCount=${false}
></dees-input-richtext>
<dees-input-richtext
.label=${'Extended Content'}
.minHeight=${300}
.placeholder=${'Write your extended content here...'}
.showWordCount=${true}
></dees-input-richtext>
</div>
</dees-panel>
<dees-panel .title=${'4. Code Examples'} .subtitle=${'Editor pre-filled with code examples'}>
<dees-input-richtext
.label=${'Technical Documentation'}
.value=${'<h2>Installation Guide</h2><p>To install the package, run the following command:</p><pre><code>npm install @design.estate/dees-catalog</code></pre><p>Then import the component in your TypeScript file:</p><pre><code>import { DeesInputRichtext } from "@design.estate/dees-catalog";</code></pre><p>You can now use the <code>&lt;dees-input-richtext&gt;</code> element in your templates.</p>'}
.minHeight=${250}
.showWordCount=${true}
></dees-input-richtext>
</dees-panel>
<dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only rich text content'}>
<dees-input-richtext
.label=${'Published Article (Read Only)'}
.value=${'<h2>The Future of Web Components</h2><p>Web Components have revolutionized how we build modern web applications...</p><blockquote><p>"The future of web development lies in reusable, encapsulated components."</p></blockquote>'}
.disabled=${true}
.showWordCount=${true}
></dees-input-richtext>
</dees-panel>
<dees-panel .title=${'6. Interactive Demo'} .subtitle=${'Type in the editor below and see the HTML output'}>
<dees-input-richtext
id="interactive-editor"
.label=${'Try it yourself'}
.placeholder=${'Type something here...'}
.showWordCount=${true}
@change=${(e: CustomEvent) => {
const output = document.querySelector('#output-preview');
if (output) {
output.textContent = e.detail.value;
}
}}
></dees-input-richtext>
<div class="output-preview" id="output-preview">
<em>HTML output will appear here...</em>
</div>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -0,0 +1,710 @@
import * as colors from './00colors.js';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-richtext.demo.js';
import './dees-icon.js';
import {
customElement,
type TemplateResult,
property,
html,
css,
cssManager,
state,
query,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Link from '@tiptap/extension-link';
import Typography from '@tiptap/extension-typography';
declare global {
interface HTMLElementTagNameMap {
'dees-input-richtext': DeesInputRichtext;
}
}
interface IToolbarButton {
name: string;
icon?: string;
action?: () => void;
isActive?: () => boolean;
title: string;
isDivider?: boolean;
}
@customElement('dees-input-richtext')
export class DeesInputRichtext extends DeesInputBase<string> {
public static demo = demoFunc;
// INSTANCE
@property({
type: String,
reflect: true,
})
public value: string = '';
@property({
type: String,
})
public placeholder: string = '';
@property({
type: Boolean,
})
public showWordCount: boolean = true;
@property({
type: Number,
})
public minHeight: number = 200;
@state()
private showLinkInput: boolean = false;
@state()
private wordCount: number = 0;
@query('.editor-content')
private editorElement: HTMLElement;
@query('.link-input input')
private linkInputElement: HTMLInputElement;
private editor: Editor;
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.input-wrapper {
position: relative;
}
.label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e4e4e7')};
}
.editor-container {
display: flex;
flex-direction: column;
min-height: ${cssManager.bdTheme('200px', '200px')};
border: 1px solid ${cssManager.bdTheme('#e1e5e9', '#2c2c2c')};
border-radius: 8px;
background: ${cssManager.bdTheme('#ffffff', '#141414')};
overflow: hidden;
transition: all 0.2s ease;
}
.editor-container:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#404040')};
}
.editor-container.focused {
border-color: ${cssManager.bdTheme('#0050b9', '#0069f2')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 80, 185, 0.1)', 'rgba(0, 105, 242, 0.1)')};
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
border-bottom: 1px solid ${cssManager.bdTheme('#e1e5e9', '#2c2c2c')};
align-items: center;
position: relative;
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 4px;
background: transparent;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#9ca3af')};
transition: all 0.2s;
user-select: none;
}
.toolbar-button dees-icon {
width: 16px;
height: 16px;
}
.toolbar-button:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#2c2c2c')};
color: ${cssManager.bdTheme('#1f2937', '#e4e4e7')};
}
.toolbar-button.active {
background: ${cssManager.bdTheme('#0050b9', '#0069f2')};
color: white;
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: ${cssManager.bdTheme('#d1d5db', '#404040')};
margin: 0 4px;
}
.editor-content {
flex: 1;
padding: 16px;
overflow-y: auto;
min-height: var(--min-height, 200px);
}
.editor-content .ProseMirror {
outline: none;
line-height: 1.6;
color: ${cssManager.bdTheme('#374151', '#e4e4e7')};
min-height: 100%;
}
.editor-content .ProseMirror p {
margin: 0.5em 0;
}
.editor-content .ProseMirror p:first-child {
margin-top: 0;
}
.editor-content .ProseMirror p:last-child {
margin-bottom: 0;
}
.editor-content .ProseMirror h1 {
font-size: 2em;
font-weight: bold;
margin: 1em 0 0.5em 0;
line-height: 1.2;
}
.editor-content .ProseMirror h2 {
font-size: 1.5em;
font-weight: bold;
margin: 1em 0 0.5em 0;
line-height: 1.3;
}
.editor-content .ProseMirror h3 {
font-size: 1.25em;
font-weight: bold;
margin: 1em 0 0.5em 0;
line-height: 1.4;
}
.editor-content .ProseMirror ul,
.editor-content .ProseMirror ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.editor-content .ProseMirror li {
margin: 0.25em 0;
}
.editor-content .ProseMirror blockquote {
border-left: 4px solid ${cssManager.bdTheme('#d1d5db', '#404040')};
margin: 1em 0;
padding-left: 1em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-style: italic;
}
.editor-content .ProseMirror code {
background: ${cssManager.bdTheme('#f3f4f6', '#2c2c2c')};
border-radius: 4px;
padding: 0.2em 0.4em;
font-family: 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.9em;
color: ${cssManager.bdTheme('#e11d48', '#f87171')};
}
.editor-content .ProseMirror pre {
background: ${cssManager.bdTheme('#1f2937', '#0a0a0a')};
color: ${cssManager.bdTheme('#f9fafb', '#e4e4e7')};
border-radius: 6px;
padding: 1em;
margin: 1em 0;
overflow-x: auto;
}
.editor-content .ProseMirror pre code {
background: none;
color: inherit;
padding: 0;
border-radius: 0;
}
.editor-content .ProseMirror a {
color: ${cssManager.bdTheme('#0050b9', '#0069f2')};
text-decoration: underline;
cursor: pointer;
}
.editor-content .ProseMirror a:hover {
color: ${cssManager.bdTheme('#0069f2', '#0084ff')};
}
.editor-footer {
padding: 8px 12px;
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
border-top: 1px solid ${cssManager.bdTheme('#e1e5e9', '#2c2c2c')};
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
display: flex;
justify-content: space-between;
align-items: center;
}
.word-count {
font-weight: 500;
}
.link-input {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: ${cssManager.bdTheme('#ffffff', '#1a1a1a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#404040')};
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 12px;
z-index: 1000;
}
.link-input.show {
display: block;
}
.link-input input {
width: 100%;
padding: 8px 12px;
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#404040')};
border-radius: 4px;
outline: none;
font-size: 14px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
color: ${cssManager.bdTheme('#374151', '#e4e4e7')};
}
.link-input input:focus {
border-color: ${cssManager.bdTheme('#0050b9', '#0069f2')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('rgba(0, 80, 185, 0.1)', 'rgba(0, 105, 242, 0.1)')};
}
.link-input-buttons {
display: flex;
gap: 8px;
margin-top: 8px;
}
.link-input-buttons button {
padding: 6px 12px;
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#404040')};
border-radius: 4px;
background: ${cssManager.bdTheme('#ffffff', '#1a1a1a')};
cursor: pointer;
font-size: 12px;
color: ${cssManager.bdTheme('#374151', '#e4e4e7')};
transition: all 0.2s;
}
.link-input-buttons button:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#2c2c2c')};
}
.link-input-buttons button.primary {
background: ${cssManager.bdTheme('#0050b9', '#0069f2')};
color: white;
border-color: ${cssManager.bdTheme('#0050b9', '#0069f2')};
}
.link-input-buttons button.primary:hover {
background: ${cssManager.bdTheme('#0069f2', '#0084ff')};
}
.description {
margin-top: 8px;
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
line-height: 1.4;
}
:host([disabled]) .editor-container {
opacity: 0.6;
cursor: not-allowed;
}
:host([disabled]) .toolbar-button,
:host([disabled]) .editor-content {
pointer-events: none;
}
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
${this.label ? html`<label class="label">${this.label}</label>` : ''}
<div class="editor-container ${this.editor?.isFocused ? 'focused' : ''}" style="--min-height: ${this.minHeight}px">
<div class="editor-toolbar">
${this.renderToolbar()}
<div class="link-input ${this.showLinkInput ? 'show' : ''}">
<input type="url" placeholder="Enter URL..." @keydown=${this.handleLinkInputKeydown} />
<div class="link-input-buttons">
<button class="primary" @click=${this.saveLink}>Save</button>
<button @click=${this.removeLink}>Remove</button>
<button @click=${this.hideLinkInput}>Cancel</button>
</div>
</div>
</div>
<div class="editor-content"></div>
${this.showWordCount
? html`
<div class="editor-footer">
<span class="word-count">${this.wordCount} word${this.wordCount !== 1 ? 's' : ''}</span>
</div>
`
: ''}
</div>
${this.description ? html`<div class="description">${this.description}</div>` : ''}
</div>
`;
}
private renderToolbar(): TemplateResult {
const buttons: IToolbarButton[] = this.getToolbarButtons();
return html`
${buttons.map((button) => {
if (button.isDivider) {
return html`<div class="toolbar-divider"></div>`;
}
return html`
<button
class="toolbar-button ${button.isActive?.() ? 'active' : ''}"
@click=${button.action}
title=${button.title}
?disabled=${this.disabled || !this.editor}
>
<dees-icon .icon=${button.icon}></dees-icon>
</button>
`;
})}
`;
}
private getToolbarButtons(): IToolbarButton[] {
if (!this.editor) return [];
return [
{
name: 'bold',
icon: 'lucide:bold',
title: 'Bold (Ctrl+B)',
action: () => this.editor.chain().focus().toggleBold().run(),
isActive: () => this.editor.isActive('bold'),
},
{
name: 'italic',
icon: 'lucide:italic',
title: 'Italic (Ctrl+I)',
action: () => this.editor.chain().focus().toggleItalic().run(),
isActive: () => this.editor.isActive('italic'),
},
{
name: 'underline',
icon: 'lucide:underline',
title: 'Underline (Ctrl+U)',
action: () => this.editor.chain().focus().toggleUnderline().run(),
isActive: () => this.editor.isActive('underline'),
},
{
name: 'strike',
icon: 'lucide:strikethrough',
title: 'Strikethrough',
action: () => this.editor.chain().focus().toggleStrike().run(),
isActive: () => this.editor.isActive('strike'),
},
{ name: 'divider1', title: '', isDivider: true },
{
name: 'h1',
icon: 'lucide:heading1',
title: 'Heading 1',
action: () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => this.editor.isActive('heading', { level: 1 }),
},
{
name: 'h2',
icon: 'lucide:heading2',
title: 'Heading 2',
action: () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => this.editor.isActive('heading', { level: 2 }),
},
{
name: 'h3',
icon: 'lucide:heading3',
title: 'Heading 3',
action: () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => this.editor.isActive('heading', { level: 3 }),
},
{ name: 'divider2', title: '', isDivider: true },
{
name: 'bulletList',
icon: 'lucide:list',
title: 'Bullet List',
action: () => this.editor.chain().focus().toggleBulletList().run(),
isActive: () => this.editor.isActive('bulletList'),
},
{
name: 'orderedList',
icon: 'lucide:listOrdered',
title: 'Numbered List',
action: () => this.editor.chain().focus().toggleOrderedList().run(),
isActive: () => this.editor.isActive('orderedList'),
},
{
name: 'blockquote',
icon: 'lucide:quote',
title: 'Quote',
action: () => this.editor.chain().focus().toggleBlockquote().run(),
isActive: () => this.editor.isActive('blockquote'),
},
{
name: 'code',
icon: 'lucide:code',
title: 'Code',
action: () => this.editor.chain().focus().toggleCode().run(),
isActive: () => this.editor.isActive('code'),
},
{
name: 'codeBlock',
icon: 'lucide:fileCode',
title: 'Code Block',
action: () => this.editor.chain().focus().toggleCodeBlock().run(),
isActive: () => this.editor.isActive('codeBlock'),
},
{ name: 'divider3', title: '', isDivider: true },
{
name: 'link',
icon: 'lucide:link',
title: 'Add Link',
action: () => this.toggleLink(),
isActive: () => this.editor.isActive('link'),
},
{
name: 'alignLeft',
icon: 'lucide:alignLeft',
title: 'Align Left',
action: () => this.editor.chain().focus().setTextAlign('left').run(),
isActive: () => this.editor.isActive({ textAlign: 'left' }),
},
{
name: 'alignCenter',
icon: 'lucide:alignCenter',
title: 'Align Center',
action: () => this.editor.chain().focus().setTextAlign('center').run(),
isActive: () => this.editor.isActive({ textAlign: 'center' }),
},
{
name: 'alignRight',
icon: 'lucide:alignRight',
title: 'Align Right',
action: () => this.editor.chain().focus().setTextAlign('right').run(),
isActive: () => this.editor.isActive({ textAlign: 'right' }),
},
{ name: 'divider4', title: '', isDivider: true },
{
name: 'undo',
icon: 'lucide:undo',
title: 'Undo (Ctrl+Z)',
action: () => this.editor.chain().focus().undo().run(),
},
{
name: 'redo',
icon: 'lucide:redo',
title: 'Redo (Ctrl+Y)',
action: () => this.editor.chain().focus().redo().run(),
},
];
}
public async firstUpdated() {
await this.updateComplete;
this.initializeEditor();
}
private initializeEditor(): void {
if (this.disabled) return;
this.editor = new Editor({
element: this.editorElement,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'editor-link',
},
}),
Typography,
],
content: this.value || (this.placeholder ? `<p>${this.placeholder}</p>` : ''),
onUpdate: ({ editor }) => {
this.value = editor.getHTML();
this.updateWordCount();
this.dispatchEvent(
new CustomEvent('input', {
detail: { value: this.value },
bubbles: true,
composed: true,
})
);
this.dispatchEvent(
new CustomEvent('change', {
detail: { value: this.value },
bubbles: true,
composed: true,
})
);
},
onSelectionUpdate: () => {
this.requestUpdate();
},
onFocus: () => {
this.requestUpdate();
},
onBlur: () => {
this.requestUpdate();
},
});
this.updateWordCount();
}
private updateWordCount(): void {
if (!this.editor) return;
const text = this.editor.getText();
this.wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
}
private toggleLink(): void {
if (!this.editor) return;
if (this.editor.isActive('link')) {
const href = this.editor.getAttributes('link').href;
this.showLinkInput = true;
requestAnimationFrame(() => {
if (this.linkInputElement) {
this.linkInputElement.value = href || '';
this.linkInputElement.focus();
this.linkInputElement.select();
}
});
} else {
this.showLinkInput = true;
requestAnimationFrame(() => {
if (this.linkInputElement) {
this.linkInputElement.value = '';
this.linkInputElement.focus();
}
});
}
}
private saveLink(): void {
if (!this.editor || !this.linkInputElement) return;
const url = this.linkInputElement.value;
if (url) {
this.editor.chain().focus().setLink({ href: url }).run();
}
this.hideLinkInput();
}
private removeLink(): void {
if (!this.editor) return;
this.editor.chain().focus().unsetLink().run();
this.hideLinkInput();
}
private hideLinkInput(): void {
this.showLinkInput = false;
this.editor?.commands.focus();
}
private handleLinkInputKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') {
e.preventDefault();
this.saveLink();
} else if (e.key === 'Escape') {
e.preventDefault();
this.hideLinkInput();
}
}
public setValue(value: string): void {
this.value = value;
if (this.editor && value !== this.editor.getHTML()) {
this.editor.commands.setContent(value);
}
}
public getValue(): string {
return this.value;
}
public clear(): void {
this.setValue('');
}
public focus(): void {
this.editor?.commands.focus();
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
if (this.editor) {
this.editor.destroy();
}
}
}

View File

@ -0,0 +1,248 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
.output-preview {
margin-top: 16px;
padding: 16px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
color: #374151;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
@media (prefers-color-scheme: dark) {
.output-preview {
background: #2c2c2c;
color: #e4e4e7;
}
}
.tag-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
background: #f9fafb;
border-radius: 4px;
min-height: 40px;
align-items: center;
}
@media (prefers-color-scheme: dark) {
.tag-preview {
background: #1f2937;
}
}
.tag-preview-item {
display: inline-block;
padding: 4px 12px;
background: #e0e7ff;
color: #4338ca;
border-radius: 12px;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.tag-preview-item {
background: #312e81;
color: #c7d2fe;
}
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic Tags Input'} .subtitle=${'Simple tag input with common programming languages'}>
<dees-input-tags
.label=${'Programming Languages'}
.placeholder=${'Add a language...'}
.value=${['JavaScript', 'TypeScript', 'Python', 'Go']}
.description=${'Press Enter or comma to add tags'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'2. Tags with Suggestions'} .subtitle=${'Auto-complete suggestions for faster input'}>
<dees-input-tags
.label=${'Tech Stack'}
.placeholder=${'Type to see suggestions...'}
.suggestions=${[
'React', 'Vue', 'Angular', 'Svelte', 'Lit', 'Next.js', 'Nuxt', 'SvelteKit',
'Node.js', 'Deno', 'Bun', 'Express', 'Fastify', 'Nest.js', 'Koa',
'MongoDB', 'PostgreSQL', 'Redis', 'MySQL', 'SQLite', 'Cassandra',
'Docker', 'Kubernetes', 'AWS', 'Azure', 'GCP', 'Vercel', 'Netlify'
]}
.value=${['React', 'Node.js', 'PostgreSQL', 'Docker']}
.description=${'Start typing to see suggestions from popular technologies'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'3. Limited Tags'} .subtitle=${'Restrict the number of tags users can add'}>
<div class="grid-layout">
<dees-input-tags
.label=${'Top 3 Skills'}
.placeholder=${'Add up to 3 skills...'}
.maxTags=${3}
.value=${['Design', 'Development']}
.description=${'Maximum 3 tags allowed'}
></dees-input-tags>
<dees-input-tags
.label=${'Categories (Max 5)'}
.placeholder=${'Select categories...'}
.maxTags=${5}
.suggestions=${['Blog', 'Tutorial', 'News', 'Review', 'Guide', 'Case Study', 'Interview']}
.value=${['Tutorial', 'Guide']}
.description=${'Choose up to 5 categories'}
></dees-input-tags>
</div>
</dees-panel>
<dees-panel .title=${'4. Required & Validation'} .subtitle=${'Tags input with validation requirements'}>
<dees-input-tags
.label=${'Project Tags'}
.placeholder=${'Add at least one tag...'}
.required=${true}
.description=${'This field is required - add at least one tag'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only tags display'}>
<dees-input-tags
.label=${'System Tags'}
.value=${['System', 'Protected', 'Read-Only', 'Archive']}
.disabled=${true}
.description=${'These tags cannot be modified'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'6. Form Integration'} .subtitle=${'Tags input working within a form context'}>
<dees-form>
<dees-input-text
.label=${'Project Name'}
.placeholder=${'My Awesome Project'}
.required=${true}
.key=${'name'}
></dees-input-text>
<div class="grid-layout">
<dees-input-tags
.label=${'Technologies Used'}
.placeholder=${'Add technologies...'}
.required=${true}
.key=${'technologies'}
.suggestions=${[
'TypeScript', 'JavaScript', 'Python', 'Go', 'Rust',
'React', 'Vue', 'Angular', 'Svelte',
'Node.js', 'Deno', 'Express', 'FastAPI'
]}
></dees-input-tags>
<dees-input-tags
.label=${'Project Tags'}
.placeholder=${'Add descriptive tags...'}
.key=${'tags'}
.maxTags=${10}
.suggestions=${[
'frontend', 'backend', 'fullstack', 'mobile', 'desktop',
'web', 'api', 'database', 'devops', 'ui/ux',
'opensource', 'saas', 'enterprise', 'startup'
]}
></dees-input-tags>
</div>
<dees-input-text
.label=${'Description'}
.inputType=${'textarea'}
.placeholder=${'Describe your project...'}
.key=${'description'}
></dees-input-text>
<dees-form-submit .text=${'Create Project'}></dees-form-submit>
</dees-form>
</dees-panel>
<dees-panel .title=${'7. Interactive Demo'} .subtitle=${'Add tags and see them collected in real-time'}>
<dees-input-tags
id="interactive-tags"
.label=${'Your Interests'}
.placeholder=${'Type your interests...'}
.suggestions=${[
'Music', 'Movies', 'Books', 'Travel', 'Photography',
'Cooking', 'Gaming', 'Sports', 'Art', 'Technology',
'Fashion', 'Fitness', 'Nature', 'Science', 'History'
]}
@change=${(e: CustomEvent) => {
const preview = document.querySelector('#tags-preview');
const tags = e.detail.value;
if (preview) {
if (tags.length === 0) {
preview.innerHTML = '<em style="color: #999;">No tags added yet...</em>';
} else {
preview.innerHTML = tags.map((tag: string) =>
`<span class="tag-preview-item">${tag}</span>`
).join('');
}
}
}}
></dees-input-tags>
<div class="tag-preview" id="tags-preview">
<em style="color: #999;">No tags added yet...</em>
</div>
<div class="output-preview" id="tags-json">
<em>JSON output will appear here...</em>
</div>
<script>
// Update JSON preview
const tagsInput = document.querySelector('#interactive-tags');
tagsInput?.addEventListener('change', (e) => {
const jsonPreview = document.querySelector('#tags-json');
if (jsonPreview) {
jsonPreview.textContent = JSON.stringify(e.detail.value, null, 2);
}
});
</script>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -0,0 +1,401 @@
import {
customElement,
html,
css,
cssManager,
property,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesInputBase } from './dees-input-base.js';
import './dees-icon.js';
import { demoFunc } from './dees-input-tags.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-tags': DeesInputTags;
}
}
@customElement('dees-input-tags')
export class DeesInputTags extends DeesInputBase<DeesInputTags> {
// STATIC
public static demo = demoFunc;
// INSTANCE
@property({ type: Array })
public value: string[] = [];
@property({ type: String })
public placeholder: string = 'Add tags...';
@property({ type: Number })
public maxTags: number = 0; // 0 means unlimited
@property({ type: Array })
public suggestions: string[] = [];
@state()
private inputValue: string = '';
@state()
private showSuggestions: boolean = false;
@state()
private highlightedSuggestionIndex: number = -1;
@property({ type: String })
public validationText: string = '';
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
font-family: 'Geist Sans', sans-serif;
}
.input-wrapper {
width: 100%;
}
.tags-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px;
min-height: 40px;
background: ${cssManager.bdTheme('#fafafa', '#222222')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333333')};
border-radius: 8px;
transition: all 0.2s ease;
cursor: text;
}
.tags-container:focus-within {
border-color: ${cssManager.bdTheme('#0069f2', '#0084ff')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('rgba(0, 105, 242, 0.1)', 'rgba(0, 132, 255, 0.2)')};
}
.tags-container.disabled {
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
cursor: not-allowed;
opacity: 0.6;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
color: ${cssManager.bdTheme('#1976d2', '#90caf9')};
border-radius: 16px;
font-size: 14px;
line-height: 1.2;
user-select: none;
animation: tagAppear 0.2s ease;
}
@keyframes tagAppear {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
margin-left: 2px;
}
.tag-remove:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
}
.tag-remove dees-icon {
width: 12px;
height: 12px;
}
.tag-input {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
outline: none;
font-size: 14px;
font-family: inherit;
color: ${cssManager.bdTheme('#333', '#fff')};
padding: 4px;
}
.tag-input::placeholder {
color: ${cssManager.bdTheme('#999', '#666')};
}
.tag-input:disabled {
cursor: not-allowed;
}
/* Suggestions dropdown */
.suggestions-container {
position: relative;
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: ${cssManager.bdTheme('#ffffff', '#222222')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333333')};
border-radius: 8px;
box-shadow: 0 4px 12px ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(0, 0, 0, 0.3)')};
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.suggestion {
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
color: ${cssManager.bdTheme('#333', '#fff')};
}
.suggestion:hover,
.suggestion.highlighted {
background: ${cssManager.bdTheme('#f5f5f5', '#333333')};
}
.suggestion.highlighted {
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
}
/* Validation styles */
.validation-message {
color: #d32f2f;
font-size: 12px;
margin-top: 4px;
min-height: 16px;
}
/* Description styles */
.description {
color: ${cssManager.bdTheme('#666', '#999')};
font-size: 12px;
margin-top: 4px;
}
`,
];
public render(): TemplateResult {
const filteredSuggestions = this.suggestions.filter(
suggestion =>
!this.value.includes(suggestion) &&
suggestion.toLowerCase().includes(this.inputValue.toLowerCase())
);
return html`
<div class="input-wrapper">
${this.label ? html`<dees-label .label=${this.label} .required=${this.required}></dees-label>` : ''}
<div class="suggestions-container">
<div
class="tags-container ${this.disabled ? 'disabled' : ''}"
@click=${this.handleContainerClick}
>
${this.value.map(tag => html`
<div class="tag">
<span>${tag}</span>
${!this.disabled ? html`
<div class="tag-remove" @click=${(e: Event) => this.removeTag(e, tag)}>
<dees-icon .icon=${'lucide:x'}></dees-icon>
</div>
` : ''}
</div>
`)}
${!this.disabled && (!this.maxTags || this.value.length < this.maxTags) ? html`
<input
type="text"
class="tag-input"
.placeholder=${this.placeholder}
.value=${this.inputValue}
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
?disabled=${this.disabled}
/>
` : ''}
</div>
${this.showSuggestions && filteredSuggestions.length > 0 ? html`
<div class="suggestions-dropdown">
${filteredSuggestions.map((suggestion, index) => html`
<div
class="suggestion ${index === this.highlightedSuggestionIndex ? 'highlighted' : ''}"
@mousedown=${(e: Event) => {
e.preventDefault(); // Prevent blur
this.addTag(suggestion);
}}
@mouseenter=${() => this.highlightedSuggestionIndex = index}
>
${suggestion}
</div>
`)}
</div>
` : ''}
</div>
${this.validationText ? html`
<div class="validation-message">${this.validationText}</div>
` : ''}
${this.description ? html`
<div class="description">${this.description}</div>
` : ''}
</div>
`;
}
private handleContainerClick(e: Event) {
if (this.disabled) return;
const input = this.shadowRoot?.querySelector('.tag-input') as HTMLInputElement;
if (input && e.target !== input) {
input.focus();
}
}
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.inputValue = input.value;
// Check for comma or semicolon to add tag
if (this.inputValue.includes(',') || this.inputValue.includes(';')) {
const tag = this.inputValue.replace(/[,;]/g, '').trim();
if (tag) {
this.addTag(tag);
}
}
}
private handleKeyDown(e: KeyboardEvent) {
const input = e.target as HTMLInputElement;
if (e.key === 'Enter') {
e.preventDefault();
if (this.highlightedSuggestionIndex >= 0 && this.showSuggestions) {
const filteredSuggestions = this.suggestions.filter(
suggestion =>
!this.value.includes(suggestion) &&
suggestion.toLowerCase().includes(this.inputValue.toLowerCase())
);
if (filteredSuggestions[this.highlightedSuggestionIndex]) {
this.addTag(filteredSuggestions[this.highlightedSuggestionIndex]);
}
} else if (this.inputValue.trim()) {
this.addTag(this.inputValue.trim());
}
} else if (e.key === 'Backspace' && !this.inputValue && this.value.length > 0) {
// Remove last tag when backspace is pressed on empty input
this.removeTag(e, this.value[this.value.length - 1]);
} else if (e.key === 'ArrowDown' && this.showSuggestions) {
e.preventDefault();
const filteredCount = this.suggestions.filter(
s => !this.value.includes(s) && s.toLowerCase().includes(this.inputValue.toLowerCase())
).length;
this.highlightedSuggestionIndex = Math.min(
this.highlightedSuggestionIndex + 1,
filteredCount - 1
);
} else if (e.key === 'ArrowUp' && this.showSuggestions) {
e.preventDefault();
this.highlightedSuggestionIndex = Math.max(this.highlightedSuggestionIndex - 1, 0);
} else if (e.key === 'Escape') {
this.showSuggestions = false;
this.highlightedSuggestionIndex = -1;
}
}
private handleFocus() {
if (this.suggestions.length > 0) {
this.showSuggestions = true;
}
}
private handleBlur() {
// Delay to allow click on suggestions
setTimeout(() => {
this.showSuggestions = false;
this.highlightedSuggestionIndex = -1;
}, 200);
}
private addTag(tag: string) {
if (!tag || this.value.includes(tag)) return;
if (this.maxTags && this.value.length >= this.maxTags) return;
this.value = [...this.value, tag];
this.inputValue = '';
this.showSuggestions = false;
this.highlightedSuggestionIndex = -1;
// Clear the input
const input = this.shadowRoot?.querySelector('.tag-input') as HTMLInputElement;
if (input) {
input.value = '';
}
this.emitChange();
}
private removeTag(e: Event, tag: string) {
e.stopPropagation();
this.value = this.value.filter(t => t !== tag);
this.emitChange();
}
private emitChange() {
this.dispatchEvent(new CustomEvent('change', {
detail: { value: this.value },
bubbles: true,
composed: true
}));
this.changeSubject.next(this);
}
public getValue(): string[] {
return this.value;
}
public setValue(value: string[]): void {
this.value = value || [];
}
public async validate(): Promise<boolean> {
if (this.required && (!this.value || this.value.length === 0)) {
this.validationText = 'At least one tag is required';
return false;
}
this.validationText = '';
return true;
}
}

View File

@ -100,7 +100,8 @@ export class DeesInputText extends DeesInputBase {
input:focus { input:focus {
outline: none; outline: none;
border-bottom: 1px solid ${cssManager.bdTheme( colors.bright.blueActive, colors.dark.blueActive)}; border-bottom: 1px solid
${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
cursor: text; cursor: text;
} }
@ -117,6 +118,7 @@ export class DeesInputText extends DeesInputBase {
padding: 4px 0px; padding: 4px 0px;
width: 40px; width: 40px;
z-index: 3; z-index: 3;
text-align: center;
} }
.showPassword:hover { .showPassword:hover {
@ -146,18 +148,20 @@ export class DeesInputText extends DeesInputBase {
letter-spacing: ${this.isPasswordBool ? '1px' : 'normal'}; letter-spacing: ${this.isPasswordBool ? '1px' : 'normal'};
color: ${this.goBright ? '#333' : '#ccc'}; color: ${this.goBright ? '#333' : '#ccc'};
} }
${this.validationText ? css` ${this.validationText
.validationContainer { ? css`
height: 22px; .validationContainer {
opacity: 1; height: 22px;
} opacity: 1;
` : css` }
.validationContainer { `
height: 4px; : css`
padding: 2px !important; .validationContainer {
opacity: 0; height: 4px;
} padding: 2px !important;
`} opacity: 0;
}
`}
</style> </style>
<div class="input-wrapper"> <div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description}></dees-label> <dees-label .label=${this.label} .description=${this.description}></dees-label>
@ -168,9 +172,7 @@ export class DeesInputText extends DeesInputBase {
@input="${this.updateValue}" @input="${this.updateValue}"
.disabled=${this.disabled} .disabled=${this.disabled}
/> />
<div class="validationContainer"> <div class="validationContainer">${this.validationText}</div>
${this.validationText}
</div>
${this.isPasswordBool ${this.isPasswordBool
? html` ? html`
<div class="showPassword" @click=${this.togglePasswordView}> <div class="showPassword" @click=${this.togglePasswordView}>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
// Re-export the modular component from the wysiwyg directory
export { DeesInputWysiwyg } from './wysiwyg/dees-input-wysiwyg.js';

View File

@ -1,4 +1,5 @@
import * as plugins from './00plugins.js'; import * as plugins from './00plugins.js';
import { zIndexLayers } from './00zindex.js';
import { import {
cssManager, cssManager,
css, css,
@ -83,7 +84,7 @@ export class DeesMobilenavigation extends DeesElement {
min-width: 280px; min-width: 280px;
transform: translateX(200px); transform: translateX(200px);
color: ${cssManager.bdTheme('#333', '#fff')}; color: ${cssManager.bdTheme('#333', '#fff')};
z-index: 250; z-index: ${zIndexLayers.fixed.mobileNav};
opacity: 0; opacity: 0;
padding: 16px 32px; padding: 16px 32px;
right: 0px; right: 0px;

View File

@ -1,37 +1,356 @@
import { html } from '@design.estate/dees-element'; import { html, css, cssManager } from '@design.estate/dees-element';
import { DeesModal } from './dees-modal.js'; import { DeesModal } from './dees-modal.js';
export const demoFunc = () => html` export const demoFunc = () => html`
<dees-button @click=${() => { <style>
DeesModal.createAndShow({ ${css`
heading: 'This is a heading', .demo-container {
content: html` display: flex;
<dees-form> flex-direction: column;
<dees-input-text gap: 24px;
.label=${'Username'} padding: 24px;
> max-width: 1200px;
</dees-input-text> margin: 0 auto;
<dees-input-text }
.label=${'Password'}
> .demo-section {
</dees-input-text> background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
</dees-form> border-radius: 8px;
`, padding: 24px;
menuOptions: [{ border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
name: 'Cancel', }
iconName: null,
action: async (deesModalArg) => { .demo-section h3 {
deesModalArg.destroy(); margin-top: 0;
return null; margin-bottom: 16px;
} color: ${cssManager.bdTheme('#333', '#fff')};
}, { }
name: 'Ok',
iconName: null, .demo-section p {
action: async (deesModalArg) => { color: ${cssManager.bdTheme('#666', '#999')};
deesModalArg.destroy(); margin-bottom: 16px;
return null; }
}
}], .button-grid {
}); display: grid;
}}>open modal</dees-button> grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
`}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Header Buttons</h3>
<p>Modals can have optional header buttons for help and closing.</p>
<div class="button-grid">
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'With Help Button',
showHelpButton: true,
onHelp: async () => {
const helpModal = await DeesModal.createAndShow({
heading: 'Help',
width: 'small',
showCloseButton: true,
showHelpButton: false,
content: html`
<p>This is the help content for the modal.</p>
<p>You can provide context-specific help here.</p>
`,
menuOptions: [{
name: 'Got it',
action: async (modal) => modal.destroy()
}],
});
},
content: html`
<p>This modal has a help button in the header. Click it to see help content.</p>
<p>The close button is also visible by default.</p>
`,
menuOptions: [{
name: 'OK',
action: async (modal) => modal.destroy()
}],
});
}}>With Help Button</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'No Close Button',
showCloseButton: false,
content: html`
<p>This modal has no close button in the header.</p>
<p>You must use the action buttons or click outside to close it.</p>
`,
menuOptions: [{
name: 'Close',
action: async (modal) => modal.destroy()
}],
});
}}>No Close Button</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Both Buttons',
showHelpButton: true,
showCloseButton: true,
onHelp: () => alert('Help clicked!'),
content: html`
<p>This modal has both help and close buttons.</p>
`,
menuOptions: [{
name: 'Done',
action: async (modal) => modal.destroy()
}],
});
}}>Both Buttons</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Clean Header',
showCloseButton: false,
showHelpButton: false,
content: html`
<p>This modal has a clean header with no buttons.</p>
`,
menuOptions: [{
name: 'Close',
action: async (modal) => modal.destroy()
}],
});
}}>Clean Header</dees-button>
</div>
</div>
<div class="demo-section">
<h3>Modal Width Variations</h3>
<p>Modals can have different widths: small, medium, large, fullscreen, or custom pixel values.</p>
<div class="button-grid">
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Small Modal',
width: 'small',
content: html`
<p>This is a small modal with a width of 380px. Perfect for simple confirmations or brief messages.</p>
`,
menuOptions: [{
name: 'Cancel',
action: async (modal) => modal.destroy()
}, {
name: 'OK',
action: async (modal) => modal.destroy()
}],
});
}}>Small Modal</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Medium Modal (Default)',
width: 'medium',
content: html`
<dees-form>
<dees-input-text .label=${'Username'}></dees-input-text>
<dees-input-text .label=${'Email'} .inputType=${'email'}></dees-input-text>
<dees-input-text .label=${'Password'} .inputType=${'password'}></dees-input-text>
</dees-form>
`,
menuOptions: [{
name: 'Cancel',
action: async (modal) => modal.destroy()
}, {
name: 'Sign Up',
action: async (modal) => modal.destroy()
}],
});
}}>Medium Modal</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Large Modal',
width: 'large',
content: html`
<h4>Wide Content Area</h4>
<p>This large modal is 800px wide and perfect for displaying more complex content like forms with multiple columns, tables, or detailed information.</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;">
<dees-input-text .label=${'First Name'}></dees-input-text>
<dees-input-text .label=${'Last Name'}></dees-input-text>
<dees-input-text .label=${'Company'}></dees-input-text>
<dees-input-text .label=${'Position'}></dees-input-text>
</div>
`,
menuOptions: [{
name: 'Cancel',
action: async (modal) => modal.destroy()
}, {
name: 'Save',
action: async (modal) => modal.destroy()
}],
});
}}>Large Modal</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Fullscreen Editor',
width: 'fullscreen',
showHelpButton: true,
onHelp: async () => {
alert('In a real app, this would show editor documentation');
},
content: html`
<h4>Fullscreen Experience with Header Controls</h4>
<p>This modal takes up almost the entire viewport with a 20px margin on all sides. The header buttons are particularly useful in fullscreen mode.</p>
<p>The content area can be as tall as needed and will scroll if necessary.</p>
<div style="height: 200px; background: ${cssManager.bdTheme('#f0f0f0', '#2a2a2a')}; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-top: 16px;">
<span style="color: ${cssManager.bdTheme('#999', '#666')}">Large content area</span>
</div>
`,
menuOptions: [{
name: 'Save',
action: async (modal) => modal.destroy()
}, {
name: 'Cancel',
action: async (modal) => modal.destroy()
}],
});
}}>Fullscreen Modal</dees-button>
</div>
</div>
<div class="demo-section">
<h3>Custom Width & Constraints</h3>
<p>You can also set custom pixel widths and min/max constraints.</p>
<div class="button-grid">
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Custom Width (700px)',
width: 700,
content: html`
<p>This modal has a custom width of exactly 700 pixels.</p>
`,
menuOptions: [{
name: 'Close',
action: async (modal) => modal.destroy()
}],
});
}}>Custom 700px</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'With Max Width',
width: 'large',
maxWidth: 600,
content: html`
<p>This modal is set to 'large' but constrained by a maxWidth of 600px.</p>
`,
menuOptions: [{
name: 'Got it',
action: async (modal) => modal.destroy()
}],
});
}}>Max Width 600px</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'With Min Width',
width: 300,
minWidth: 400,
content: html`
<p>This modal width is set to 300px but has a minWidth of 400px, so it will be 400px wide.</p>
`,
menuOptions: [{
name: 'OK',
action: async (modal) => modal.destroy()
}],
});
}}>Min Width 400px</dees-button>
</div>
</div>
<div class="demo-section">
<h3>Button Variations</h3>
<p>Modals can have different button configurations with proper spacing.</p>
<div class="button-grid">
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Multiple Actions',
content: html`
<p>This modal demonstrates multiple buttons with proper spacing between them.</p>
`,
menuOptions: [{
name: 'Delete',
action: async (modal) => modal.destroy()
}, {
name: 'Cancel',
action: async (modal) => modal.destroy()
}, {
name: 'Save Changes',
action: async (modal) => modal.destroy()
}],
});
}}>Three Buttons</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Single Action',
content: html`
<p>Sometimes you just need one button.</p>
`,
menuOptions: [{
name: 'Acknowledge',
action: async (modal) => modal.destroy()
}],
});
}}>Single Button</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'No Actions',
content: html`
<p>This modal has no bottom buttons. Use the X button or click outside to close.</p>
<p style="margin-top: 16px; color: ${cssManager.bdTheme('#666', '#999')};">This is useful for informational modals that don't require user action.</p>
`,
menuOptions: [],
});
}}>No Buttons</dees-button>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Long Button Labels',
content: html`
<p>Testing button layout with longer labels.</p>
`,
menuOptions: [{
name: 'Discard All Changes',
action: async (modal) => modal.destroy()
}, {
name: 'Save and Continue Editing',
action: async (modal) => modal.destroy()
}],
});
}}>Long Labels</dees-button>
</div>
</div>
<div class="demo-section">
<h3>Responsive Behavior</h3>
<p>All modals automatically become full-width on mobile devices (< 768px viewport width) for better usability.</p>
<dees-button @click=${() => {
DeesModal.createAndShow({
heading: 'Responsive Modal',
width: 'large',
showHelpButton: true,
onHelp: () => console.log('Help requested for responsive modal'),
content: html`
<p>Resize your browser window to see how this modal adapts. On mobile viewports, it will automatically take the full width minus margins.</p>
<p>The header buttons remain accessible at all viewport sizes.</p>
`,
menuOptions: [{
name: 'Close',
action: async (modal) => modal.destroy()
}],
});
}}>Test Responsive</dees-button>
</div>
</div>
` `

View File

@ -1,5 +1,6 @@
import * as colors from './00colors.js'; import * as colors from './00colors.js';
import * as plugins from './00plugins.js'; import * as plugins from './00plugins.js';
import { zIndexLayers, zIndexRegistry } from './00zindex.js';
import { demoFunc } from './dees-modal.demo.js'; import { demoFunc } from './dees-modal.demo.js';
import { import {
@ -18,6 +19,7 @@ import {
import * as domtools from '@design.estate/dees-domtools'; import * as domtools from '@design.estate/dees-domtools';
import { DeesWindowLayer } from './dees-windowlayer.js'; import { DeesWindowLayer } from './dees-windowlayer.js';
import './dees-icon.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@ -34,12 +36,24 @@ export class DeesModal extends DeesElement {
heading: string; heading: string;
content: TemplateResult; content: TemplateResult;
menuOptions: plugins.tsclass.website.IMenuItem<DeesModal>[]; menuOptions: plugins.tsclass.website.IMenuItem<DeesModal>[];
width?: 'small' | 'medium' | 'large' | 'fullscreen' | number;
maxWidth?: number;
minWidth?: number;
showCloseButton?: boolean;
showHelpButton?: boolean;
onHelp?: () => void | Promise<void>;
}) { }) {
const body = document.body; const body = document.body;
const modal = new DeesModal(); const modal = new DeesModal();
modal.heading = optionsArg.heading; modal.heading = optionsArg.heading;
modal.content = optionsArg.content; modal.content = optionsArg.content;
modal.menuOptions = optionsArg.menuOptions; modal.menuOptions = optionsArg.menuOptions;
if (optionsArg.width) modal.width = optionsArg.width;
if (optionsArg.maxWidth) modal.maxWidth = optionsArg.maxWidth;
if (optionsArg.minWidth) modal.minWidth = optionsArg.minWidth;
if (optionsArg.showCloseButton !== undefined) modal.showCloseButton = optionsArg.showCloseButton;
if (optionsArg.showHelpButton !== undefined) modal.showHelpButton = optionsArg.showHelpButton;
if (optionsArg.onHelp) modal.onHelp = optionsArg.onHelp;
modal.windowLayer = await DeesWindowLayer.createAndShow({ modal.windowLayer = await DeesWindowLayer.createAndShow({
blur: true, blur: true,
}); });
@ -48,6 +62,12 @@ export class DeesModal extends DeesElement {
}); });
body.append(modal.windowLayer); body.append(modal.windowLayer);
body.append(modal); body.append(modal);
// Get z-index for modal (should be above window layer)
modal.modalZIndex = zIndexRegistry.getNextZIndex();
zIndexRegistry.register(modal, modal.modalZIndex);
return modal;
} }
// INSTANCE // INSTANCE
@ -63,6 +83,27 @@ export class DeesModal extends DeesElement {
@state({}) @state({})
public menuOptions: plugins.tsclass.website.IMenuItem<DeesModal>[] = []; public menuOptions: plugins.tsclass.website.IMenuItem<DeesModal>[] = [];
@property({ type: String })
public width: 'small' | 'medium' | 'large' | 'fullscreen' | number = 'medium';
@property({ type: Number })
public maxWidth: number;
@property({ type: Number })
public minWidth: number;
@property({ type: Boolean })
public showCloseButton: boolean = true;
@property({ type: Boolean })
public showHelpButton: boolean = false;
@property({ attribute: false })
public onHelp: () => void | Promise<void>;
@state()
private modalZIndex: number = 1000;
constructor() { constructor() {
super(); super();
} }
@ -85,20 +126,45 @@ export class DeesModal extends DeesElement {
box-sizing: border-box; box-sizing: border-box;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 2000;
} }
.modal { .modal {
will-change: transform; will-change: transform;
transform: translateY(0px) scale(0.95); transform: translateY(0px) scale(0.95);
opacity: 0; opacity: 0;
width: 480px;
min-height: 120px; min-height: 120px;
background: #111; background: ${cssManager.bdTheme('#ffffff', '#111')};
border-radius: 8px; border-radius: 8px;
border: 1px solid #222; border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
transition: all 0.2s; transition: all 0.2s;
overflow: hidden; overflow: hidden;
box-shadow: 0px 2px 5px #00000080; box-shadow: ${cssManager.bdTheme('0px 2px 10px rgba(0, 0, 0, 0.1)', '0px 2px 5px rgba(0, 0, 0, 0.5)')};
margin: 20px;
}
/* Width variations */
.modal.width-small {
width: 380px;
}
.modal.width-medium {
width: 560px;
}
.modal.width-large {
width: 800px;
}
.modal.width-fullscreen {
width: calc(100vw - 40px);
height: calc(100vh - 40px);
max-height: calc(100vh - 40px);
}
@media (max-width: 768px) {
.modal {
width: calc(100vw - 40px) !important;
max-width: none !important;
}
} }
.modal.show { .modal.show {
@ -112,13 +178,61 @@ export class DeesModal extends DeesElement {
} }
.modal .heading { .modal .heading {
height: 32px; height: 40px;
font-family: 'Geist Sans', sans-serif; font-family: 'Geist Sans', sans-serif;
line-height: 32px; display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
position: relative;
}
.modal .heading .header-buttons {
display: flex;
align-items: center;
gap: 4px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
}
.modal .heading .header-button {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
background: transparent;
color: ${cssManager.bdTheme('#666', '#999')};
}
.modal .heading .header-button:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.08)')};
color: ${cssManager.bdTheme('#333', '#fff')};
}
.modal .heading .header-button:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.12)', 'rgba(255, 255, 255, 0.12)')};
}
.modal .heading .header-button dees-icon {
width: 16px;
height: 16px;
display: block;
}
.modal .heading .heading-text {
flex: 1;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 14px;
border-bottom: 1px solid #222; line-height: 40px;
padding: 0 40px;
} }
.modal .content { .modal .content {
@ -127,60 +241,92 @@ export class DeesModal extends DeesElement {
.modal .bottomButtons { .modal .bottomButtons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
border-top: 1px solid #222; border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
justify-content: flex-end; justify-content: flex-end;
gap: 8px;
padding: 8px;
} }
.modal .bottomButtons .bottomButton { .modal .bottomButtons .bottomButton {
margin: 8px 0px; padding: 8px 16px;
padding: 8px 12px; border-radius: 6px;
border-radius: 4px;
line-height: 16px; line-height: 16px;
text-align: center; text-align: center;
font-size: 14px; font-size: 14px;
cursor: default; font-weight: 500;
cursor: pointer;
user-select: none; user-select: none;
} transition: all 0.2s;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
.modal .bottomButtons .bottomButton:first-child { white-space: nowrap;
margin-left: 8px;
}
.modal .bottomButtons .bottomButton:last-child {
margin-right: 8px;
} }
.modal .bottomButtons .bottomButton:hover { .modal .bottomButtons .bottomButton:hover {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)}; background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
color: #ffffff;
} }
.modal .bottomButtons .bottomButton:active { .modal .bottomButtons .bottomButton:active {
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)}; background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
color: #ffffff;
} }
.modal .bottomButtons .bottomButton:last-child { .modal .bottomButtons .bottomButton:last-child {
border-right: none; border-right: none;
} }
.modal .bottomButtons .bottomButton.primary {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
color: #ffffff;
}
.modal .bottomButtons .bottomButton.primary:hover {
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
}
.modal .bottomButtons .bottomButton.primary:active {
background: ${cssManager.bdTheme(colors.bright.blueMuted, colors.dark.blueMuted)};
}
`, `,
]; ];
public render(): TemplateResult { public render(): TemplateResult {
const widthClass = typeof this.width === 'string' ? `width-${this.width}` : '';
const customWidth = typeof this.width === 'number' ? `${this.width}px` : '';
const maxWidthStyle = this.maxWidth ? `${this.maxWidth}px` : '';
const minWidthStyle = this.minWidth ? `${this.minWidth}px` : '';
return html` return html`
<style> <style>
.modal .bottomButtons { ${customWidth ? `.modal { width: ${customWidth}; }` : ''}
grid-template-columns: ${cssManager.cssGridColumns(this.menuOptions.length, 0)}; ${maxWidthStyle ? `.modal { max-width: ${maxWidthStyle}; }` : ''}
} ${minWidthStyle ? `.modal { min-width: ${minWidthStyle}; }` : ''}
</style> </style>
<div class="modalContainer" @click=${this.handleOutsideClick}> <div class="modalContainer" @click=${this.handleOutsideClick} style="z-index: ${this.modalZIndex}">
<div class="modal"> <div class="modal ${widthClass}">
<div class="heading">${this.heading}</div> <div class="heading">
<div class="content">${this.content}</div> <div class="heading-text">${this.heading}</div>
<div class="bottomButtons"> <div class="header-buttons">
${this.menuOptions.map( ${this.showHelpButton ? html`
(actionArg) => html` <div class="header-button" @click=${this.handleHelp} title="Help">
<div class="bottomButton" @click=${() => { <dees-icon .icon=${'lucide:helpCircle'}></dees-icon>
actionArg.action(this); </div>
}}>${actionArg.name}</div> ` : ''}
` ${this.showCloseButton ? html`
)} <div class="header-button" @click=${() => this.destroy()} title="Close">
<dees-icon .icon=${'lucide:x'}></dees-icon>
</div>
` : ''}
</div>
</div> </div>
<div class="content">${this.content}</div>
${this.menuOptions.length > 0 ? html`
<div class="bottomButtons">
${this.menuOptions.map(
(actionArg, index) => html`
<div class="bottomButton ${index === this.menuOptions.length - 1 ? 'primary' : ''} ${actionArg.name === 'OK' ? 'ok' : ''}" @click=${() => {
actionArg.action(this);
}}>${actionArg.name}</div>
`
)}
</div>
` : ''}
</div> </div>
</div> </div>
`; `;
@ -210,5 +356,14 @@ export class DeesModal extends DeesElement {
await domtools.convenience.smartdelay.delayFor(200); await domtools.convenience.smartdelay.delayFor(200);
document.body.removeChild(this); document.body.removeChild(this);
await this.windowLayer.destroy(); await this.windowLayer.destroy();
// Unregister from z-index registry
zIndexRegistry.unregister(this);
}
private async handleHelp() {
if (this.onHelp) {
await this.onHelp();
}
} }
} }

View File

@ -4,7 +4,7 @@ import './dees-form.js';
import './dees-input-text.js'; import './dees-input-text.js';
import './dees-input-checkbox.js'; import './dees-input-checkbox.js';
import './dees-input-dropdown.js'; import './dees-input-dropdown.js';
import './dees-input-radio.js'; import './dees-input-radiogroup.js';
import './dees-form-submit.js'; import './dees-form-submit.js';
import './dees-statsgrid.js'; import './dees-statsgrid.js';
import type { IStatsTile } from './dees-statsgrid.js'; import type { IStatsTile } from './dees-statsgrid.js';
@ -230,13 +230,12 @@ class DemoViewSettings extends DeesElement {
<div class="settings-section"> <div class="settings-section">
<h2>Notification Settings</h2> <h2>Notification Settings</h2>
<dees-form> <dees-form>
<div style="margin-bottom: 16px;"> <dees-input-radiogroup
<div style="font-weight: 500; margin-bottom: 8px;">Email Frequency:</div> .label=${'Email Frequency'}
<dees-input-radio label="Real-time" value="true" key="email-realtime"></dees-input-radio> .options=${['Real-time', 'Daily Digest', 'Weekly Summary', 'Never']}
<dees-input-radio label="Daily Digest" key="email-daily"></dees-input-radio> .selectedOption=${'Real-time'}
<dees-input-radio label="Weekly Summary" key="email-weekly"></dees-input-radio> .key=${'emailFrequency'}
<dees-input-radio label="Never" key="email-never"></dees-input-radio> ></dees-input-radiogroup>
</div>
<dees-input-checkbox key="pushNotifications" label="Enable Push Notifications" value="true"></dees-input-checkbox> <dees-input-checkbox key="pushNotifications" label="Enable Push Notifications" value="true"></dees-input-checkbox>
<dees-input-checkbox key="soundAlerts" label="Play Sound for Alerts" value="true"></dees-input-checkbox> <dees-input-checkbox key="soundAlerts" label="Play Sound for Alerts" value="true"></dees-input-checkbox>
<dees-form-submit>Update Notifications</dees-form-submit> <dees-form-submit>Update Notifications</dees-form-submit>

View File

@ -1,6 +1,7 @@
import { customElement, DeesElement, type TemplateResult, html, css, property, cssManager } from '@design.estate/dees-element'; import { customElement, DeesElement, type TemplateResult, html, css, property, cssManager } from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools'; import * as domtools from '@design.estate/dees-domtools';
import { zIndexLayers } from './00zindex.js';
import { demoFunc } from './dees-toast.demo.js'; import { demoFunc } from './dees-toast.demo.js';
declare global { declare global {
@ -32,7 +33,7 @@ export class DeesToast extends DeesElement {
container.className = `toast-container toast-container-${position}`; container.className = `toast-container toast-container-${position}`;
container.style.cssText = ` container.style.cssText = `
position: fixed; position: fixed;
z-index: 10000; z-index: ${zIndexLayers.overlay.toast};
pointer-events: none; pointer-events: none;
padding: 16px; padding: 16px;
display: flex; display: flex;
@ -105,6 +106,11 @@ export class DeesToast extends DeesElement {
return toast; return toast;
} }
// Alias for consistency with DeesModal
public static async createAndShow(options: IToastOptions | string) {
return this.show(options);
}
// Convenience methods // Convenience methods
public static info(message: string, duration?: number) { public static info(message: string, duration?: number) {
return this.show({ message, type: 'info', duration }); return this.show({ message, type: 'info', duration });

View File

@ -1,4 +1,5 @@
import { customElement, DeesElement, domtools, type TemplateResult, html, property, type CSSResult, state, } from '@design.estate/dees-element'; import { customElement, DeesElement, domtools, type TemplateResult, html, property, type CSSResult, state, } from '@design.estate/dees-element';
import { zIndexLayers, zIndexRegistry } from './00zindex.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@ -32,6 +33,12 @@ export class DeesWindowLayer extends DeesElement {
public options: IOptions_DeesWindowLayer = { public options: IOptions_DeesWindowLayer = {
blur: false blur: false
}; };
@state()
private backdropZIndex: number = 1000;
@state()
private contentZIndex: number = 1001;
// INSTANCE // INSTANCE
@property({ @property({
@ -62,7 +69,7 @@ export class DeesWindowLayer extends DeesElement {
background: rgba(0, 0, 0, 0.0); background: rgba(0, 0, 0, 0.0);
backdrop-filter: brightness(1) ${this.options.blur ? 'blur(0px)' : ''}; backdrop-filter: brightness(1) ${this.options.blur ? 'blur(0px)' : ''};
pointer-events: none; pointer-events: none;
z-index: 200; z-index: ${this.backdropZIndex};
} }
.slotContent { .slotContent {
position: fixed; position: fixed;
@ -71,7 +78,12 @@ export class DeesWindowLayer extends DeesElement {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 201; z-index: ${this.contentZIndex};
pointer-events: none;
}
.slotContent > * {
pointer-events: auto;
} }
.visible { .visible {
@ -80,9 +92,9 @@ export class DeesWindowLayer extends DeesElement {
pointer-events: all; pointer-events: all;
} }
</style> </style>
<div class="windowOverlay ${this.visible ? 'visible' : null}"> <div @click=${this.dispatchClicked} class="windowOverlay ${this.visible ? 'visible' : null}">
</div> </div>
<div @click=${this.dispatchClicked} class="slotContent"> <div class="slotContent">
<slot></slot> <slot></slot>
</div> </div>
`; `;
@ -101,9 +113,21 @@ export class DeesWindowLayer extends DeesElement {
public toggleVisibility () { public toggleVisibility () {
this.visible = !this.visible; this.visible = !this.visible;
} }
public getContentZIndex(): number {
return this.contentZIndex;
}
public async show() { public async show() {
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
// Get z-indexes from registry
this.backdropZIndex = zIndexRegistry.getNextZIndex();
this.contentZIndex = zIndexRegistry.getNextZIndex();
// Register this element
zIndexRegistry.register(this, this.backdropZIndex);
await domtools.convenience.smartdelay.delayFor(0); await domtools.convenience.smartdelay.delayFor(0);
this.visible = true; this.visible = true;
} }
@ -118,6 +142,10 @@ export class DeesWindowLayer extends DeesElement {
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
await this.hide(); await this.hide();
await domtools.convenience.smartdelay.delayFor(300); await domtools.convenience.smartdelay.delayFor(300);
// Unregister from z-index registry
zIndexRegistry.unregister(this);
this.remove(); this.remove();
} }
} }

View File

@ -1,3 +1,4 @@
export * from './00zindex.js';
export * from './dees-appui-activitylog.js'; export * from './dees-appui-activitylog.js';
export * from './dees-appui-appbar.js'; export * from './dees-appui-appbar.js';
export * from './dees-appui-base.js'; export * from './dees-appui-base.js';
@ -31,9 +32,12 @@ export * from './dees-input-fileupload.js';
export * from './dees-input-iban.js'; export * from './dees-input-iban.js';
export * from './dees-input-typelist.js'; export * from './dees-input-typelist.js';
export * from './dees-input-phone.js'; export * from './dees-input-phone.js';
export * from './dees-input-wysiwyg.js';
export * from './dees-progressbar.js'; export * from './dees-progressbar.js';
export * from './dees-input-quantityselector.js'; export * from './dees-input-quantityselector.js';
export * from './dees-input-radio.js'; export * from './dees-input-radiogroup.js';
export * from './dees-input-richtext.js';
export * from './dees-input-tags.js';
export * from './dees-input-text.js'; export * from './dees-input-text.js';
export * from './dees-label.js'; export * from './dees-label.js';
export * from './dees-mobilenavigation.js'; export * from './dees-mobilenavigation.js';

View File

@ -0,0 +1,65 @@
# WYSIWYG Block Cleanup Status
## Overview
This document tracks the cleanup of `dees-wysiwyg-block.ts` after migrating all block types to the new block handler architecture.
## Completed ✅
All cleanup tasks have been successfully completed on 2025-06-26.
## Cleanup Tasks
### 1. ✅ Remove Block-Specific Styles (lines 101-219)
- [x] Remove `.block.heading-1/2/3` styles → Now in `heading.block.ts`
- [x] Remove `.block.quote` styles → Now in `quote.block.ts`
- [x] Remove `.block.list` styles → Now in `list.block.ts`
- [x] Remove `.block.paragraph` styles → Now in `paragraph.block.ts`
### 2. ✅ Remove Code Block Specific Logic
- [x] Remove code block rendering in `renderBlockContent()` (lines 508-521)
- [x] Remove all `type === 'code'` conditional branches
- [x] Simplify element selection to not special-case code blocks
### 3. ✅ Remove List Block Specific Logic
- [x] Remove `focusListItem()` method (lines 814-821)
- [x] Remove list-specific handling in `getContent()` (lines 732-734)
- [x] Remove list-specific handling in `setContent()` (lines 764-765)
- [x] Remove list content rendering in `firstUpdated()` (line 479)
### 4. ✅ Remove getPlaceholder() Method
- [x] Remove entire method (lines 538-553)
- [x] Update renderBlockContent() to not use placeholders
### 5. ✅ Clean Up Excessive Empty Lines
- [x] Remove consecutive blank lines throughout the file
### 6. ✅ Centralize nonEditableTypes
- [x] Create a single source of truth for non-editable block types
- [x] Remove duplicate arrays
### 7. ✅ Simplify Handler Delegation
- [x] Keep handler delegation pattern but ensure consistency
### 8. ✅ Remove Unused Properties (if confirmed unused)
- [x] Keep `contentInitialized` - still used for tracking
- [x] Keep `blockElement` - used for caching
- [x] Keep cursor tracking properties - used for selection
## Implementation Notes
### Block Types Now Fully Handled by Handlers:
1. **Text blocks**: paragraph, heading-1/2/3, quote, code, list
2. **Media blocks**: image, youtube, attachment
3. **Content blocks**: divider, markdown, html
### Remaining Responsibilities of dees-wysiwyg-block.ts:
1. Shadow DOM container management
2. Handler delegation for all operations
3. Generic block wrapper styles
4. Selection/cursor tracking
5. Event listener setup (until fully delegated to handlers)
## Future Improvements
- Consider moving all event handling to block handlers
- Simplify the handler delegation pattern
- Move generic block styles to a shared location
- Consider removing the need for special-casing any block types

View File

@ -0,0 +1,87 @@
# WYSIWYG Block Migration Status
## Overview
This document tracks the progress of migrating all WYSIWYG blocks to the new block handler architecture.
## Migration Progress
### ✅ Phase 1: Architecture Foundation
- Created block handler base classes and interfaces
- Created block registry system
- Created common block styles and utilities
### ✅ Phase 2: Divider Block
- Simple non-editable block as proof of concept
- See `phase2-summary.md` for details
### ✅ Phase 3: Paragraph Block
- First text block with full editing capabilities
- Established patterns for text selection, cursor tracking, and content splitting
- See commit history for implementation details
### ✅ Phase 4: Heading Blocks
- All three heading levels (h1, h2, h3) using unified handler
- See `phase4-summary.md` for details
### ✅ Phase 5: Other Text Blocks
- [x] Quote block - Completed with custom styling
- [x] Code block - Completed with syntax highlighting, line numbers, and copy button
- [x] List block - Completed with bullet and numbered list support
### 🔄 Phase 6: Media Blocks (In Progress)
- [x] Image block - Completed with click upload, drag-drop, and base64 encoding
- [x] YouTube block - Completed with URL parsing and video embedding
- [ ] Attachment block
### 📋 Phase 7: Content Blocks (Planned)
- [ ] Markdown block
- [ ] HTML block
## Block Handler Status
| Block Type | Handler Created | Registered | Tested | Notes |
|------------|----------------|------------|---------|-------|
| divider | ✅ | ✅ | ✅ | Complete |
| paragraph | ✅ | ✅ | ✅ | Complete |
| heading-1 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
| heading-2 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
| heading-3 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
| quote | ✅ | ✅ | ✅ | Complete with custom styling |
| code | ✅ | ✅ | ✅ | Complete with highlighting, line numbers, copy |
| list | ✅ | ✅ | ✅ | Complete with bullet/numbered support |
| image | ✅ | ✅ | ✅ | Complete with upload, drag-drop support |
| youtube | ✅ | ✅ | ✅ | Complete with URL parsing, video embedding |
| attachment | ❌ | ❌ | ❌ | Phase 6 |
| markdown | ❌ | ❌ | ❌ | Phase 7 |
| html | ❌ | ❌ | ❌ | Phase 7 |
## Files Modified During Migration
### Core Architecture Files
- `blocks/block.base.ts` - Base handler interface and class
- `blocks/block.registry.ts` - Registry for handlers
- `blocks/block.styles.ts` - Common styles
- `blocks/index.ts` - Main exports
- `wysiwyg.blockregistration.ts` - Registration of all handlers
### Handler Files Created
- `blocks/content/divider.block.ts`
- `blocks/text/paragraph.block.ts`
- `blocks/text/heading.block.ts`
- `blocks/text/quote.block.ts`
- `blocks/text/code.block.ts`
- `blocks/text/list.block.ts`
- `blocks/media/image.block.ts`
- `blocks/media/youtube.block.ts`
### Main Component Updates
- `dees-wysiwyg-block.ts` - Updated to use registry pattern
## Next Steps
1. Begin Phase 6: Media blocks migration
- Start with image block (most common media type)
- Implement YouTube block for video embedding
- Create attachment block for file uploads
2. Follow established patterns from existing handlers
3. Test thoroughly after each migration
4. Update documentation as blocks are completed

View File

@ -0,0 +1,294 @@
# Critical WYSIWYG Knowledge - DO NOT LOSE
This document captures all the hard-won knowledge from our WYSIWYG editor development. These patterns and solutions took significant effort to discover and MUST be preserved during refactoring.
## 1. Static Rendering to Prevent Focus Loss
### Problem
When using Lit's reactive rendering, every state change would cause a re-render, which would:
- Lose cursor position
- Lose focus state
- Interrupt typing
- Break IME (Input Method Editor) support
### Solution
We render blocks **statically** and manage updates imperatively:
```typescript
// In dees-wysiwyg-block.ts
render(): TemplateResult {
if (!this.block) return html``;
// Render empty container - content set in firstUpdated
return html`<div class="wysiwyg-block-container"></div>`;
}
firstUpdated(): void {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container');
if (container && this.block) {
container.innerHTML = this.renderBlockContent();
}
}
```
### Critical Pattern
- NEVER use reactive properties that would trigger re-renders during typing
- Use `shouldUpdate()` to prevent unnecessary renders
- Manage content updates imperatively through event handlers
## 2. Shadow DOM Selection Handling
### Problem
The Web Selection API doesn't work across Shadow DOM boundaries by default. This broke:
- Text selection
- Cursor position tracking
- Formatting detection
- Content splitting for Enter key
### Solution
Use the `getComposedRanges` API with all relevant shadow roots:
```typescript
// From paragraph.block.ts
const wysiwygBlock = element.closest('dees-wysiwyg-block');
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = (wysiwygBlock as any)?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
```
### Critical Pattern
- ALWAYS collect all shadow roots in the hierarchy
- Use `WysiwygSelection` utility methods that handle shadow DOM
- Never use raw `window.getSelection()` without shadow root context
## 3. Cursor Position Tracking
### Problem
Cursor position would be lost during various operations, making it impossible to:
- Split content at the right position for Enter key
- Restore cursor after operations
- Track position for formatting
### Solution
Track cursor position through multiple events and maintain `lastKnownCursorPosition`:
```typescript
// Track on every relevant event
private lastKnownCursorPosition: number = 0;
// In event handlers:
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Fallback when selection not available:
if (!selectionInfo && this.lastKnownCursorPosition !== null) {
// Use last known position
}
```
### Critical Events to Track
- `input` - After text changes
- `keydown` - Before key press
- `keyup` - After key press
- `mouseup` - After mouse selection
- `click` - With setTimeout(0) for browser to set cursor
## 4. Content Splitting for Enter Key
### Problem
Splitting content at cursor position while preserving HTML formatting was complex due to:
- Need to preserve formatting tags
- Shadow DOM complications
- Cursor position accuracy
### Solution
Use Range API to split content while preserving HTML:
```typescript
getSplitContent(): { before: string; after: string } | null {
// Create ranges for before and after cursor
const beforeRange = document.createRange();
const afterRange = document.createRange();
beforeRange.setStart(element, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(element, element.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
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 };
}
```
## 5. Focus Management
### Problem
Focus would be lost or not properly set due to:
- Timing issues with DOM updates
- Shadow DOM complications
- Browser inconsistencies
### Solution
Use defensive focus management with fallbacks:
```typescript
focus(element: HTMLElement): void {
const block = element.querySelector('.block');
if (!block) return;
// Ensure focusable
if (!block.hasAttribute('contenteditable')) {
block.setAttribute('contenteditable', 'true');
}
block.focus();
// Fallback with microtask if focus failed
if (document.activeElement !== block && element.shadowRoot?.activeElement !== block) {
Promise.resolve().then(() => {
block.focus();
});
}
}
```
## 6. Selection Event Handling for Formatting
### Problem
Need to show formatting menu when text is selected, but selection events don't bubble across Shadow DOM.
### Solution
Dispatch custom events with selection information:
```typescript
// Listen for selection changes
document.addEventListener('selectionchange', () => {
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (selectedText !== this.lastSelectedText) {
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch custom event
this.dispatchSelectionEvent(element, {
text: selectedText,
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
});
// Custom event bubbles through Shadow DOM
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
```
## 7. IME (Input Method Editor) Support
### Problem
Composition events for non-Latin input methods would break without proper handling.
### Solution
Track composition state and handle events:
```typescript
// In dees-input-wysiwyg.ts
public isComposing: boolean = false;
// In block handlers
element.addEventListener('compositionstart', () => {
handlers.onCompositionStart(); // Sets isComposing = true
});
element.addEventListener('compositionend', () => {
handlers.onCompositionEnd(); // Sets isComposing = false
});
// Don't process certain operations during composition
if (this.isComposing) return;
```
## 8. Programmatic Rendering
### Problem
Lit's declarative rendering would cause focus loss and performance issues with many blocks.
### Solution
Render blocks programmatically:
```typescript
public renderBlocksProgrammatically() {
if (!this.editorContentRef) return;
// Clear existing blocks
this.editorContentRef.innerHTML = '';
// Create and append block elements
this.blocks.forEach(block => {
const blockWrapper = this.createBlockElement(block);
this.editorContentRef.appendChild(blockWrapper);
});
}
```
## 9. Block Handler Architecture Requirements
When creating new block handlers, they MUST:
1. **Implement all cursor/selection methods** even if not applicable
2. **Use Shadow DOM-aware selection utilities**
3. **Track cursor position through events**
4. **Handle focus with fallbacks**
5. **Preserve HTML content when getting/setting**
6. **Dispatch selection events for formatting**
7. **Support IME composition events**
8. **Clean up event listeners on disconnect**
## 10. Testing Considerations
### webhelpers.fixture() Issue
The test helper `webhelpers.fixture()` triggers property changes during initialization that can cause null reference errors. Always:
1. Check for null/undefined before accessing nested properties
2. Set required properties in specific order when testing
3. Consider manual element creation for complex test scenarios
## Summary
These patterns represent hours of debugging and problem-solving. When refactoring:
1. **NEVER** remove static rendering approach
2. **ALWAYS** use Shadow DOM-aware selection utilities
3. **MAINTAIN** cursor position tracking through all events
4. **PRESERVE** the complex content splitting logic
5. **KEEP** all focus management fallbacks
6. **ENSURE** selection events bubble through Shadow DOM
7. **SUPPORT** IME composition events
8. **TEST** thoroughly with actual typing, not just unit tests
Any changes that break these patterns will result in a degraded user experience that took significant effort to achieve.

View File

@ -0,0 +1,44 @@
import type { IBlock } from '../wysiwyg.types.js';
import type { IBlockEventHandlers } from '../wysiwyg.interfaces.js';
// Re-export types from the interfaces
export type { IBlockEventHandlers } from '../wysiwyg.interfaces.js';
export interface IBlockContext {
shadowRoot: ShadowRoot;
component: any; // Reference to the wysiwyg-block component
}
export interface IBlockHandler {
type: string;
render(block: IBlock, isSelected: boolean): string;
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void;
getStyles(): string;
getPlaceholder?(): string;
// Optional methods for editable blocks - now with context
getContent?(element: HTMLElement, context?: IBlockContext): string;
setContent?(element: HTMLElement, content: string, context?: IBlockContext): void;
getCursorPosition?(element: HTMLElement, context?: IBlockContext): number | null;
setCursorToStart?(element: HTMLElement, context?: IBlockContext): void;
setCursorToEnd?(element: HTMLElement, context?: IBlockContext): void;
focus?(element: HTMLElement, context?: IBlockContext): void;
focusWithCursor?(element: HTMLElement, position: 'start' | 'end' | number, context?: IBlockContext): void;
getSplitContent?(element: HTMLElement, context?: IBlockContext): { before: string; after: string } | null;
}
export abstract class BaseBlockHandler implements IBlockHandler {
abstract type: string;
abstract render(block: IBlock, isSelected: boolean): string;
// Default implementation for common setup
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
// Common setup logic
}
// Common styles can be defined here
getStyles(): string {
return '';
}
}

View File

@ -0,0 +1,17 @@
import type { IBlockHandler } from './block.base.js';
export class BlockRegistry {
private static handlers = new Map<string, IBlockHandler>();
static register(type: string, handler: IBlockHandler): void {
this.handlers.set(type, handler);
}
static getHandler(type: string): IBlockHandler | undefined {
return this.handlers.get(type);
}
static getAllTypes(): string[] {
return Array.from(this.handlers.keys());
}
}

View File

@ -0,0 +1,64 @@
/**
* Common styles shared across all block types
*/
export const commonBlockStyles = `
/* Common block spacing and layout */
/* TODO: Extract common spacing from existing blocks */
/* Common focus states */
/* TODO: Extract common focus styles */
/* Common selected states */
/* TODO: Extract common selection styles */
/* Common hover states */
/* TODO: Extract common hover styles */
/* Common transition effects */
/* TODO: Extract common transitions */
/* Common placeholder styles */
/* TODO: Extract common placeholder styles */
/* Common error states */
/* TODO: Extract common error styles */
/* Common loading states */
/* TODO: Extract common loading styles */
`;
/**
* Helper function to generate consistent block classes
*/
export const getBlockClasses = (
type: string,
isSelected: boolean,
additionalClasses: string[] = []
): string => {
const classes = ['block', type];
if (isSelected) {
classes.push('selected');
}
classes.push(...additionalClasses);
return classes.join(' ');
};
/**
* Helper function to generate consistent data attributes
*/
export const getBlockDataAttributes = (
blockId: string,
blockType: string,
additionalAttributes: Record<string, string> = {}
): string => {
const attributes = {
'data-block-id': blockId,
'data-block-type': blockType,
...additionalAttributes
};
return Object.entries(attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
};

View File

@ -0,0 +1,80 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
export class DividerBlockHandler extends BaseBlockHandler {
type = 'divider';
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
return `
<div class="block divider${selectedClass}" data-block-id="${block.id}" data-block-type="${block.type}" tabindex="0">
<hr>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const dividerBlock = element.querySelector('.block.divider') as HTMLDivElement;
if (!dividerBlock) return;
// Handle click to select
dividerBlock.addEventListener('click', (e) => {
e.stopPropagation();
// Focus will trigger the selection
dividerBlock.focus();
// Ensure focus handler is called immediately
handlers.onFocus?.();
});
// Handle focus/blur
dividerBlock.addEventListener('focus', () => {
handlers.onFocus?.();
});
dividerBlock.addEventListener('blur', () => {
handlers.onBlur?.();
});
// Handle keyboard events
dividerBlock.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
// Let the keyboard handler in the parent component handle the deletion
handlers.onKeyDown?.(e);
} else {
// Handle navigation keys
handlers.onKeyDown?.(e);
}
});
}
getStyles(): string {
return `
.block.divider {
padding: 8px 0;
margin: 16px 0;
cursor: pointer;
position: relative;
border-radius: 4px;
transition: all 0.15s ease;
}
.block.divider:focus {
outline: none;
}
.block.divider.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)')};
}
.block.divider hr {
border: none;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
margin: 0;
pointer-events: none;
}
`;
}
}

View File

@ -0,0 +1,519 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* HTMLBlockHandler - Handles raw HTML content with preview/edit toggle
*
* Features:
* - Live HTML preview (sandboxed)
* - Edit/preview mode toggle
* - Syntax highlighting in edit mode
* - HTML validation hints
* - Auto-save on mode switch
*/
export class HtmlBlockHandler extends BaseBlockHandler {
type = 'html';
render(block: IBlock, isSelected: boolean): string {
const isEditMode = block.metadata?.isEditMode ?? true;
const content = block.content || '';
return `
<div class="html-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
data-edit-mode="${isEditMode}">
<div class="html-header">
<div class="html-icon">&lt;/&gt;</div>
<div class="html-title">HTML</div>
<button class="html-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
${isEditMode ? '👁️' : '✏️'}
</button>
</div>
<div class="html-content">
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
</div>
</div>
`;
}
private renderEditor(content: string): string {
return `
<textarea class="html-editor"
placeholder="Enter HTML content..."
spellcheck="false">${this.escapeHtml(content)}</textarea>
`;
}
private renderPreview(content: string): string {
return `
<div class="html-preview">
${content || '<div class="preview-empty">No content to preview</div>'}
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.html-block-container') as HTMLElement;
const toggleBtn = element.querySelector('.html-toggle-mode') as HTMLButtonElement;
if (!container || !toggleBtn) {
console.error('HtmlBlockHandler: Could not find required elements');
return;
}
// Initialize metadata
if (!block.metadata) block.metadata = {};
if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true;
// Toggle mode button
toggleBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Save current content if in edit mode
if (block.metadata.isEditMode) {
const editor = container.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
block.content = editor.value;
}
}
// Toggle mode
block.metadata.isEditMode = !block.metadata.isEditMode;
// Request UI update
handlers.onRequestUpdate?.();
});
// Setup based on mode
if (block.metadata.isEditMode) {
this.setupEditor(element, block, handlers);
} else {
this.setupPreview(element, block, handlers);
}
}
private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (!editor) return;
// Focus handling
editor.addEventListener('focus', () => handlers.onFocus());
editor.addEventListener('blur', () => handlers.onBlur());
// Content changes
editor.addEventListener('input', () => {
block.content = editor.value;
this.validateHtml(editor.value);
});
// Keyboard shortcuts
editor.addEventListener('keydown', (e) => {
// Tab handling for indentation
if (e.key === 'Tab') {
e.preventDefault();
const start = editor.selectionStart;
const end = editor.selectionEnd;
const value = editor.value;
if (e.shiftKey) {
// Unindent
const beforeCursor = value.substring(0, start);
const lastNewline = beforeCursor.lastIndexOf('\n');
const lineStart = lastNewline + 1;
const lineContent = value.substring(lineStart, start);
if (lineContent.startsWith(' ')) {
editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start);
editor.selectionStart = editor.selectionEnd = start - 2;
}
} else {
// Indent
editor.value = value.substring(0, start) + ' ' + value.substring(end);
editor.selectionStart = editor.selectionEnd = start + 2;
}
block.content = editor.value;
return;
}
// Auto-close tags (Ctrl/Cmd + /)
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
this.autoCloseTag(editor);
block.content = editor.value;
return;
}
// Pass other key events to handlers
handlers.onKeyDown(e);
});
// Auto-resize
this.autoResize(editor);
editor.addEventListener('input', () => this.autoResize(editor));
}
private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.html-block-container') as HTMLElement;
const preview = element.querySelector('.html-preview') as HTMLElement;
if (!container || !preview) return;
// Make preview focusable
preview.setAttribute('tabindex', '0');
// Focus handling
preview.addEventListener('focus', () => handlers.onFocus());
preview.addEventListener('blur', () => handlers.onBlur());
// Keyboard navigation
preview.addEventListener('keydown', (e) => {
// Switch to edit mode on Enter
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
block.metadata.isEditMode = true;
handlers.onRequestUpdate?.();
return;
}
handlers.onKeyDown(e);
});
// Sandbox styles and scripts in preview
this.sandboxContent(preview);
}
private autoCloseTag(editor: HTMLTextAreaElement): void {
const cursorPos = editor.selectionStart;
const text = editor.value;
// Find the opening tag
let tagStart = cursorPos;
while (tagStart > 0 && text[tagStart - 1] !== '<') {
tagStart--;
}
if (tagStart > 0) {
const tagContent = text.substring(tagStart, cursorPos);
const tagMatch = tagContent.match(/^(\w+)/);
if (tagMatch) {
const tagName = tagMatch[1];
const closingTag = `</${tagName}>`;
// Insert closing tag
editor.value = text.substring(0, cursorPos) + '>' + closingTag + text.substring(cursorPos);
editor.selectionStart = editor.selectionEnd = cursorPos + 1;
}
}
}
private autoResize(editor: HTMLTextAreaElement): void {
editor.style.height = 'auto';
editor.style.height = editor.scrollHeight + 'px';
}
private validateHtml(html: string): boolean {
// Basic HTML validation
const openTags: string[] = [];
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g;
let match;
while ((match = tagRegex.exec(html)) !== null) {
const isClosing = match[0].startsWith('</');
const tagName = match[1].toLowerCase();
if (isClosing) {
if (openTags.length === 0 || openTags[openTags.length - 1] !== tagName) {
console.warn(`Mismatched closing tag: ${tagName}`);
return false;
}
openTags.pop();
} else if (!match[0].endsWith('/>')) {
// Not a self-closing tag
openTags.push(tagName);
}
}
if (openTags.length > 0) {
console.warn(`Unclosed tags: ${openTags.join(', ')}`);
return false;
}
return true;
}
private sandboxContent(preview: HTMLElement): void {
// Remove any script tags
const scripts = preview.querySelectorAll('script');
scripts.forEach(script => script.remove());
// Remove event handlers
const allElements = preview.querySelectorAll('*');
allElements.forEach(el => {
// Remove all on* attributes
Array.from(el.attributes).forEach(attr => {
if (attr.name.startsWith('on')) {
el.removeAttribute(attr.name);
}
});
});
// Prevent forms from submitting
const forms = preview.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
});
});
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
return editor.value;
}
// If in preview mode, return the stored content
const container = element.querySelector('.html-block-container');
const blockId = container?.getAttribute('data-block-id');
// In real implementation, would need access to block data
return '';
}
setContent(element: HTMLElement, content: string): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
editor.value = content;
this.autoResize(editor);
}
}
getCursorPosition(element: HTMLElement): number | null {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
return editor ? editor.selectionStart : null;
}
setCursorToStart(element: HTMLElement): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
editor.selectionStart = editor.selectionEnd = 0;
editor.focus();
} else {
this.focus(element);
}
}
setCursorToEnd(element: HTMLElement): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
const length = editor.value.length;
editor.selectionStart = editor.selectionEnd = length;
editor.focus();
} else {
this.focus(element);
}
}
focus(element: HTMLElement): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
editor.focus();
} else {
const preview = element.querySelector('.html-preview') as HTMLElement;
preview?.focus();
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
if (position === 'start') {
this.setCursorToStart(element);
} else if (position === 'end') {
this.setCursorToEnd(element);
} else if (typeof position === 'number') {
editor.selectionStart = editor.selectionEnd = position;
editor.focus();
}
} else {
this.focus(element);
}
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
if (!editor) return null;
const cursorPos = editor.selectionStart;
return {
before: editor.value.substring(0, cursorPos),
after: editor.value.substring(cursorPos)
};
}
getStyles(): string {
return `
/* HTML Block Container */
.html-block-container {
position: relative;
margin: 12px 0;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
background: ${cssManager.bdTheme('#ffffff', '#111827')};
}
.html-block-container.selected {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Header */
.html-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
}
.html-icon {
font-size: 14px;
font-weight: 600;
opacity: 0.8;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
}
.html-title {
flex: 1;
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.html-toggle-mode {
padding: 4px 8px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.html-toggle-mode:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
}
/* Content */
.html-content {
position: relative;
min-height: 120px;
}
/* Editor */
.html-editor {
width: 100%;
min-height: 120px;
padding: 12px;
background: transparent;
border: none;
outline: none;
resize: none;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
overflow: hidden;
}
.html-editor::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Preview */
.html-preview {
padding: 12px;
min-height: 96px;
outline: none;
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
}
.preview-empty {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
font-style: italic;
}
/* Sandboxed HTML preview styles */
.html-preview * {
max-width: 100%;
}
.html-preview img {
height: auto;
}
.html-preview a {
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
text-decoration: none;
}
.html-preview a:hover {
text-decoration: underline;
}
.html-preview table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
}
.html-preview th,
.html-preview td {
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
padding: 8px;
text-align: left;
}
.html-preview th {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
font-weight: 600;
}
.html-preview pre {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
padding: 12px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
}
.html-preview code {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
.html-preview pre code {
background: transparent;
padding: 0;
}
`;
}
}

View File

@ -0,0 +1,562 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* MarkdownBlockHandler - Handles markdown content with preview/edit toggle
*
* Features:
* - Live markdown preview
* - Edit/preview mode toggle
* - Syntax highlighting in edit mode
* - Common markdown shortcuts
* - Auto-save on mode switch
*/
export class MarkdownBlockHandler extends BaseBlockHandler {
type = 'markdown';
render(block: IBlock, isSelected: boolean): string {
const isEditMode = block.metadata?.isEditMode ?? true;
const content = block.content || '';
return `
<div class="markdown-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
data-edit-mode="${isEditMode}">
<div class="markdown-header">
<div class="markdown-icon">M↓</div>
<div class="markdown-title">Markdown</div>
<button class="markdown-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
${isEditMode ? '👁️' : '✏️'}
</button>
</div>
<div class="markdown-content">
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
</div>
</div>
`;
}
private renderEditor(content: string): string {
return `
<textarea class="markdown-editor"
placeholder="Enter markdown content..."
spellcheck="false">${this.escapeHtml(content)}</textarea>
`;
}
private renderPreview(content: string): string {
const html = this.parseMarkdown(content);
return `
<div class="markdown-preview">
${html || '<div class="preview-empty">No content to preview</div>'}
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.markdown-block-container') as HTMLElement;
const toggleBtn = element.querySelector('.markdown-toggle-mode') as HTMLButtonElement;
if (!container || !toggleBtn) {
console.error('MarkdownBlockHandler: Could not find required elements');
return;
}
// Initialize metadata
if (!block.metadata) block.metadata = {};
if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true;
// Toggle mode button
toggleBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Save current content if in edit mode
if (block.metadata.isEditMode) {
const editor = container.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
block.content = editor.value;
}
}
// Toggle mode
block.metadata.isEditMode = !block.metadata.isEditMode;
// Request UI update
handlers.onRequestUpdate?.();
});
// Setup based on mode
if (block.metadata.isEditMode) {
this.setupEditor(element, block, handlers);
} else {
this.setupPreview(element, block, handlers);
}
}
private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (!editor) return;
// Focus handling
editor.addEventListener('focus', () => handlers.onFocus());
editor.addEventListener('blur', () => handlers.onBlur());
// Content changes
editor.addEventListener('input', () => {
block.content = editor.value;
});
// Keyboard shortcuts
editor.addEventListener('keydown', (e) => {
// Tab handling for indentation
if (e.key === 'Tab') {
e.preventDefault();
const start = editor.selectionStart;
const end = editor.selectionEnd;
const value = editor.value;
if (e.shiftKey) {
// Unindent
const beforeCursor = value.substring(0, start);
const lastNewline = beforeCursor.lastIndexOf('\n');
const lineStart = lastNewline + 1;
const lineContent = value.substring(lineStart, start);
if (lineContent.startsWith(' ')) {
editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start);
editor.selectionStart = editor.selectionEnd = start - 2;
}
} else {
// Indent
editor.value = value.substring(0, start) + ' ' + value.substring(end);
editor.selectionStart = editor.selectionEnd = start + 2;
}
block.content = editor.value;
return;
}
// Bold shortcut (Ctrl/Cmd + B)
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault();
this.wrapSelection(editor, '**', '**');
block.content = editor.value;
return;
}
// Italic shortcut (Ctrl/Cmd + I)
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
e.preventDefault();
this.wrapSelection(editor, '_', '_');
block.content = editor.value;
return;
}
// Link shortcut (Ctrl/Cmd + K)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.insertLink(editor);
block.content = editor.value;
return;
}
// Pass other key events to handlers
handlers.onKeyDown(e);
});
// Auto-resize
this.autoResize(editor);
editor.addEventListener('input', () => this.autoResize(editor));
}
private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.markdown-block-container') as HTMLElement;
const preview = element.querySelector('.markdown-preview') as HTMLElement;
if (!container || !preview) return;
// Make preview focusable
preview.setAttribute('tabindex', '0');
// Focus handling
preview.addEventListener('focus', () => handlers.onFocus());
preview.addEventListener('blur', () => handlers.onBlur());
// Keyboard navigation
preview.addEventListener('keydown', (e) => {
// Switch to edit mode on Enter
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
block.metadata.isEditMode = true;
handlers.onRequestUpdate?.();
return;
}
handlers.onKeyDown(e);
});
}
private wrapSelection(editor: HTMLTextAreaElement, before: string, after: string): void {
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = editor.value.substring(start, end);
const replacement = before + (selectedText || 'text') + after;
editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
if (selectedText) {
editor.selectionStart = start;
editor.selectionEnd = start + replacement.length;
} else {
editor.selectionStart = start + before.length;
editor.selectionEnd = start + before.length + 4; // 'text'.length
}
editor.focus();
}
private insertLink(editor: HTMLTextAreaElement): void {
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = editor.value.substring(start, end);
const linkText = selectedText || 'link text';
const replacement = `[${linkText}](url)`;
editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
// Select the URL part
editor.selectionStart = start + linkText.length + 3; // '[linktext]('.length
editor.selectionEnd = start + linkText.length + 6; // '[linktext](url'.length
editor.focus();
}
private autoResize(editor: HTMLTextAreaElement): void {
editor.style.height = 'auto';
editor.style.height = editor.scrollHeight + 'px';
}
private parseMarkdown(markdown: string): string {
// Basic markdown parsing - in production, use a proper markdown parser
let html = this.escapeHtml(markdown);
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Code blocks
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// Inline code
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// Lists
html = html.replace(/^\* (.+)$/gm, '<li>$1</li>');
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
// Wrap consecutive list items
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => {
return '<ul>' + match + '</ul>';
});
// Paragraphs
html = html.replace(/\n\n/g, '</p><p>');
html = '<p>' + html + '</p>';
// Clean up empty paragraphs
html = html.replace(/<p><\/p>/g, '');
html = html.replace(/<p>(<h[1-3]>)/g, '$1');
html = html.replace(/(<\/h[1-3]>)<\/p>/g, '$1');
html = html.replace(/<p>(<ul>)/g, '$1');
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
html = html.replace(/<p>(<pre>)/g, '$1');
html = html.replace(/(<\/pre>)<\/p>/g, '$1');
return html;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
return editor.value;
}
// If in preview mode, return the stored content
const container = element.querySelector('.markdown-block-container');
const blockId = container?.getAttribute('data-block-id');
// In real implementation, would need access to block data
return '';
}
setContent(element: HTMLElement, content: string): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
editor.value = content;
this.autoResize(editor);
}
}
getCursorPosition(element: HTMLElement): number | null {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
return editor ? editor.selectionStart : null;
}
setCursorToStart(element: HTMLElement): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
editor.selectionStart = editor.selectionEnd = 0;
editor.focus();
} else {
this.focus(element);
}
}
setCursorToEnd(element: HTMLElement): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
const length = editor.value.length;
editor.selectionStart = editor.selectionEnd = length;
editor.focus();
} else {
this.focus(element);
}
}
focus(element: HTMLElement): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
editor.focus();
} else {
const preview = element.querySelector('.markdown-preview') as HTMLElement;
preview?.focus();
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
if (position === 'start') {
this.setCursorToStart(element);
} else if (position === 'end') {
this.setCursorToEnd(element);
} else if (typeof position === 'number') {
editor.selectionStart = editor.selectionEnd = position;
editor.focus();
}
} else {
this.focus(element);
}
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (!editor) return null;
const cursorPos = editor.selectionStart;
return {
before: editor.value.substring(0, cursorPos),
after: editor.value.substring(cursorPos)
};
}
getStyles(): string {
return `
/* Markdown Block Container */
.markdown-block-container {
position: relative;
margin: 12px 0;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
background: ${cssManager.bdTheme('#ffffff', '#111827')};
}
.markdown-block-container.selected {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Header */
.markdown-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
}
.markdown-icon {
font-size: 14px;
font-weight: 600;
opacity: 0.8;
}
.markdown-title {
flex: 1;
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.markdown-toggle-mode {
padding: 4px 8px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.markdown-toggle-mode:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
}
/* Content */
.markdown-content {
position: relative;
min-height: 120px;
}
/* Editor */
.markdown-editor {
width: 100%;
min-height: 120px;
padding: 12px;
background: transparent;
border: none;
outline: none;
resize: none;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
overflow: hidden;
}
.markdown-editor::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Preview */
.markdown-preview {
padding: 12px;
min-height: 96px;
outline: none;
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
}
.preview-empty {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
font-style: italic;
}
/* Markdown preview styles */
.markdown-preview h1 {
font-size: 24px;
font-weight: 600;
margin: 16px 0 8px 0;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
.markdown-preview h2 {
font-size: 20px;
font-weight: 600;
margin: 14px 0 6px 0;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
.markdown-preview h3 {
font-size: 18px;
font-weight: 600;
margin: 12px 0 4px 0;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
.markdown-preview p {
margin: 8px 0;
}
.markdown-preview ul,
.markdown-preview ol {
margin: 8px 0;
padding-left: 24px;
}
.markdown-preview li {
margin: 4px 0;
}
.markdown-preview code {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
.markdown-preview pre {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
padding: 12px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
}
.markdown-preview pre code {
background: transparent;
padding: 0;
}
.markdown-preview strong {
font-weight: 600;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
}
.markdown-preview em {
font-style: italic;
}
.markdown-preview a {
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
text-decoration: none;
}
.markdown-preview a:hover {
text-decoration: underline;
}
.markdown-preview blockquote {
border-left: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
padding-left: 12px;
margin: 8px 0;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
`;
}
}

View File

@ -0,0 +1,43 @@
/**
* Main exports for the blocks module
*/
// Core interfaces and base classes
export {
type IBlockHandler,
type IBlockEventHandlers,
BaseBlockHandler
} from './block.base.js';
// Block registry for registration and retrieval
export { BlockRegistry } from './block.registry.js';
// Common styles and helpers
export {
commonBlockStyles,
getBlockClasses,
getBlockDataAttributes
} from './block.styles.js';
// Text block handlers
export { ParagraphBlockHandler } from './text/paragraph.block.js';
export { HeadingBlockHandler } from './text/heading.block.js';
export { QuoteBlockHandler } from './text/quote.block.js';
export { CodeBlockHandler } from './text/code.block.js';
export { ListBlockHandler } from './text/list.block.js';
// Media block handlers
export { ImageBlockHandler } from './media/image.block.js';
export { YouTubeBlockHandler } from './media/youtube.block.js';
export { AttachmentBlockHandler } from './media/attachment.block.js';
// Content block handlers
export { DividerBlockHandler } from './content/divider.block.js';
export { MarkdownBlockHandler } from './content/markdown.block.js';
export { HtmlBlockHandler } from './content/html.block.js';
// Utilities
// TODO: Export when implemented
// export * from './utils/file.utils.js';
// export * from './utils/media.utils.js';
// export * from './utils/markdown.utils.js';

View File

@ -0,0 +1,477 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* AttachmentBlockHandler - Handles file attachments
*
* Features:
* - Multiple file upload support
* - Click to upload or drag and drop
* - File type icons
* - Remove individual files
* - Base64 encoding (TODO: server upload in production)
*/
export class AttachmentBlockHandler extends BaseBlockHandler {
type = 'attachment';
render(block: IBlock, isSelected: boolean): string {
const files = block.metadata?.files || [];
return `
<div class="attachment-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
tabindex="0">
<div class="attachment-header">
<div class="attachment-icon">📎</div>
<div class="attachment-title">File Attachments</div>
</div>
<div class="attachment-list">
${files.length > 0 ? this.renderFiles(files) : this.renderPlaceholder()}
</div>
<input type="file"
class="attachment-file-input"
multiple
style="display: none;" />
${files.length > 0 ? '<button class="add-more-files">Add More Files</button>' : ''}
</div>
`;
}
private renderPlaceholder(): string {
return `
<div class="attachment-placeholder">
<div class="placeholder-text">Click to add files</div>
<div class="placeholder-hint">or drag and drop</div>
</div>
`;
}
private renderFiles(files: any[]): string {
return files.map((file: any) => `
<div class="attachment-item" data-file-id="${file.id}">
<div class="file-icon">${this.getFileIcon(file.type)}</div>
<div class="file-info">
<div class="file-name">${this.escapeHtml(file.name)}</div>
<div class="file-size">${this.formatFileSize(file.size)}</div>
</div>
<button class="remove-file" data-file-id="${file.id}">×</button>
</div>
`).join('');
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.attachment-block-container') as HTMLElement;
const fileInput = element.querySelector('.attachment-file-input') as HTMLInputElement;
if (!container || !fileInput) {
console.error('AttachmentBlockHandler: Could not find required elements');
return;
}
// Initialize files array if needed
if (!block.metadata) block.metadata = {};
if (!block.metadata.files) block.metadata.files = [];
// Click to upload on placeholder
const placeholder = container.querySelector('.attachment-placeholder');
if (placeholder) {
placeholder.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
fileInput.click();
});
}
// Add more files button
const addMoreBtn = container.querySelector('.add-more-files') as HTMLButtonElement;
if (addMoreBtn) {
addMoreBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
fileInput.click();
});
}
// File input change
fileInput.addEventListener('change', async (e) => {
const input = e.target as HTMLInputElement;
const files = input.files;
if (files && files.length > 0) {
await this.handleFileAttachments(files, block, handlers);
input.value = ''; // Clear input for next selection
}
});
// Remove file buttons
container.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('remove-file')) {
e.preventDefault();
e.stopPropagation();
const fileId = target.getAttribute('data-file-id');
if (fileId) {
this.removeFile(fileId, block, handlers);
}
}
});
// Drag and drop
container.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.add('drag-over');
});
container.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
});
container.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
await this.handleFileAttachments(files, block, handlers);
}
});
// Focus/blur
container.addEventListener('focus', () => handlers.onFocus());
container.addEventListener('blur', () => handlers.onBlur());
// Keyboard navigation
container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
// Only remove all files if container is focused, not when removing individual files
if (document.activeElement === container && block.metadata?.files?.length > 0) {
e.preventDefault();
block.metadata.files = [];
handlers.onRequestUpdate?.();
return;
}
}
handlers.onKeyDown(e);
});
}
private async handleFileAttachments(
files: FileList,
block: IBlock,
handlers: IBlockEventHandlers
): Promise<void> {
if (!block.metadata) block.metadata = {};
if (!block.metadata.files) block.metadata.files = [];
for (const file of Array.from(files)) {
try {
const dataUrl = await this.fileToDataUrl(file);
const fileData = {
id: this.generateId(),
name: file.name,
size: file.size,
type: file.type,
data: dataUrl
};
block.metadata.files.push(fileData);
} catch (error) {
console.error('Failed to attach file:', file.name, error);
}
}
// Update block content with file count
block.content = `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`;
// Request UI update
handlers.onRequestUpdate?.();
}
private removeFile(fileId: string, block: IBlock, handlers: IBlockEventHandlers): void {
if (!block.metadata?.files) return;
block.metadata.files = block.metadata.files.filter((f: any) => f.id !== fileId);
// Update content
block.content = block.metadata.files.length > 0
? `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`
: '';
// Request UI update
handlers.onRequestUpdate?.();
}
private fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result;
if (typeof result === 'string') {
resolve(result);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
private getFileIcon(mimeType: string): string {
if (mimeType.startsWith('image/')) return '🖼️';
if (mimeType.startsWith('video/')) return '🎥';
if (mimeType.startsWith('audio/')) return '🎵';
if (mimeType.includes('pdf')) return '📄';
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('tar')) return '🗄️';
if (mimeType.includes('sheet')) return '📊';
if (mimeType.includes('document') || mimeType.includes('msword')) return '📝';
if (mimeType.includes('presentation')) return '📋';
if (mimeType.includes('text')) return '📃';
return '📁';
}
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
private generateId(): string {
return `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
// Content is the description of attached files
const block = this.getBlockFromElement(element);
return block?.content || '';
}
setContent(element: HTMLElement, content: string): void {
// Content is the description of attached files
const block = this.getBlockFromElement(element);
if (block) {
block.content = content;
}
}
private getBlockFromElement(element: HTMLElement): IBlock | null {
const container = element.querySelector('.attachment-block-container');
const blockId = container?.getAttribute('data-block-id');
if (!blockId) return null;
// Simplified version - in real implementation would need access to block data
return {
id: blockId,
type: 'attachment',
content: '',
metadata: {}
};
}
getCursorPosition(element: HTMLElement): number | null {
return null; // Attachment blocks don't have cursor position
}
setCursorToStart(element: HTMLElement): void {
this.focus(element);
}
setCursorToEnd(element: HTMLElement): void {
this.focus(element);
}
focus(element: HTMLElement): void {
const container = element.querySelector('.attachment-block-container') as HTMLElement;
container?.focus();
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
this.focus(element);
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
return null; // Attachment blocks can't be split
}
getStyles(): string {
return `
/* Attachment Block Container */
.attachment-block-container {
position: relative;
margin: 12px 0;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
outline: none;
background: ${cssManager.bdTheme('#ffffff', '#111827')};
}
.attachment-block-container.selected {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.attachment-block-container.drag-over {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
}
/* Header */
.attachment-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
}
.attachment-icon {
font-size: 18px;
opacity: 0.8;
}
.attachment-title {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
/* File List */
.attachment-list {
padding: 8px;
min-height: 80px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Placeholder */
.attachment-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
cursor: pointer;
transition: all 0.15s ease;
}
.attachment-placeholder:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
}
.placeholder-text {
font-size: 14px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 4px;
}
.placeholder-hint {
font-size: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* File Items */
.attachment-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
transition: all 0.15s ease;
}
.attachment-item:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
}
.file-icon {
font-size: 20px;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 11px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-top: 2px;
}
.remove-file {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.remove-file:hover {
background: ${cssManager.bdTheme('#fee2e2', '#991b1b')};
border-color: ${cssManager.bdTheme('#fca5a5', '#dc2626')};
color: ${cssManager.bdTheme('#dc2626', '#fca5a5')};
}
/* Add More Files Button */
.add-more-files {
margin: 8px;
padding: 6px 12px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
font-size: 13px;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
cursor: pointer;
transition: all 0.15s ease;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.add-more-files:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
}
/* Hidden file input */
.attachment-file-input {
display: none !important;
}
`;
}
}

View File

@ -0,0 +1,406 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* ImageBlockHandler - Handles image upload, display, and interactions
*
* Features:
* - Click to upload
* - Drag and drop support
* - Base64 encoding (TODO: server upload in production)
* - Loading states
* - Alt text from filename
*/
export class ImageBlockHandler extends BaseBlockHandler {
type = 'image';
render(block: IBlock, isSelected: boolean): string {
const imageUrl = block.metadata?.url;
const altText = block.content || 'Image';
const isLoading = block.metadata?.loading;
return `
<div class="image-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
data-has-image="${!!imageUrl}"
tabindex="0">
${isLoading ? this.renderLoading() :
imageUrl ? this.renderImage(imageUrl, altText) :
this.renderPlaceholder()}
<input type="file"
class="image-file-input"
accept="image/*"
style="display: none;" />
</div>
`;
}
private renderPlaceholder(): string {
return `
<div class="image-upload-placeholder" style="cursor: pointer;">
<div class="upload-icon" style="pointer-events: none;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
<div class="upload-text" style="pointer-events: none;">Click to upload an image</div>
<div class="upload-hint" style="pointer-events: none;">or drag and drop</div>
</div>
`;
}
private renderImage(url: string, altText: string): string {
return `
<div class="image-container">
<img src="${url}" alt="${this.escapeHtml(altText)}" />
</div>
`;
}
private renderLoading(): string {
return `
<div class="image-loading">
<div class="loading-spinner"></div>
<div class="loading-text">Uploading image...</div>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.image-block-container') as HTMLElement;
const fileInput = element.querySelector('.image-file-input') as HTMLInputElement;
if (!container) {
console.error('ImageBlockHandler: Could not find container');
return;
}
if (!fileInput) {
console.error('ImageBlockHandler: Could not find file input');
return;
}
// Click to upload (only on placeholder)
const placeholder = container.querySelector('.image-upload-placeholder');
if (placeholder) {
placeholder.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('ImageBlockHandler: Placeholder clicked, opening file selector');
fileInput.click();
});
}
// Container click for focus
container.addEventListener('click', () => {
handlers.onFocus();
});
// File input change
fileInput.addEventListener('change', async (e) => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
console.log('ImageBlockHandler: File selected:', file.name);
await this.handleFileUpload(file, block, handlers);
}
});
// Drag and drop
container.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
if (!block.metadata?.url) {
container.classList.add('drag-over');
}
});
container.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
});
container.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/') && !block.metadata?.url) {
await this.handleFileUpload(file, block, handlers);
}
});
// Focus/blur
container.addEventListener('focus', () => handlers.onFocus());
container.addEventListener('blur', () => handlers.onBlur());
// Keyboard navigation
container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (block.metadata?.url) {
// Clear the image
block.metadata.url = undefined;
block.metadata.loading = false;
block.content = '';
handlers.onInput(new InputEvent('input'));
return;
}
}
handlers.onKeyDown(e);
});
}
private async handleFileUpload(
file: File,
block: IBlock,
handlers: IBlockEventHandlers
): Promise<void> {
console.log('ImageBlockHandler: Starting file upload', {
fileName: file.name,
fileSize: file.size,
blockId: block.id
});
// Validate file
if (!file.type.startsWith('image/')) {
console.error('Invalid file type:', file.type);
return;
}
// Check file size (10MB limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
console.error('File too large. Maximum size is 10MB');
return;
}
// Set loading state
if (!block.metadata) block.metadata = {};
block.metadata.loading = true;
block.metadata.fileName = file.name;
block.metadata.fileSize = file.size;
block.metadata.mimeType = file.type;
console.log('ImageBlockHandler: Set loading state, requesting update');
// Request immediate UI update for loading state
handlers.onRequestUpdate?.();
try {
// Convert to base64
const dataUrl = await this.fileToDataUrl(file);
// Update block
block.metadata.url = dataUrl;
block.metadata.loading = false;
// Set default alt text from filename
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
block.content = nameWithoutExt;
console.log('ImageBlockHandler: Upload complete, requesting update', {
hasUrl: !!block.metadata.url,
urlLength: dataUrl.length,
altText: block.content
});
// Request immediate UI update to show uploaded image
handlers.onRequestUpdate?.();
} catch (error) {
console.error('Failed to upload image:', error);
block.metadata.loading = false;
// Request UI update to clear loading state
handlers.onRequestUpdate?.();
}
}
private fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result;
if (typeof result === 'string') {
resolve(result);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
// Content is the alt text
const block = this.getBlockFromElement(element);
return block?.content || '';
}
setContent(element: HTMLElement, content: string): void {
// Content is the alt text
const block = this.getBlockFromElement(element);
if (block) {
block.content = content;
}
}
private getBlockFromElement(element: HTMLElement): IBlock | null {
const container = element.querySelector('.image-block-container');
const blockId = container?.getAttribute('data-block-id');
if (!blockId) return null;
// This is a simplified version - in real implementation,
// we'd need access to the block data
return {
id: blockId,
type: 'image',
content: '',
metadata: {}
};
}
getCursorPosition(element: HTMLElement): number | null {
return null; // Images don't have cursor position
}
setCursorToStart(element: HTMLElement): void {
this.focus(element);
}
setCursorToEnd(element: HTMLElement): void {
this.focus(element);
}
focus(element: HTMLElement): void {
const container = element.querySelector('.image-block-container') as HTMLElement;
container?.focus();
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
this.focus(element);
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
return null; // Images can't be split
}
getStyles(): string {
return `
/* Image Block Container */
.image-block-container {
position: relative;
margin: 12px 0;
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
outline: none;
cursor: pointer;
}
.image-block-container.selected {
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
}
/* Upload Placeholder */
.image-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
border: 2px dashed ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
transition: all 0.15s ease;
}
.image-block-container:hover .image-upload-placeholder {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
}
.image-block-container.drag-over .image-upload-placeholder {
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
background: ${cssManager.bdTheme('#eff6ff', '#1e1b4b')};
}
.upload-icon {
margin-bottom: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
}
.upload-text {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
margin-bottom: 4px;
}
.upload-hint {
font-size: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Image Container */
.image-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
}
.image-container img {
max-width: 100%;
height: auto;
display: block;
border-radius: 4px;
}
/* Loading State */
.image-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-top-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 14px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* File input hidden */
.image-file-input {
display: none !important;
}
`;
}
}

View File

@ -0,0 +1,337 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* YouTubeBlockHandler - Handles YouTube video embedding
*
* Features:
* - YouTube URL parsing and validation
* - Video ID extraction from various YouTube URL formats
* - Embedded iframe player
* - Clean minimalist design
*/
export class YouTubeBlockHandler extends BaseBlockHandler {
type = 'youtube';
render(block: IBlock, isSelected: boolean): string {
const videoId = block.metadata?.videoId;
const url = block.metadata?.url || '';
return `
<div class="youtube-block-container${isSelected ? ' selected' : ''}"
data-block-id="${block.id}"
data-has-video="${!!videoId}">
${videoId ? this.renderVideo(videoId) : this.renderPlaceholder(url)}
</div>
`;
}
private renderPlaceholder(url: string): string {
return `
<div class="youtube-placeholder">
<div class="placeholder-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"/>
</svg>
</div>
<div class="placeholder-text">Enter YouTube URL</div>
<input type="url"
class="youtube-url-input"
placeholder="https://youtube.com/watch?v=..."
value="${this.escapeHtml(url)}" />
<button class="youtube-embed-btn">Embed Video</button>
</div>
`;
}
private renderVideo(videoId: string): string {
return `
<div class="youtube-container">
<iframe
src="https://www.youtube.com/embed/${videoId}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.youtube-block-container') as HTMLElement;
if (!container) return;
// If video is already embedded, just handle focus/blur
if (block.metadata?.videoId) {
container.setAttribute('tabindex', '0');
container.addEventListener('focus', () => handlers.onFocus());
container.addEventListener('blur', () => handlers.onBlur());
// Handle deletion
container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
handlers.onKeyDown(e);
} else {
handlers.onKeyDown(e);
}
});
return;
}
// Setup placeholder interactions
const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement;
const embedBtn = element.querySelector('.youtube-embed-btn') as HTMLButtonElement;
if (!urlInput || !embedBtn) return;
// Focus management
urlInput.addEventListener('focus', () => handlers.onFocus());
urlInput.addEventListener('blur', () => handlers.onBlur());
// Handle embed button click
embedBtn.addEventListener('click', () => {
this.embedVideo(urlInput.value, block, handlers);
});
// Handle Enter key in input
urlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.embedVideo(urlInput.value, block, handlers);
} else if (e.key === 'Escape') {
e.preventDefault();
urlInput.blur();
}
});
// Handle paste event
urlInput.addEventListener('paste', (e) => {
// Allow paste to complete first
setTimeout(() => {
const pastedUrl = urlInput.value;
if (this.extractYouTubeVideoId(pastedUrl)) {
// Auto-embed if valid YouTube URL was pasted
this.embedVideo(pastedUrl, block, handlers);
}
}, 0);
});
// Update URL in metadata as user types
urlInput.addEventListener('input', () => {
if (!block.metadata) block.metadata = {};
block.metadata.url = urlInput.value;
});
}
private embedVideo(url: string, block: IBlock, handlers: IBlockEventHandlers): void {
const videoId = this.extractYouTubeVideoId(url);
if (!videoId) {
// Could show an error message here
console.error('Invalid YouTube URL');
return;
}
// Update block metadata
if (!block.metadata) block.metadata = {};
block.metadata.videoId = videoId;
block.metadata.url = url;
// Set content as video title (could be fetched from API in the future)
block.content = `YouTube Video: ${videoId}`;
// Request immediate UI update to show embedded video
handlers.onRequestUpdate?.();
}
private extractYouTubeVideoId(url: string): string | null {
// Handle various YouTube URL formats
const patterns = [
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/,
/youtube\.com\/embed\/([^"&?\/ ]{11})/,
/youtube\.com\/watch\?v=([^"&?\/ ]{11})/,
/youtu\.be\/([^"&?\/ ]{11})/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return match[1];
}
}
return null;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
// Content is the video description/title
const block = this.getBlockFromElement(element);
return block?.content || '';
}
setContent(element: HTMLElement, content: string): void {
// Content is the video description/title
const block = this.getBlockFromElement(element);
if (block) {
block.content = content;
}
}
private getBlockFromElement(element: HTMLElement): IBlock | null {
const container = element.querySelector('.youtube-block-container');
const blockId = container?.getAttribute('data-block-id');
if (!blockId) return null;
// Simplified version - in real implementation would need access to block data
return {
id: blockId,
type: 'youtube',
content: '',
metadata: {}
};
}
getCursorPosition(element: HTMLElement): number | null {
return null; // YouTube blocks don't have cursor position
}
setCursorToStart(element: HTMLElement): void {
this.focus(element);
}
setCursorToEnd(element: HTMLElement): void {
this.focus(element);
}
focus(element: HTMLElement): void {
const container = element.querySelector('.youtube-block-container') as HTMLElement;
const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement;
if (urlInput) {
urlInput.focus();
} else if (container) {
container.focus();
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
this.focus(element);
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
return null; // YouTube blocks can't be split
}
getStyles(): string {
return `
/* YouTube Block Container */
.youtube-block-container {
position: relative;
margin: 12px 0;
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
outline: none;
}
.youtube-block-container.selected {
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
}
/* YouTube Placeholder */
.youtube-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 24px;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
gap: 12px;
}
.placeholder-icon {
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
opacity: 0.8;
}
.placeholder-text {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.youtube-url-input {
width: 100%;
max-width: 400px;
padding: 8px 12px;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 4px;
background: ${cssManager.bdTheme('#ffffff', '#111827')};
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: all 0.15s ease;
outline: none;
}
.youtube-url-input:focus {
border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: ${cssManager.bdTheme('#ffffff', '#1f2937')};
}
.youtube-url-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
}
.youtube-embed-btn {
padding: 6px 16px;
background: ${cssManager.bdTheme('#111827', '#f9fafb')};
color: ${cssManager.bdTheme('#f9fafb', '#111827')};
border: 1px solid transparent;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
}
.youtube-embed-btn:hover {
background: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.youtube-embed-btn:active {
transform: scale(0.98);
}
/* YouTube Container */
.youtube-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
background: ${cssManager.bdTheme('#000000', '#000000')};
}
.youtube-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 6px;
}
`;
}
}

View File

@ -0,0 +1,669 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
import hlight from 'highlight.js';
/**
* CodeBlockHandler with improved architecture
*
* Key features:
* 1. Simple DOM structure
* 2. Line number handling
* 3. Syntax highlighting only when not focused (grey text while editing)
* 4. Clean event handling
* 5. Copy button functionality
*/
export class CodeBlockHandler extends BaseBlockHandler {
type = 'code';
private highlightTimer: any = null;
render(block: IBlock, isSelected: boolean): string {
const language = block.metadata?.language || 'javascript';
const content = block.content || '';
const lineCount = content.split('\n').length;
// Generate line numbers
let lineNumbersHtml = '';
for (let i = 1; i <= lineCount; i++) {
lineNumbersHtml += `<div class="line-number">${i}</div>`;
}
return `
<div class="code-block-container${isSelected ? ' selected' : ''}" data-language="${language}">
<div class="code-header">
<span class="language-label">${language}</span>
<button class="copy-button" title="Copy code">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
<div class="code-body">
<div class="line-numbers">${lineNumbersHtml}</div>
<div class="code-content">
<pre class="code-pre"><code class="code-editor"
contenteditable="true"
data-block-id="${block.id}"
data-block-type="${block.type}"
spellcheck="false">${this.escapeHtml(content)}</code></pre>
</div>
</div>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
const container = element.querySelector('.code-block-container') as HTMLElement;
const copyButton = element.querySelector('.copy-button') as HTMLButtonElement;
if (!editor || !container) return;
// Setup copy button
if (copyButton) {
copyButton.addEventListener('click', async () => {
const content = editor.textContent || '';
try {
await navigator.clipboard.writeText(content);
// Show feedback
const copyText = copyButton.querySelector('.copy-text') as HTMLElement;
const originalText = copyText.textContent;
copyText.textContent = 'Copied!';
copyButton.classList.add('copied');
// Reset after 2 seconds
setTimeout(() => {
copyText.textContent = originalText;
copyButton.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = content;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
// @ts-ignore - execCommand is deprecated but needed for fallback
document.execCommand('copy');
// Show feedback
const copyText = copyButton.querySelector('.copy-text') as HTMLElement;
const originalText = copyText.textContent;
copyText.textContent = 'Copied!';
copyButton.classList.add('copied');
setTimeout(() => {
copyText.textContent = originalText;
copyButton.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
}
});
}
// Track if we're currently editing
let isEditing = false;
// Focus handler
editor.addEventListener('focus', () => {
isEditing = true;
container.classList.add('editing');
// Remove all syntax highlighting when focused
const content = editor.textContent || '';
editor.textContent = content; // This removes all HTML formatting
// Restore cursor position after removing highlighting
requestAnimationFrame(() => {
const range = document.createRange();
const selection = window.getSelection();
if (editor.firstChild) {
range.setStart(editor.firstChild, 0);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
}
});
handlers.onFocus();
});
// Blur handler
editor.addEventListener('blur', () => {
isEditing = false;
container.classList.remove('editing');
// Apply final highlighting on blur
this.applyHighlighting(element, block);
handlers.onBlur();
});
// Input handler
editor.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Update line numbers
this.updateLineNumbers(element);
// Clear any pending highlight timer (no highlighting while editing)
clearTimeout(this.highlightTimer);
});
// Keydown handler
editor.addEventListener('keydown', (e) => {
// Handle Tab key for code blocks
if (e.key === 'Tab') {
e.preventDefault();
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const textNode = document.createTextNode(' ');
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
handlers.onInput(new InputEvent('input'));
this.updateLineNumbers(element);
}
return;
}
// Check cursor position for navigation keys
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
const cursorPos = this.getCursorPosition(element);
const textLength = editor.textContent?.length || 0;
// For ArrowLeft at position 0 or ArrowRight at end, let parent handle navigation
if ((e.key === 'ArrowLeft' && cursorPos === 0) ||
(e.key === 'ArrowRight' && cursorPos === textLength)) {
// Pass to parent handler for inter-block navigation
handlers.onKeyDown(e);
return;
}
// For ArrowUp/Down, check if we're at first/last line
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const lines = (editor.textContent || '').split('\n');
const currentLine = this.getCurrentLineIndex(editor);
if ((e.key === 'ArrowUp' && currentLine === 0) ||
(e.key === 'ArrowDown' && currentLine === lines.length - 1)) {
// Let parent handle navigation to prev/next block
handlers.onKeyDown(e);
return;
}
}
}
// Pass other keys to parent handler
handlers.onKeyDown(e);
});
// Paste handler - plain text only
editor.addEventListener('paste', (e) => {
e.preventDefault();
const text = e.clipboardData?.getData('text/plain');
if (text) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
handlers.onInput(new InputEvent('input'));
this.updateLineNumbers(element);
}
}
});
// Composition handlers
editor.addEventListener('compositionstart', () => handlers.onCompositionStart());
editor.addEventListener('compositionend', () => handlers.onCompositionEnd());
// Initial syntax highlighting if content exists and not focused
if (block.content && document.activeElement !== editor) {
requestAnimationFrame(() => {
this.applyHighlighting(element, block);
});
}
}
private updateLineNumbers(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
const lineNumbersContainer = element.querySelector('.line-numbers') as HTMLElement;
if (!editor || !lineNumbersContainer) return;
const content = editor.textContent || '';
const lines = content.split('\n');
const lineCount = lines.length || 1;
let lineNumbersHtml = '';
for (let i = 1; i <= lineCount; i++) {
lineNumbersHtml += `<div class="line-number">${i}</div>`;
}
lineNumbersContainer.innerHTML = lineNumbersHtml;
}
private getCurrentLineIndex(editor: HTMLElement): number {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(editor);
preCaretRange.setEnd(range.startContainer, range.startOffset);
const textBeforeCursor = preCaretRange.toString();
const linesBeforeCursor = textBeforeCursor.split('\n');
return linesBeforeCursor.length - 1; // 0-indexed
}
private applyHighlighting(element: HTMLElement, block: IBlock): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return;
// Store cursor position
const cursorPos = this.getCursorPosition(element);
// Get plain text content
const content = editor.textContent || '';
const language = block.metadata?.language || 'javascript';
// Apply highlighting
try {
const result = hlight.highlight(content, {
language: language,
ignoreIllegals: true
});
// Only update if we have valid highlighted content
if (result.value) {
editor.innerHTML = result.value;
// Restore cursor position if editor is focused
if (document.activeElement === editor && cursorPos !== null) {
requestAnimationFrame(() => {
WysiwygSelection.setCursorPosition(editor, cursorPos);
});
}
}
} catch (error) {
// If highlighting fails, keep plain text
console.warn('Syntax highlighting failed:', error);
}
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
const editor = element.querySelector('.code-editor') as HTMLElement;
return editor?.textContent || '';
}
setContent(element: HTMLElement, content: string): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return;
editor.textContent = content;
this.updateLineNumbers(element);
// Apply highlighting if not focused
if (document.activeElement !== editor) {
const block: IBlock = {
id: editor.dataset.blockId || '',
type: 'code',
content: content,
metadata: {
language: element.querySelector('.code-block-container')?.getAttribute('data-language') || 'javascript'
}
};
this.applyHighlighting(element, block);
}
}
getCursorPosition(element: HTMLElement): number | null {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!editor.contains(range.startContainer)) return null;
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(editor);
preCaretRange.setEnd(range.startContainer, range.startOffset);
return preCaretRange.toString().length;
}
setCursorToStart(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (editor) {
WysiwygSelection.setCursorPosition(editor, 0);
}
}
setCursorToEnd(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (editor) {
const length = editor.textContent?.length || 0;
WysiwygSelection.setCursorPosition(editor, length);
}
}
focus(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
editor?.focus();
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return;
editor.focus();
requestAnimationFrame(() => {
if (position === 'start') {
this.setCursorToStart(element);
} else if (position === 'end') {
this.setCursorToEnd(element);
} else if (typeof position === 'number') {
WysiwygSelection.setCursorPosition(editor, position);
}
});
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
const position = this.getCursorPosition(element);
if (position === null) return null;
const content = this.getContent(element);
return {
before: content.substring(0, position),
after: content.substring(position)
};
}
getStyles(): string {
return `
/* Code Block Container - Minimalist shadcn style */
.code-block-container {
position: relative;
margin: 12px 0;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
}
.code-block-container.selected {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.code-block-container.editing {
border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
/* Header - Simplified */
.code-header {
background: transparent;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.language-label {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Copy Button - Minimal */
.copy-button {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
}
.copy-button:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#e5e7eb', '#374151')};
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.copy-button:active {
transform: scale(0.98);
}
.copy-button.copied {
color: ${cssManager.bdTheme('#059669', '#10b981')};
}
.copy-icon {
flex-shrink: 0;
opacity: 0.7;
}
.copy-button:hover .copy-icon {
opacity: 1;
}
.copy-text {
min-width: 40px;
text-align: center;
}
/* Code Body */
.code-body {
display: flex;
position: relative;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
/* Line Numbers - Subtle */
.line-numbers {
flex-shrink: 0;
padding: 12px 0;
background: transparent;
text-align: right;
user-select: none;
min-width: 40px;
border-right: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
}
.line-number {
padding: 0 12px 0 8px;
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 13px;
line-height: 20px;
height: 20px;
}
/* Code Content */
.code-content {
flex: 1;
overflow-x: auto;
position: relative;
}
.code-pre {
margin: 0;
padding: 0;
background: transparent;
}
.code-editor {
display: block;
padding: 12px 16px;
margin: 0;
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 13px;
line-height: 20px;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
background: transparent;
border: none;
outline: none;
white-space: pre-wrap;
word-wrap: break-word;
min-height: 60px;
overflow: visible;
}
/* Placeholder */
.code-editor:empty::before {
content: "// Type or paste code here...";
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
pointer-events: none;
}
/* When editing (focused), show grey text without highlighting */
.code-block-container.editing .code-editor {
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')} !important;
}
.code-block-container.editing .code-editor * {
color: inherit !important;
}
/* Syntax Highlighting - Muted colors */
.code-editor .hljs-keyword {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
font-weight: 500;
}
.code-editor .hljs-string {
color: ${cssManager.bdTheme('#059669', '#10b981')};
}
.code-editor .hljs-number {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.code-editor .hljs-function {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.code-editor .hljs-comment {
color: ${cssManager.bdTheme('#6b7280', '#6b7280')};
font-style: italic;
}
.code-editor .hljs-variable,
.code-editor .hljs-attr {
color: ${cssManager.bdTheme('#ea580c', '#fb923c')};
}
.code-editor .hljs-class,
.code-editor .hljs-title {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
font-weight: 500;
}
.code-editor .hljs-params {
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.code-editor .hljs-built_in {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.code-editor .hljs-literal {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.code-editor .hljs-meta {
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.code-editor .hljs-punctuation {
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.code-editor .hljs-tag {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.code-editor .hljs-attribute {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.code-editor .hljs-selector-tag {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.code-editor .hljs-selector-class {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.code-editor .hljs-selector-id {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
/* Selection */
.code-editor::selection,
.code-editor *::selection {
background: ${cssManager.bdTheme('rgba(99, 102, 241, 0.2)', 'rgba(99, 102, 241, 0.3)')};
}
/* Scrollbar styling - Minimal */
.code-content::-webkit-scrollbar {
height: 6px;
}
.code-content::-webkit-scrollbar-track {
background: transparent;
}
.code-content::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
border-radius: 3px;
}
.code-content::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
`;
}
}

View File

@ -0,0 +1,533 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class HeadingBlockHandler extends BaseBlockHandler {
type: string;
private level: 1 | 2 | 3;
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
constructor(type: 'heading-1' | 'heading-2' | 'heading-3') {
super();
this.type = type;
this.level = parseInt(type.split('-')[1]) as 1 | 2 | 3;
}
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
return `
<div
class="block heading-${this.level}${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
data-block-id="${block.id}"
data-block-type="${block.type}"
></div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) {
console.error('HeadingBlockHandler.setup: No heading block element found');
return;
}
// Set initial content if needed
if (block.content && !headingBlock.innerHTML) {
headingBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
headingBlock.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler with cursor tracking
headingBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onKeyDown(e);
});
// Focus handler
headingBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
headingBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
headingBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
headingBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
headingBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Selection will be handled by selectionchange event
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
headingBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler for additional cursor tracking
headingBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection change handler
this.setupSelectionHandler(element, headingBlock, block);
}
private setupSelectionHandler(element: HTMLElement, headingBlock: HTMLDivElement, block: IBlock): void {
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root - in setup, we need to traverse
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
this.selectionHandler = checkSelection;
// Clean up on disconnect (will be called by dees-wysiwyg-block)
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
// Return styles for all heading levels
return `
.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')};
}
`;
}
getPlaceholder(): string {
switch(this.level) {
case 1:
return 'Heading 1';
case 2:
return 'Heading 2';
case 3:
return 'Heading 3';
default:
return 'Heading';
}
}
/**
* 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;
}
// Helper methods for heading functionality (mostly the same as paragraph)
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual heading element
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
return null;
}
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(headingBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
return position;
}
getContent(element: HTMLElement, context?: any): string {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return '';
// For headings, get the innerHTML which includes formatting tags
const content = headingBlock.innerHTML || '';
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === headingBlock ||
element.shadowRoot?.activeElement === headingBlock;
headingBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
headingBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (headingBlock) {
WysiwygBlocks.setCursorToStart(headingBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (headingBlock) {
WysiwygBlocks.setCursorToEnd(headingBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return;
// Ensure the element is focusable
if (!headingBlock.hasAttribute('contenteditable')) {
headingBlock.setAttribute('contenteditable', 'true');
}
headingBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== headingBlock && element.shadowRoot?.activeElement !== headingBlock) {
Promise.resolve().then(() => {
headingBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return;
// Ensure element is focusable first
if (!headingBlock.hasAttribute('contenteditable')) {
headingBlock.setAttribute('contenteditable', 'true');
}
// For 'end' position, we need to set up selection before focus to prevent browser default
if (position === 'end' && headingBlock.textContent && headingBlock.textContent.length > 0) {
// Set up the selection first
const sel = window.getSelection();
if (sel) {
const range = document.createRange();
const lastNode = this.getLastTextNode(headingBlock) || headingBlock;
if (lastNode.nodeType === Node.TEXT_NODE) {
range.setStart(lastNode, lastNode.textContent?.length || 0);
range.setEnd(lastNode, lastNode.textContent?.length || 0);
} else {
range.selectNodeContents(lastNode);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
}
}
// Now focus the element
headingBlock.focus();
// Set cursor position after focus is established (for non-end positions)
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end' && (!headingBlock.textContent || headingBlock.textContent.length === 0)) {
// Only call setCursorToEnd for empty blocks
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(headingBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
setCursor();
} else {
// Try again with a small delay - sometimes focus needs more time
setTimeout(() => {
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
setCursor();
}
}, 10);
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = headingBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = headingBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position first
const cursorPos = this.getCursorPosition(element, context);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
return {
before: '',
after: headingBlock.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(headingBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(headingBlock, headingBlock.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
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
};
}
}

View File

@ -0,0 +1,448 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class ListBlockHandler extends BaseBlockHandler {
type = 'list';
// Track cursor position and list state
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const listType = block.metadata?.listType || 'unordered';
const listTag = listType === 'ordered' ? 'ol' : 'ul';
// Render list content
const listContent = this.renderListContent(block.content, block.metadata);
return `
<div
class="block list${selectedClass}"
contenteditable="true"
data-block-id="${block.id}"
data-block-type="${block.type}"
>${listContent}</div>
`;
}
private renderListContent(content: string | undefined, metadata: any): string {
if (!content) return '<ul><li></li></ul>';
const listType = metadata?.listType || 'unordered';
const listTag = listType === 'ordered' ? 'ol' : 'ul';
// Split content by newlines to create list items
const lines = content.split('\n').filter(line => line.trim());
if (lines.length === 0) {
return `<${listTag}><li></li></${listTag}>`;
}
const listItems = lines.map(line => `<li>${line}</li>`).join('');
return `<${listTag}>${listItems}</${listTag}>`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) {
console.error('ListBlockHandler.setup: No list block element found');
return;
}
// Set initial content if needed
if (block.content && !listBlock.innerHTML) {
listBlock.innerHTML = this.renderListContent(block.content, block.metadata);
}
// Input handler
listBlock.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler
listBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Special handling for Enter key in lists
if (e.key === 'Enter' && !e.shiftKey) {
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();
handlers.onKeyDown(e);
return;
}
// Otherwise, let browser create new list item naturally
}
}
handlers.onKeyDown(e);
});
// Focus handler
listBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
listBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
listBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
listBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
listBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onMouseUp?.(e);
});
// Click handler
listBlock.addEventListener('click', (e: MouseEvent) => {
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler
listBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection handler
this.setupSelectionHandler(element, listBlock, block);
}
private setupSelectionHandler(element: HTMLElement, listBlock: HTMLDivElement, block: IBlock): void {
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
document.addEventListener('selectionchange', checkSelection);
this.selectionHandler = checkSelection;
// Cleanup on disconnect
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
return `
/* List specific styles */
.block.list {
padding: 0;
}
.block.list ul,
.block.list ol {
margin: 0;
padding-left: 24px;
}
.block.list li {
margin: 4px 0;
line-height: 1.6;
}
.block.list li:last-child {
margin-bottom: 0;
}
`;
}
getPlaceholder(): string {
return '';
}
// Helper methods for list functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return null;
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return null;
if (!WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer)) {
return null;
}
// For lists, calculate position based on text content
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(listBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
return preCaretRange.toString().length;
}
getContent(element: HTMLElement, context?: any): string {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return '';
// Extract text content from list items
const listItems = listBlock.querySelectorAll('li');
const content = Array.from(listItems)
.map(li => li.textContent || '')
.join('\n');
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
const hadFocus = document.activeElement === listBlock ||
element.shadowRoot?.activeElement === listBlock;
// Get current metadata to preserve list type
const listElement = listBlock.querySelector('ul, ol');
const isOrdered = listElement?.tagName === 'OL';
// Update content
listBlock.innerHTML = this.renderListContent(content, { listType: isOrdered ? 'ordered' : 'unordered' });
if (hadFocus) {
listBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
const firstLi = listBlock.querySelector('li');
if (firstLi) {
const textNode = this.getFirstTextNode(firstLi);
if (textNode) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
const lastLi = listBlock.querySelector('li:last-child');
if (lastLi) {
const textNode = this.getLastTextNode(lastLi);
if (textNode) {
const range = document.createRange();
const selection = window.getSelection();
const textLength = textNode.textContent?.length || 0;
range.setStart(textNode, textLength);
range.setEnd(textNode, textLength);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}
private getFirstTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = 0; i < element.childNodes.length; i++) {
const firstText = this.getFirstTextNode(element.childNodes[i]);
if (firstText) return firstText;
}
return null;
}
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;
}
focus(element: HTMLElement, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
if (!listBlock.hasAttribute('contenteditable')) {
listBlock.setAttribute('contenteditable', 'true');
}
listBlock.focus();
if (document.activeElement !== listBlock && element.shadowRoot?.activeElement !== listBlock) {
Promise.resolve().then(() => {
listBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
if (!listBlock.hasAttribute('contenteditable')) {
listBlock.setAttribute('contenteditable', 'true');
}
listBlock.focus();
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// For numeric positions in lists, we need custom logic
// This is complex due to list structure, so default to end
this.setCursorToEnd(element, context);
}
};
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
setCursor();
} else {
Promise.resolve().then(() => {
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return null;
// For lists, we don't split content - instead let the keyboard handler
// create a new paragraph block when Enter is pressed on empty list item
return null;
}
}

View File

@ -0,0 +1,499 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class ParagraphBlockHandler extends BaseBlockHandler {
type = 'paragraph';
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
return `
<div
class="block paragraph${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
data-block-id="${block.id}"
data-block-type="${block.type}"
></div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) {
console.error('ParagraphBlockHandler.setup: No paragraph block element found');
return;
}
// Set initial content if needed
if (block.content && !paragraphBlock.innerHTML) {
paragraphBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
paragraphBlock.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler with cursor tracking
paragraphBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onKeyDown(e);
});
// Focus handler
paragraphBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
paragraphBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
paragraphBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
paragraphBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
paragraphBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Selection will be handled by selectionchange event
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
paragraphBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler for additional cursor tracking
paragraphBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection change handler
this.setupSelectionHandler(element, paragraphBlock, block);
}
private setupSelectionHandler(element: HTMLElement, paragraphBlock: HTMLDivElement, block: IBlock): void {
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root - traverse from shadow root
const wysiwygBlock = (paragraphBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
this.selectionHandler = checkSelection;
// Clean up on disconnect (will be called by dees-wysiwyg-block)
const wysiwygBlock = element.closest('dees-wysiwyg-block');
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
return `
/* Paragraph specific styles */
.block.paragraph {
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
`;
}
getPlaceholder(): string {
return "Type '/' for commands...";
}
/**
* 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;
}
// Helper methods for paragraph functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual paragraph element
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
return null;
}
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(paragraphBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
return position;
}
getContent(element: HTMLElement, context?: any): string {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return '';
// For paragraphs, get the innerHTML which includes formatting tags
const content = paragraphBlock.innerHTML || '';
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === paragraphBlock ||
element.shadowRoot?.activeElement === paragraphBlock;
paragraphBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
paragraphBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (paragraphBlock) {
WysiwygBlocks.setCursorToStart(paragraphBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (paragraphBlock) {
WysiwygBlocks.setCursorToEnd(paragraphBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return;
// Ensure the element is focusable
if (!paragraphBlock.hasAttribute('contenteditable')) {
paragraphBlock.setAttribute('contenteditable', 'true');
}
paragraphBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== paragraphBlock && element.shadowRoot?.activeElement !== paragraphBlock) {
Promise.resolve().then(() => {
paragraphBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return;
// Ensure element is focusable first
if (!paragraphBlock.hasAttribute('contenteditable')) {
paragraphBlock.setAttribute('contenteditable', 'true');
}
// For 'end' position, we need to set up selection before focus to prevent browser default
if (position === 'end' && paragraphBlock.textContent && paragraphBlock.textContent.length > 0) {
// Set up the selection first
const sel = window.getSelection();
if (sel) {
const range = document.createRange();
const lastNode = this.getLastTextNode(paragraphBlock) || paragraphBlock;
if (lastNode.nodeType === Node.TEXT_NODE) {
range.setStart(lastNode, lastNode.textContent?.length || 0);
range.setEnd(lastNode, lastNode.textContent?.length || 0);
} else {
range.selectNodeContents(lastNode);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
}
}
// Now focus the element
paragraphBlock.focus();
// Set cursor position after focus is established (for non-end positions)
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end' && (!paragraphBlock.textContent || paragraphBlock.textContent.length === 0)) {
// Only call setCursorToEnd for empty blocks
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(paragraphBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
setCursor();
} else {
// Try again with a small delay - sometimes focus needs more time
setTimeout(() => {
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
setCursor();
}
}, 10);
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = paragraphBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = paragraphBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position first
const cursorPos = this.getCursorPosition(element, context);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
return {
before: '',
after: paragraphBlock.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(paragraphBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(paragraphBlock, paragraphBlock.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
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
};
}
}

View File

@ -0,0 +1,457 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class QuoteBlockHandler extends BaseBlockHandler {
type = 'quote';
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
return `
<div
class="block quote${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
data-block-id="${block.id}"
data-block-type="${block.type}"
></div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
console.error('QuoteBlockHandler.setup: No quote block element found');
return;
}
// Set initial content if needed
if (block.content && !quoteBlock.innerHTML) {
quoteBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
quoteBlock.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler with cursor tracking
quoteBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onKeyDown(e);
});
// Focus handler
quoteBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
quoteBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
quoteBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
quoteBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
quoteBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Selection will be handled by selectionchange event
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
quoteBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler for additional cursor tracking
quoteBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection change handler
this.setupSelectionHandler(element, quoteBlock, block);
}
private setupSelectionHandler(element: HTMLElement, quoteBlock: HTMLDivElement, block: IBlock): void {
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root - traverse from shadow root
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
this.selectionHandler = checkSelection;
// Clean up on disconnect (will be called by dees-wysiwyg-block)
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
return `
/* Quote specific styles */
.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;
}
`;
}
getPlaceholder(): string {
return 'Add a quote...';
}
// Helper methods for quote functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual quote element
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
return null;
}
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(quoteBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
return position;
}
getContent(element: HTMLElement, context?: any): string {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return '';
// For quotes, get the innerHTML which includes formatting tags
const content = quoteBlock.innerHTML || '';
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === quoteBlock ||
element.shadowRoot?.activeElement === quoteBlock;
quoteBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
quoteBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (quoteBlock) {
WysiwygBlocks.setCursorToStart(quoteBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (quoteBlock) {
WysiwygBlocks.setCursorToEnd(quoteBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Ensure the element is focusable
if (!quoteBlock.hasAttribute('contenteditable')) {
quoteBlock.setAttribute('contenteditable', 'true');
}
quoteBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== quoteBlock && element.shadowRoot?.activeElement !== quoteBlock) {
Promise.resolve().then(() => {
quoteBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Ensure element is focusable first
if (!quoteBlock.hasAttribute('contenteditable')) {
quoteBlock.setAttribute('contenteditable', 'true');
}
// Focus the element
quoteBlock.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(quoteBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = quoteBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = quoteBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position first
const cursorPos = this.getCursorPosition(element, context);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
return {
before: '',
after: quoteBlock.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(quoteBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(quoteBlock, quoteBlock.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
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
};
}
}

View File

@ -0,0 +1,217 @@
import {
customElement,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import { zIndexRegistry } from '../00zindex.js';
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 };
@state()
private menuZIndex: number = 1000;
private callback: ((command: string) => void | Promise<void>) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
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``;
// Apply z-index to host element
this.style.zIndex = this.menuZIndex.toString();
return html`
<div
class="formatting-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
data-menu-type="formatting"
>
${WysiwygFormatting.formatButtons.map(button => html`
<button
class="format-button ${button.command}"
data-command="${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 {
console.log('FormattingMenu.show called:', { position, visible: this.visible });
this.position = position;
this.callback = callback;
// Get z-index from registry
this.menuZIndex = zIndexRegistry.getNextZIndex();
zIndexRegistry.register(this, this.menuZIndex);
this.visible = true;
console.log('FormattingMenu.show - visible set to:', this.visible);
}
public hide(): void {
this.visible = false;
this.callback = null;
// Unregister from z-index registry
zIndexRegistry.unregister(this);
}
public updatePosition(position: { x: number; y: number }): void {
this.position = position;
}
public firstUpdated(): void {
// Set up event delegation for the menu
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
const menu = this.shadowRoot?.querySelector('.formatting-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}
});
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const button = target.closest('.format-button') as HTMLElement;
if (button) {
e.preventDefault();
e.stopPropagation();
const command = button.getAttribute('data-command');
if (command) {
this.applyFormat(command);
}
}
});
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
const menu = this.shadowRoot?.querySelector('.formatting-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}
}, true); // Use capture phase
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,256 @@
import {
customElement,
property,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import { zIndexRegistry } from '../00zindex.js';
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;
@state()
private menuZIndex: number = 1000;
private callback: ((type: string) => void) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
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``;
// Apply z-index to host element
this.style.zIndex = this.menuZIndex.toString();
const menuItems = this.getFilteredMenuItems();
return html`
<div
class="slash-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
data-menu-type="slash"
>
${menuItems.map((item, index) => html`
<div
class="slash-menu-item ${index === this.selectedIndex ? 'selected' : ''}"
data-item-type="${item.type}"
data-item-index="${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;
// Get z-index from registry
this.menuZIndex = zIndexRegistry.getNextZIndex();
zIndexRegistry.register(this, this.menuZIndex);
this.visible = true;
}
public hide(): void {
this.visible = false;
this.callback = null;
this.filter = '';
this.selectedIndex = 0;
// Unregister from z-index registry
zIndexRegistry.unregister(this);
}
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);
}
}
public firstUpdated(): void {
// Set up event delegation
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
const menu = this.shadowRoot?.querySelector('.slash-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}
});
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
if (menuItem) {
e.preventDefault();
e.stopPropagation();
const itemType = menuItem.getAttribute('data-item-type');
if (itemType) {
this.selectItem(itemType);
}
}
});
this.shadowRoot?.addEventListener('mouseenter', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
if (menuItem) {
const index = parseInt(menuItem.getAttribute('data-item-index') || '0', 10);
this.selectedIndex = index;
}
}, true); // Use capture phase
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
const menu = this.shadowRoot?.querySelector('.slash-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}
}, true); // Use capture phase
}
}

View File

@ -0,0 +1,841 @@
import {
customElement,
property,
static as html,
DeesElement,
type TemplateResult,
cssManager,
css,
} from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
import { WysiwygSelection } from './wysiwyg.selection.js';
import { BlockRegistry, type IBlockEventHandlers } from './blocks/index.js';
import './wysiwyg.blockregistration.js';
declare global {
interface HTMLElementTagNameMap {
'dees-wysiwyg-block': DeesWysiwygBlock;
}
}
@customElement('dees-wysiwyg-block')
export class DeesWysiwygBlock extends DeesElement {
async disconnectedCallback() {
await super.disconnectedCallback();
// Clean up selection handler
if ((this as any)._selectionHandler) {
document.removeEventListener('selectionchange', (this as any)._selectionHandler);
}
}
@property({ type: Object })
public block: IBlock;
@property({ type: Boolean })
public isSelected: boolean = false;
@property({ type: Object })
public handlers: IBlockEventHandlers;
// Reference to the editable block element
private blockElement: HTMLDivElement | null = null;
// Track if we've initialized the content
private contentInitialized: boolean = false;
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private handlerStylesInjected = false;
// Block types that don't support contenteditable
private static readonly NON_EDITABLE_TYPES = ['image', 'divider', 'youtube'];
private injectHandlerStyles(): void {
// Only inject once per instance
if (this.handlerStylesInjected) return;
this.handlerStylesInjected = true;
// Get styles from all registered block handlers
let styles = '';
const blockTypes = BlockRegistry.getAllTypes();
for (const type of blockTypes) {
const handler = BlockRegistry.getHandler(type);
if (handler) {
styles += handler.getStyles();
}
}
if (styles) {
// Create and inject style element
const styleElement = document.createElement('style');
styleElement.textContent = styles;
this.shadowRoot?.appendChild(styleElement);
}
}
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-specific styles moved to handlers */
/* 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 block container and language styles moved to handler */
/* Selection styles */
.block ::selection {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
color: inherit;
}
/* Strike through */
.block :is(s, strike) {
text-decoration: line-through;
opacity: 0.7;
}
/* 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(changedProperties: Map<string, any>): boolean {
// If selection state changed, update the selected class without re-rendering
if (changedProperties.has('isSelected') && this.block) {
// Find the block element based on block type
let element: HTMLElement | null = null;
// Build the specific selector based on block type
const blockType = this.block.type;
const selector = `.block.${blockType}`;
element = this.shadowRoot?.querySelector(selector) as HTMLElement;
if (element) {
if (this.isSelected) {
element.classList.add('selected');
} else {
element.classList.remove('selected');
}
}
return false; // Don't re-render, just update the class
}
// Never update if only the block content changed
if (changedProperties.has('block') && this.block) {
const oldBlock = changedProperties.get('block');
if (oldBlock && oldBlock.id && oldBlock.type && oldBlock.id === this.block.id && oldBlock.type === this.block.type) {
// Only content or metadata changed, don't re-render
return false;
}
}
// Only update if the block type or id changes
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
}
public firstUpdated(): void {
// Mark that content has been initialized
this.contentInitialized = true;
// Inject handler styles if not already done
this.injectHandlerStyles();
// First, populate the container with the rendered content
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container && this.block) {
container.innerHTML = this.renderBlockContent();
}
// Check if we have a registered handler for this block type
if (this.block) {
const handler = BlockRegistry.getHandler(this.block.type);
if (handler) {
const blockElement = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
if (blockElement) {
handler.setup(blockElement, this.block, this.handlers);
}
return; // Block handler takes care of all setup
}
}
// Handle special block types
// Now find the actual editable block element
const editableBlock = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
// Ensure the block element maintains its content
if (editableBlock) {
editableBlock.setAttribute('data-block-id', this.block.id);
editableBlock.setAttribute('data-block-type', this.block.type);
// Set up all event handlers manually to avoid Lit re-renders
editableBlock.addEventListener('input', (e) => {
this.handlers?.onInput?.(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
editableBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
this.handlers?.onKeyDown?.(e);
});
editableBlock.addEventListener('focus', () => {
this.handlers?.onFocus?.();
});
editableBlock.addEventListener('blur', () => {
this.handlers?.onBlur?.();
});
editableBlock.addEventListener('compositionstart', () => {
this.handlers?.onCompositionStart?.();
});
editableBlock.addEventListener('compositionend', () => {
this.handlers?.onCompositionEnd?.();
});
editableBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Selection will be handled by selectionchange event
this.handlers?.onMouseUp?.(e);
});
editableBlock.addEventListener('click', () => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: '',
blockId: this.block.id,
hasSelection: false
},
bubbles: true,
composed: true
}));
}
return;
}
// Get fresh reference to the editable block
const currentEditableBlock = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!currentEditableBlock) return;
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: selectedText.trim(),
blockId: this.block.id,
range: range,
rect: rect,
hasSelection: true
},
bubbles: true,
composed: true
}));
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: '',
blockId: this.block.id,
hasSelection: false
},
bubbles: true,
composed: true
}));
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
(this as any)._selectionHandler = checkSelection;
// Add keyup handler for cursor position tracking
editableBlock.addEventListener('keyup', () => {
// Track cursor position
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set initial content if needed
if (this.block.content) {
editableBlock.innerHTML = this.block.content;
}
}
// Store reference to the block element for quick access
this.blockElement = editableBlock;
}
render(): TemplateResult {
if (!this.block) return html``;
// Since we need dynamic content, we'll render an empty container
// and set the innerHTML in firstUpdated
return html`<div class="wysiwyg-block-container"></div>`;
}
private renderBlockContent(): string {
if (!this.block) return '';
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler) {
return handler.render(this.block, this.isSelected);
}
// Default rendering for blocks without handlers
const selectedClass = this.isSelected ? ' selected' : '';
return `
<div
class="block ${this.block.type}${selectedClass}"
contenteditable="true"
></div>
`;
}
public focus(): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.focus) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.focus(container, context);
}
// Handle non-editable blocks
if (this.block && DeesWysiwygBlock.NON_EDITABLE_TYPES.includes(this.block.type)) {
const blockElement = this.shadowRoot?.querySelector(`.block.${this.block.type}`) as HTMLDivElement;
if (blockElement) {
blockElement.focus();
}
return;
}
// Get the actual editable element
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
// Ensure the element is focusable
if (!editableElement.hasAttribute('contenteditable')) {
editableElement.setAttribute('contenteditable', 'true');
}
editableElement.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== editableElement && this.shadowRoot?.activeElement !== editableElement) {
Promise.resolve().then(() => {
editableElement.focus();
});
}
}
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.focusWithCursor) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.focusWithCursor(container, position, context);
}
// Non-editable blocks don't support cursor positioning
if (this.block && DeesWysiwygBlock.NON_EDITABLE_TYPES.includes(this.block.type)) {
this.focus();
return;
}
// Get the actual editable element
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
// Ensure element is focusable first
if (!editableElement.hasAttribute('contenteditable')) {
editableElement.setAttribute('contenteditable', 'true');
}
// Focus the element
editableElement.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart();
} else if (position === 'end') {
this.setCursorToEnd();
} else if (typeof position === 'number') {
// Use the new selection utility to set cursor position
WysiwygSelection.setCursorPosition(editableElement, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
setCursor();
}
});
}
}
/**
* Get cursor position in the editable element
*/
public getCursorPosition(element: HTMLElement): number | null {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.getCursorPosition) {
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.getCursorPosition(element, context);
}
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getCursorPosition: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getCursorPosition: No selection found');
return null;
}
console.log('getCursorPosition: Range info:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
collapsed: selectionInfo.collapsed,
startContainerText: selectionInfo.startContainer.textContent
});
if (!element.contains(selectionInfo.startContainer)) {
console.log('getCursorPosition: Range not in element');
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
console.log('getCursorPosition: Calculated position:', {
position,
preCaretText: preCaretRange.toString(),
elementText: element.textContent,
elementTextLength: element.textContent?.length
});
return position;
}
public getContent(): string {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.getContent) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.getContent(container, context);
}
// Get the actual editable element
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return '';
// Get the innerHTML which includes formatting tags
const content = editableElement.innerHTML || '';
console.log('Getting content from block:', content);
return content;
}
public setContent(content: string): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.setContent) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.setContent(container, content, context);
}
// Get the actual editable element
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
// Store if we have focus
const hadFocus = document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement;
editableElement.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
editableElement.focus();
}
}
public setCursorToStart(): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.setCursorToStart) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.setCursorToStart(container, context);
}
// Always find the element fresh, don't rely on cached blockElement
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement) {
WysiwygBlocks.setCursorToStart(editableElement);
}
}
public setCursorToEnd(): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.setCursorToEnd) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.setCursorToEnd(container, context);
}
// Always find the element fresh, don't rely on cached blockElement
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement) {
WysiwygBlocks.setCursorToEnd(editableElement);
}
}
/**
* Gets content split at cursor position
*/
public getSplitContent(): { before: string; after: string } | null {
console.log('getSplitContent: Starting...');
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
console.log('getSplitContent: Checking for handler', {
blockType: this.block.type,
hasHandler: !!handler,
hasSplitMethod: !!(handler && handler.getSplitContent)
});
if (handler && handler.getSplitContent) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
console.log('getSplitContent: Found container', {
container: !!container,
containerHTML: container?.innerHTML?.substring(0, 100)
});
const context = {
shadowRoot: this.shadowRoot!,
component: this
};
return handler.getSplitContent(container, context);
}
// Get the actual editable element first
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) {
console.log('getSplitContent: No editable element found');
return null;
}
console.log('getSplitContent: Element info:', {
blockType: this.block.type,
innerHTML: editableElement.innerHTML,
textContent: editableElement.textContent,
textLength: editableElement.textContent?.length
});
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getSplitContent: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
console.log('getSplitContent: Splitting with last known position:', {
pos,
fullTextLength: fullText.length,
before: fullText.substring(0, pos),
after: fullText.substring(pos)
});
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
console.log('getSplitContent: Selection range:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
startContainerInElement: editableElement.contains(selectionInfo.startContainer)
});
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(editableElement, selectionInfo.startContainer)) {
console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// For HTML content, get cursor position first
const cursorPos = this.getCursorPosition(editableElement);
console.log('getSplitContent: Cursor position for HTML split:', cursorPos);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
console.log('getSplitContent: Cursor at start or null, moving all content');
return {
before: '',
after: editableElement.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(editableElement, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(editableElement, editableElement.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
console.log('getSplitContent: Final split result:', {
cursorPos,
beforeHtml,
beforeLength: beforeHtml.length,
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
afterHtml,
afterLength: afterHtml.length,
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
});
return {
before: beforeHtml,
after: afterHtml
};
}
}

View File

@ -0,0 +1,18 @@
export * from './wysiwyg.types.js';
export * from './wysiwyg.interfaces.js';
export * from './wysiwyg.constants.js';
export * from './wysiwyg.styles.js';
export * from './wysiwyg.converters.js';
export * from './wysiwyg.shortcuts.js';
export * from './wysiwyg.blocks.js';
export * from './wysiwyg.formatting.js';
export * from './wysiwyg.selection.js';
export * from './wysiwyg.blockoperations.js';
export * from './wysiwyg.inputhandler.js';
export * from './wysiwyg.keyboardhandler.js';
export * from './wysiwyg.dragdrophandler.js';
export * from './wysiwyg.modalmanager.js';
export * from './wysiwyg.history.js';
export * from './dees-wysiwyg-block.js';
export * from './dees-slash-menu.js';
export * from './dees-formatting-menu.js';

View File

@ -0,0 +1,7 @@
* We don't use lit html logic, no event binding, no nothing, but only use static`` here to handle dom operations ourselves
* We try to have separated concerns in different classes
* We try to have clean concise and managable code
* lets log whats happening, so if something goes wrong, we understand whats happening.
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
* Make sure to hand over correct shodowroots.

View File

@ -0,0 +1,61 @@
# Phase 2 Implementation Summary - Divider Block Migration
## Overview
Successfully migrated the divider block to the new block handler architecture as a proof of concept.
## Changes Made
### 1. Created Block Handler
- **File**: `blocks/content/divider.block.ts`
- Implemented `DividerBlockHandler` class extending `BaseBlockHandler`
- Extracted divider rendering logic from `dees-wysiwyg-block.ts`
- Extracted divider setup logic (event handlers)
- Extracted divider-specific styles
### 2. Registration System
- **File**: `wysiwyg.blockregistration.ts`
- Created registration module that registers all block handlers
- Currently registers only the divider handler
- Includes placeholders for future block types
### 3. Updated Block Component
- **File**: `dees-wysiwyg-block.ts`
- Added import for BlockRegistry and handler types
- Modified `renderBlockContent()` to check registry first
- Modified `firstUpdated()` to use registry for setup
- Added `injectHandlerStyles()` method to inject handler styles dynamically
- Removed hardcoded divider rendering logic
- Removed hardcoded divider styles
- Removed `setupDividerBlock()` method
### 4. Updated Exports
- **File**: `blocks/index.ts`
- Exported `DividerBlockHandler` class
## Key Features Preserved
✅ Visual appearance with gradient and icon
✅ Click to select behavior
✅ Keyboard navigation support (Tab, Arrow keys)
✅ Deletion with backspace/delete
✅ Focus/blur handling
✅ Proper styling for selected state
## Architecture Benefits
1. **Modularity**: Each block type is now self-contained
2. **Maintainability**: Block-specific logic is isolated
3. **Extensibility**: Easy to add new block types
4. **Type Safety**: Proper TypeScript interfaces
5. **Code Reuse**: Common functionality in BaseBlockHandler
## Next Steps
To migrate other block types, follow this pattern:
1. Create handler file in appropriate folder (text/, media/, content/)
2. Extract render logic, setup logic, and styles
3. Register in `wysiwyg.blockregistration.ts`
4. Remove hardcoded logic from `dees-wysiwyg-block.ts`
5. Export from `blocks/index.ts`
## Testing
- Project builds successfully without errors
- Existing tests pass
- Divider blocks render and function identically to before

View File

@ -0,0 +1,75 @@
# Phase 4 Implementation Summary - Heading Blocks Migration
## Overview
Successfully migrated all heading blocks (h1, h2, h3) to the new block handler architecture using a unified HeadingBlockHandler class.
## Changes Made
### 1. Created Unified Heading Handler
- **File**: `blocks/text/heading.block.ts`
- Implemented `HeadingBlockHandler` class extending `BaseBlockHandler`
- Single handler class that accepts heading level (1, 2, or 3) in constructor
- Extracted all heading rendering logic from `dees-wysiwyg-block.ts`
- Extracted heading setup logic with full text editing support:
- Input handling with cursor tracking
- Selection handling with Shadow DOM support
- Focus/blur management
- Composition events for IME support
- Split content functionality
- Extracted all heading-specific styles for all three levels
### 2. Registration Updates
- **File**: `wysiwyg.blockregistration.ts`
- Registered three heading handlers using the same class:
- `heading-1``new HeadingBlockHandler('heading-1')`
- `heading-2``new HeadingBlockHandler('heading-2')`
- `heading-3``new HeadingBlockHandler('heading-3')`
- Updated imports to include HeadingBlockHandler
### 3. Updated Exports
- **File**: `blocks/index.ts`
- Exported `HeadingBlockHandler` class
- Removed TODO comment for heading handler
### 4. Handler Implementation Details
- **Dynamic Level Handling**: The handler determines the heading level from the block type
- **Shared Styles**: All heading levels share the same style method but render different CSS
- **Placeholder Support**: Each level has its own placeholder text
- **Full Text Editing**: Inherits all paragraph-like functionality:
- Cursor position tracking
- Text selection with Shadow DOM awareness
- Content splitting for Enter key handling
- Focus management with cursor positioning
## Key Features Preserved
✅ All three heading levels render with correct styles
✅ Font sizes: h1 (32px), h2 (24px), h3 (20px)
✅ Proper font weights and line heights
✅ Theme-aware colors using cssManager.bdTheme
✅ Contenteditable functionality
✅ Selection and cursor tracking
✅ Keyboard navigation
✅ Focus/blur handling
✅ Placeholder text for empty headings
## Architecture Benefits
1. **Code Reuse**: Single handler class for all heading levels
2. **Consistency**: All headings share the same behavior
3. **Maintainability**: Changes to heading behavior only need to be made once
4. **Type Safety**: Heading level is type-checked at construction
5. **Scalability**: Easy to add more heading levels if needed
## Testing Results
- ✅ TypeScript compilation successful
- ✅ All three heading handlers registered correctly
- ✅ Render method produces correct HTML with proper classes
- ✅ Placeholders set correctly for each level
- ✅ All handlers are instances of HeadingBlockHandler
## Next Steps
Continue with Phase 5 to migrate remaining text blocks:
- Quote block
- Code block
- List block
Each will follow the same pattern but with their specific requirements.

View File

@ -0,0 +1,177 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
export class WysiwygBlockOperations {
private component: IWysiwygComponent;
constructor(component: IWysiwygComponent) {
this.component = component;
}
/**
* Creates a new block with the specified parameters
*/
createBlock(type: IBlock['type'] = 'paragraph', content: string = '', metadata?: any): IBlock {
return {
id: WysiwygShortcuts.generateBlockId(),
type,
content,
...(metadata && { metadata })
};
}
/**
* Inserts a block after the specified block
*/
async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
const blocks = this.component.blocks;
const blockIndex = blocks.findIndex((b: IBlock) => b.id === afterBlock.id);
this.component.blocks = [
...blocks.slice(0, blockIndex + 1),
newBlock,
...blocks.slice(blockIndex + 1)
];
// Insert the new block element programmatically if we have the editor
if (this.component.editorContentRef) {
const afterWrapper = this.component.editorContentRef.querySelector(`[data-block-id="${afterBlock.id}"]`);
if (afterWrapper) {
const newWrapper = this.component.createBlockElement(newBlock);
afterWrapper.insertAdjacentElement('afterend', newWrapper);
}
}
this.component.updateValue();
if (focusNewBlock && newBlock.type !== 'divider') {
// Give DOM time to settle
await new Promise(resolve => setTimeout(resolve, 0));
// Focus the new block
await this.focusBlock(newBlock.id, 'start');
}
}
/**
* Removes a block by its ID
*/
removeBlock(blockId: string): void {
// Save checkpoint before deletion
this.component.saveToHistory(false);
this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId);
// Remove the block element programmatically if we have the editor
if (this.component.editorContentRef) {
const wrapper = this.component.editorContentRef.querySelector(`[data-block-id="${blockId}"]`);
if (wrapper) {
wrapper.remove();
}
}
this.component.updateValue();
}
/**
* Finds a block by its ID
*/
findBlock(blockId: string): IBlock | undefined {
return this.component.blocks.find((b: IBlock) => b.id === blockId);
}
/**
* Gets the index of a block
*/
getBlockIndex(blockId: string): number {
return this.component.blocks.findIndex((b: IBlock) => b.id === blockId);
}
/**
* Focuses a specific block
*/
async focusBlock(blockId: string, cursorPosition: 'start' | 'end' | number = 'start'): Promise<void> {
const wrapperElement = this.component.shadowRoot!.querySelector(`[data-block-id="${blockId}"]`);
if (wrapperElement) {
const blockComponent = wrapperElement.querySelector('dees-wysiwyg-block') as any;
if (blockComponent) {
// Wait a frame to ensure the block is rendered
await new Promise(resolve => requestAnimationFrame(resolve));
// Now focus with cursor position
blockComponent.focusWithCursor(cursorPosition);
}
}
}
/**
* Updates the content of a block
*/
updateBlockContent(blockId: string, content: string): void {
const block = this.findBlock(blockId);
if (block) {
block.content = content;
this.component.updateValue();
}
}
/**
* Transforms a block to a different type
*/
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void {
const block = this.findBlock(blockId);
if (block) {
// Save checkpoint before transformation
this.component.saveToHistory(false);
block.type = newType;
block.content = '';
if (metadata) {
block.metadata = metadata;
}
// Update the block element programmatically if we have the editor
if (this.component.editorContentRef) {
this.component.updateBlockElement(blockId);
}
this.component.updateValue();
}
}
/**
* Moves a block to a new position
*/
moveBlock(blockId: string, targetIndex: number): void {
const blocks = [...this.component.blocks];
const currentIndex = this.getBlockIndex(blockId);
if (currentIndex === -1 || targetIndex < 0 || targetIndex >= blocks.length) {
return;
}
const [movedBlock] = blocks.splice(currentIndex, 1);
blocks.splice(targetIndex, 0, movedBlock);
this.component.blocks = blocks;
this.component.updateValue();
}
/**
* Gets the previous block
*/
getPreviousBlock(blockId: string): IBlock | null {
const index = this.getBlockIndex(blockId);
return index > 0 ? this.component.blocks[index - 1] : null;
}
/**
* Gets the next block
*/
getNextBlock(blockId: string): IBlock | null {
const index = this.getBlockIndex(blockId);
return index < this.component.blocks.length - 1 ? this.component.blocks[index + 1] : null;
}
}

View File

@ -0,0 +1,59 @@
/**
* Block Registration Module
* Handles registration of all block handlers with the BlockRegistry
*
* Phase 2 Complete: Divider block has been successfully migrated
* to the new block handler architecture.
* Phase 3 Complete: Paragraph block has been successfully migrated
* to the new block handler architecture.
* Phase 4 Complete: All heading blocks (h1, h2, h3) have been successfully migrated
* to the new block handler architecture using a unified HeadingBlockHandler.
* Phase 5 Complete: Quote, Code, and List blocks have been successfully migrated
* to the new block handler architecture.
* Phase 6 Complete: Image, YouTube, and Attachment blocks have been successfully migrated
* to the new block handler architecture.
* Phase 7 Complete: Markdown and HTML blocks have been successfully migrated
* to the new block handler architecture.
*/
import {
BlockRegistry,
DividerBlockHandler,
ParagraphBlockHandler,
HeadingBlockHandler,
QuoteBlockHandler,
CodeBlockHandler,
ListBlockHandler,
ImageBlockHandler,
YouTubeBlockHandler,
AttachmentBlockHandler,
MarkdownBlockHandler,
HtmlBlockHandler
} from './blocks/index.js';
// Initialize and register all block handlers
export function registerAllBlockHandlers(): void {
// Register content blocks
BlockRegistry.register('divider', new DividerBlockHandler());
// Register text blocks
BlockRegistry.register('paragraph', new ParagraphBlockHandler());
BlockRegistry.register('heading-1', new HeadingBlockHandler('heading-1'));
BlockRegistry.register('heading-2', new HeadingBlockHandler('heading-2'));
BlockRegistry.register('heading-3', new HeadingBlockHandler('heading-3'));
BlockRegistry.register('quote', new QuoteBlockHandler());
BlockRegistry.register('code', new CodeBlockHandler());
BlockRegistry.register('list', new ListBlockHandler());
// Register media blocks
BlockRegistry.register('image', new ImageBlockHandler());
BlockRegistry.register('youtube', new YouTubeBlockHandler());
BlockRegistry.register('attachment', new AttachmentBlockHandler());
// Register other content blocks
BlockRegistry.register('markdown', new MarkdownBlockHandler());
BlockRegistry.register('html', new HtmlBlockHandler());
}
// Ensure blocks are registered when this module is imported
registerAllBlockHandlers();

View File

@ -0,0 +1,202 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygConverters } from './wysiwyg.converters.js';
export class WysiwygBlocks {
static renderListContent(content: string, metadata?: any): string {
const items = content.split('\n').filter(item => item.trim());
if (items.length === 0) return '';
const listTag = metadata?.listType === 'ordered' ? 'ol' : 'ul';
// Don't escape HTML to preserve formatting
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
}
static renderBlock(
block: IBlock,
isSelected: boolean,
handlers: {
onInput: (e: InputEvent) => void;
onKeyDown: (e: KeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void;
}
): TemplateResult {
if (block.type === 'divider') {
return html`
<div
class="block divider"
data-block-id="${block.id}"
>
<hr>
</div>
`;
}
if (block.type === 'list') {
return html`
<div
class="block list ${isSelected ? 'selected' : ''}"
data-block-id="${block.id}"
contenteditable="true"
@input="${handlers.onInput}"
@keydown="${handlers.onKeyDown}"
@focus="${handlers.onFocus}"
@blur="${handlers.onBlur}"
@compositionstart="${handlers.onCompositionStart}"
@compositionend="${handlers.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
console.log('Block mouseup event fired');
if (handlers.onMouseUp) handlers.onMouseUp(e);
}}"
.innerHTML="${this.renderListContent(block.content, block.metadata)}"
></div>
`;
}
// Special rendering for code blocks with language indicator
if (block.type === 'code') {
const language = block.metadata?.language || 'plain text';
return html`
<div class="code-block-container">
<div class="code-language">${language}</div>
<div
class="block ${block.type} ${isSelected ? 'selected' : ''}"
contenteditable="true"
@input="${handlers.onInput}"
@keydown="${handlers.onKeyDown}"
@focus="${handlers.onFocus}"
@blur="${handlers.onBlur}"
@compositionstart="${handlers.onCompositionStart}"
@compositionend="${handlers.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
console.log('Block mouseup event fired');
if (handlers.onMouseUp) handlers.onMouseUp(e);
}}"
.textContent="${block.content || ''}"
></div>
</div>
`;
}
const blockElement = html`
<div
class="block ${block.type} ${isSelected ? 'selected' : ''}"
contenteditable="true"
@input="${handlers.onInput}"
@keydown="${handlers.onKeyDown}"
@focus="${handlers.onFocus}"
@blur="${handlers.onBlur}"
@compositionstart="${handlers.onCompositionStart}"
@compositionend="${handlers.onCompositionEnd}"
@mouseup="${(e: MouseEvent) => {
console.log('Block mouseup event fired');
if (handlers.onMouseUp) handlers.onMouseUp(e);
}}"
.innerHTML="${block.content || ''}"
></div>
`;
return blockElement;
}
static setCursorToEnd(element: HTMLElement): void {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
// Handle different content types
if (element.childNodes.length === 0) {
// Empty element - add a zero-width space to enable cursor
const textNode = document.createTextNode('\u200B');
element.appendChild(textNode);
range.setStart(textNode, 1);
range.collapse(true);
} else {
// Find the last text node or element
const lastNode = this.getLastNode(element);
if (lastNode.nodeType === Node.TEXT_NODE) {
range.setStart(lastNode, lastNode.textContent?.length || 0);
} else {
range.setStartAfter(lastNode);
}
range.collapse(true);
}
sel.removeAllRanges();
sel.addRange(range);
// Remove zero-width space if it was added
if (element.textContent === '\u200B') {
element.textContent = '';
}
}
static setCursorToStart(element: HTMLElement): void {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
// Handle different content types
if (element.childNodes.length === 0) {
// Empty element
range.setStart(element, 0);
range.collapse(true);
} else {
// Find the first text node or element
const firstNode = this.getFirstNode(element);
if (firstNode.nodeType === Node.TEXT_NODE) {
range.setStart(firstNode, 0);
} else {
range.setStartBefore(firstNode);
}
range.collapse(true);
}
sel.removeAllRanges();
sel.addRange(range);
}
private static getLastNode(element: Node): Node {
if (element.childNodes.length === 0) {
return element;
}
const lastChild = element.childNodes[element.childNodes.length - 1];
if (lastChild.nodeType === Node.TEXT_NODE || lastChild.childNodes.length === 0) {
return lastChild;
}
return this.getLastNode(lastChild);
}
private static getFirstNode(element: Node): Node {
if (element.childNodes.length === 0) {
return element;
}
const firstChild = element.childNodes[0];
if (firstChild.nodeType === Node.TEXT_NODE || firstChild.childNodes.length === 0) {
return firstChild;
}
return this.getFirstNode(firstChild);
}
static focusListItem(listElement: HTMLElement): void {
const firstLi = listElement.querySelector('li');
if (firstLi) {
firstLi.focus();
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(firstLi);
range.collapse(true);
sel!.removeAllRanges();
sel!.addRange(range);
}
}
}

View File

@ -0,0 +1,27 @@
/**
* Shared constants for the WYSIWYG editor
*/
/**
* Available programming languages for code blocks
*/
export const PROGRAMMING_LANGUAGES = [
'JavaScript',
'TypeScript',
'Python',
'Java',
'C++',
'C#',
'Go',
'Rust',
'HTML',
'CSS',
'SQL',
'Shell',
'JSON',
'YAML',
'Markdown',
'Plain Text'
] as const;
export type ProgrammingLanguage = typeof PROGRAMMING_LANGUAGES[number];

View File

@ -0,0 +1,329 @@
import { type IBlock } from './wysiwyg.types.js';
export class WysiwygConverters {
static escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
static formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
static getHtmlOutput(blocks: IBlock[]): string {
return blocks.map(block => {
// Check if content already contains HTML formatting
const content = block.content.includes('<') && block.content.includes('>')
? block.content // Already contains HTML formatting
: this.escapeHtml(block.content);
switch (block.type) {
case 'paragraph':
return block.content ? `<p>${content}</p>` : '';
case 'heading-1':
return `<h1>${content}</h1>`;
case 'heading-2':
return `<h2>${content}</h2>`;
case 'heading-3':
return `<h3>${content}</h3>`;
case 'quote':
return `<blockquote>${content}</blockquote>`;
case 'code':
return `<pre><code>${this.escapeHtml(block.content)}</code></pre>`;
case 'list':
const items = block.content.split('\n').filter(item => item.trim());
if (items.length > 0) {
const listTag = block.metadata?.listType === 'ordered' ? 'ol' : 'ul';
// Don't escape HTML in list items to preserve formatting
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
}
return '';
case 'divider':
return '<hr>';
case 'image':
const imageUrl = block.metadata?.url;
if (imageUrl) {
const altText = this.escapeHtml(block.content || 'Image');
return `<img src="${imageUrl}" alt="${altText}" />`;
}
return '';
case 'youtube':
const videoId = block.metadata?.videoId;
if (videoId) {
return `<iframe width="560" height="315" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
}
return '';
case 'markdown':
// Return the raw markdown content wrapped in a div
return `<div class="markdown-content">${this.escapeHtml(block.content)}</div>`;
case 'html':
// Return the raw HTML content (already HTML)
return block.content;
case 'attachment':
const files = block.metadata?.files || [];
if (files.length > 0) {
return `<div class="attachments">${files.map((file: any) =>
`<div class="attachment-item" data-file-id="${file.id}">
<a href="${file.data}" download="${file.name}">${this.escapeHtml(file.name)}</a>
<span class="file-size">(${this.formatFileSize(file.size)})</span>
</div>`
).join('')}</div>`;
}
return '';
default:
return `<p>${content}</p>`;
}
}).filter(html => html !== '').join('\n');
}
static getMarkdownOutput(blocks: IBlock[]): string {
return blocks.map(block => {
switch (block.type) {
case 'paragraph':
return block.content;
case 'heading-1':
return `# ${block.content}`;
case 'heading-2':
return `## ${block.content}`;
case 'heading-3':
return `### ${block.content}`;
case 'quote':
return `> ${block.content}`;
case 'code':
return `\`\`\`\n${block.content}\n\`\`\``;
case 'list':
const items = block.content.split('\n').filter(item => item.trim());
if (block.metadata?.listType === 'ordered') {
return items.map((item, index) => `${index + 1}. ${item}`).join('\n');
} else {
return items.map(item => `- ${item}`).join('\n');
}
case 'divider':
return '---';
case 'image':
const imageUrl = block.metadata?.url;
const altText = block.content || 'Image';
return imageUrl ? `![${altText}](${imageUrl})` : '';
case 'youtube':
const videoId = block.metadata?.videoId;
const url = block.metadata?.url || (videoId ? `https://youtube.com/watch?v=${videoId}` : '');
return url ? `[YouTube Video](${url})` : '';
case 'markdown':
// Return the raw markdown content
return block.content;
case 'html':
// Return as HTML comment in markdown
return `<!-- HTML Block\n${block.content}\n-->`;
case 'attachment':
const files = block.metadata?.files || [];
if (files.length > 0) {
return files.map((file: any) => `- [${file.name}](${file.data})`).join('\n');
}
return '';
default:
return block.content;
}
}).filter(md => md !== '').join('\n\n');
}
static parseHtmlToBlocks(html: string): IBlock[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const blocks: IBlock[] = [];
const processNode = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'paragraph',
content: node.textContent.trim(),
});
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
const tagName = element.tagName.toLowerCase();
switch (tagName) {
case 'p':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'paragraph',
content: element.innerHTML || '',
});
break;
case 'h1':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-1',
content: element.innerHTML || '',
});
break;
case 'h2':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-2',
content: element.innerHTML || '',
});
break;
case 'h3':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-3',
content: element.innerHTML || '',
});
break;
case 'blockquote':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'quote',
content: element.innerHTML || '',
});
break;
case 'pre':
case 'code':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'code',
content: element.textContent || '',
});
break;
case 'ul':
case 'ol':
const listItems = Array.from(element.querySelectorAll('li'));
// Use innerHTML to preserve formatting
const content = listItems.map(li => li.innerHTML || '').join('\n');
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'list',
content: content,
metadata: { listType: tagName === 'ol' ? 'ordered' : 'bullet' }
});
break;
case 'hr':
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'divider',
content: ' ',
});
break;
case 'img':
const imgElement = element as HTMLImageElement;
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'image',
content: imgElement.alt || '',
metadata: { url: imgElement.src }
});
break;
default:
// Process children for other elements
element.childNodes.forEach(child => processNode(child));
}
}
};
doc.body.childNodes.forEach(node => processNode(node));
return blocks;
}
static parseMarkdownToBlocks(markdown: string): IBlock[] {
const lines = markdown.split('\n');
const blocks: IBlock[] = [];
let currentListItems: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('# ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-1',
content: line.substring(2),
});
} else if (line.startsWith('## ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-2',
content: line.substring(3),
});
} else if (line.startsWith('### ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'heading-3',
content: line.substring(4),
});
} else if (line.startsWith('> ')) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'quote',
content: line.substring(2),
});
} else if (line.startsWith('```')) {
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'code',
content: codeLines.join('\n'),
});
} else if (line.match(/^(\*|-) /)) {
currentListItems.push(line.substring(2));
// Check if next line is not a list item
if (i === lines.length - 1 || (!lines[i + 1].match(/^(\*|-) /))) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'list',
content: currentListItems.join('\n'),
metadata: { listType: 'bullet' }
});
currentListItems = [];
}
} else if (line.match(/^\d+\. /)) {
currentListItems.push(line.replace(/^\d+\. /, ''));
// Check if next line is not a numbered list item
if (i === lines.length - 1 || (!lines[i + 1].match(/^\d+\. /))) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'list',
content: currentListItems.join('\n'),
metadata: { listType: 'ordered' }
});
currentListItems = [];
}
} else if (line === '---' || line === '***' || line === '___') {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'divider',
content: ' ',
});
} else if (line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/)) {
// Parse markdown image syntax ![alt](url)
const match = line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/);
if (match) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'image',
content: match[1] || '',
metadata: { url: match[2] }
});
}
} else if (line.trim()) {
blocks.push({
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
type: 'paragraph',
content: line,
});
}
}
return blocks;
}
}

View File

@ -0,0 +1,405 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
export class WysiwygDragDropHandler {
private component: IWysiwygComponent;
private draggedBlockId: string | null = null;
private dragOverBlockId: string | null = null;
private dragOverPosition: 'before' | 'after' | null = null;
private dropIndicator: HTMLElement | null = null;
private initialMouseY: number = 0;
private initialBlockY: number = 0;
private draggedBlockElement: HTMLElement | null = null;
private draggedBlockHeight: number = 0;
private lastUpdateTime: number = 0;
private updateThrottle: number = 80; // milliseconds
constructor(component: IWysiwygComponent) {
this.component = component;
}
/**
* Gets the current drag state
*/
get dragState() {
return {
draggedBlockId: this.draggedBlockId,
dragOverBlockId: this.dragOverBlockId,
dragOverPosition: this.dragOverPosition
};
}
/**
* Handles drag start
*/
handleDragStart(e: DragEvent, block: IBlock): void {
if (!e.dataTransfer) return;
this.draggedBlockId = block.id;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', block.id);
// Hide the default drag image
const emptyImg = new Image();
emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=';
e.dataTransfer.setDragImage(emptyImg, 0, 0);
// Store initial mouse position and block element
this.initialMouseY = e.clientY;
this.draggedBlockElement = this.component.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
if (this.draggedBlockElement) {
const rect = this.draggedBlockElement.getBoundingClientRect();
this.draggedBlockHeight = rect.height;
this.initialBlockY = rect.top;
// Create drop indicator
this.createDropIndicator();
// Set up drag event listeners
document.addEventListener('dragover', this.handleGlobalDragOver);
document.addEventListener('dragend', this.handleGlobalDragEnd);
}
// Update component state
this.component.draggedBlockId = this.draggedBlockId;
// Add dragging class after a small delay
setTimeout(() => {
if (this.draggedBlockElement) {
this.draggedBlockElement.classList.add('dragging');
}
if (this.component.editorContentRef) {
this.component.editorContentRef.classList.add('dragging');
}
}, 10);
}
/**
* Handles drag end
*/
handleDragEnd(): void {
// Clean up visual state
const allBlocks = this.component.editorContentRef.querySelectorAll('.block-wrapper');
allBlocks.forEach((block: HTMLElement) => {
block.classList.remove('dragging', 'move-up', 'move-down');
block.style.removeProperty('--drag-offset');
block.style.removeProperty('transform');
});
// Remove dragging class from editor
if (this.component.editorContentRef) {
this.component.editorContentRef.classList.remove('dragging');
}
// Reset drag state
this.draggedBlockId = null;
this.dragOverBlockId = null;
this.dragOverPosition = null;
this.draggedBlockElement = null;
this.draggedBlockHeight = 0;
this.initialBlockY = 0;
// Update component state
this.component.draggedBlockId = null;
this.component.dragOverBlockId = null;
this.component.dragOverPosition = null;
}
/**
* Handles drag over
*/
handleDragOver(e: DragEvent, block: IBlock): void {
e.preventDefault();
if (!e.dataTransfer || !this.draggedBlockId || this.draggedBlockId === block.id) return;
e.dataTransfer.dropEffect = 'move';
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
this.dragOverBlockId = block.id;
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
// Update component state
this.component.dragOverBlockId = this.dragOverBlockId;
this.component.dragOverPosition = this.dragOverPosition;
// The parent component already handles drag-over classes programmatically
}
/**
* Handles drag leave
*/
handleDragLeave(block: IBlock): void {
if (this.dragOverBlockId === block.id) {
this.dragOverBlockId = null;
this.dragOverPosition = null;
// Update component state
this.component.dragOverBlockId = null;
this.component.dragOverPosition = null;
// The parent component already handles removing drag-over classes programmatically
}
}
/**
* Handles drop
*/
handleDrop(e: DragEvent, targetBlock: IBlock): void {
e.preventDefault();
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
// The parent component already has a handleDrop method that handles this programmatically
// We'll delegate to that to ensure proper programmatic rendering
this.component.handleDrop(e, targetBlock);
}
/**
* Checks if a block is being dragged
*/
isDragging(blockId: string): boolean {
return this.draggedBlockId === blockId;
}
/**
* Checks if a block has drag over state
*/
isDragOver(blockId: string): boolean {
return this.dragOverBlockId === blockId;
}
/**
* Gets drag over CSS classes for a block
*/
getDragOverClasses(blockId: string): string {
if (!this.isDragOver(blockId)) return '';
return this.dragOverPosition === 'before' ? 'drag-over-before' : 'drag-over-after';
}
/**
* Creates the drop indicator element
*/
private createDropIndicator(): void {
this.dropIndicator = document.createElement('div');
this.dropIndicator.className = 'drop-indicator';
this.dropIndicator.style.display = 'none';
this.component.editorContentRef.appendChild(this.dropIndicator);
}
/**
* Handles global dragover to update dragged block position and move other blocks
*/
private handleGlobalDragOver = (e: DragEvent): void => {
e.preventDefault();
if (!this.draggedBlockElement) return;
// Calculate vertical offset from initial position
const deltaY = e.clientY - this.initialMouseY;
// Apply transform to move the dragged block vertically
this.draggedBlockElement.style.transform = `translateY(${deltaY}px)`;
// Throttle position updates to reduce stuttering
const now = Date.now();
if (now - this.lastUpdateTime < this.updateThrottle) {
return;
}
this.lastUpdateTime = now;
// Calculate which blocks should move
this.updateBlockPositions(e.clientY);
};
/**
* Updates block positions based on cursor position
*/
private updateBlockPositions(mouseY: number): void {
const blocks = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[];
const draggedIndex = blocks.findIndex(b => b.getAttribute('data-block-id') === this.draggedBlockId);
if (draggedIndex === -1) return;
// Reset all transforms first (except the dragged block)
blocks.forEach(block => {
if (block.getAttribute('data-block-id') !== this.draggedBlockId) {
block.classList.remove('move-up', 'move-down');
block.style.removeProperty('--drag-offset');
}
});
// Calculate where the dragged block should be inserted
let newIndex = blocks.length; // Default to end
for (let i = 0; i < blocks.length; i++) {
if (i === draggedIndex) continue;
const block = blocks[i];
const rect = block.getBoundingClientRect();
const blockTop = rect.top;
// Check if mouse is above this block's middle
if (mouseY < blockTop + (rect.height * 0.5)) {
newIndex = i;
break;
}
}
// Apply transforms to move blocks out of the way
for (let i = 0; i < blocks.length; i++) {
if (i === draggedIndex) continue;
const block = blocks[i];
// Determine if this block needs to move
if (draggedIndex < newIndex) {
// Dragging down: blocks between original and new position move up
if (i > draggedIndex && i < newIndex) {
block.classList.add('move-up');
block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`);
}
} else if (draggedIndex > newIndex) {
// Dragging up: blocks between new and original position move down
if (i >= newIndex && i < draggedIndex) {
block.classList.add('move-down');
block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`);
}
}
}
// Update drop indicator position
this.updateDropIndicator(blocks, newIndex, draggedIndex);
}
/**
* Updates the drop indicator position
*/
private updateDropIndicator(blocks: HTMLElement[], targetIndex: number, draggedIndex: number): void {
if (!this.dropIndicator || !this.draggedBlockElement) return;
this.dropIndicator.style.display = 'block';
this.dropIndicator.style.height = `${this.draggedBlockHeight}px`;
const containerRect = this.component.editorContentRef.getBoundingClientRect();
// Calculate where the block will actually land
let topPosition = 0;
if (targetIndex === 0) {
// Before first block
topPosition = 0;
} else {
// After a specific block
const prevIndex = targetIndex - 1;
let blockCount = 0;
// Find the visual position of the block that will be before our dropped block
for (let i = 0; i < blocks.length; i++) {
if (i === draggedIndex) continue; // Skip the dragged block
if (blockCount === prevIndex) {
const rect = blocks[i].getBoundingClientRect();
topPosition = rect.bottom - containerRect.top + 16; // 16px gap
break;
}
blockCount++;
}
}
this.dropIndicator.style.top = `${topPosition}px`;
}
/**
* Handles global drag end
*/
private handleGlobalDragEnd = (): void => {
// Clean up event listeners
document.removeEventListener('dragover', this.handleGlobalDragOver);
document.removeEventListener('dragend', this.handleGlobalDragEnd);
// Remove drop indicator
if (this.dropIndicator) {
this.dropIndicator.remove();
this.dropIndicator = null;
}
// Trigger the actual drop if we have a dragged block
if (this.draggedBlockId) {
// Small delay to ensure transforms are applied
requestAnimationFrame(() => {
this.performDrop();
// Call the regular drag end handler after drop
this.handleDragEnd();
});
} else {
// Call the regular drag end handler
this.handleDragEnd();
}
};
/**
* Performs the actual drop operation
*/
private performDrop(): void {
if (!this.draggedBlockId) return;
// Get the visual order of blocks based on their positions
const blockElements = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[];
const draggedElement = blockElements.find(el => el.getAttribute('data-block-id') === this.draggedBlockId);
if (!draggedElement) return;
// Create an array of blocks with their visual positions
const visualOrder = blockElements.map(el => {
const id = el.getAttribute('data-block-id');
const rect = el.getBoundingClientRect();
const centerY = rect.top + rect.height / 2;
return { id, centerY, element: el };
});
// Sort by visual Y position
visualOrder.sort((a, b) => a.centerY - b.centerY);
// Get the new order of block IDs
const newBlockIds = visualOrder.map(item => item.id).filter(id => id !== null);
// Find the original block data
const originalBlocks = [...this.component.blocks];
const draggedBlock = originalBlocks.find(b => b.id === this.draggedBlockId);
if (!draggedBlock) return;
// Check if order actually changed
const oldOrder = originalBlocks.map(b => b.id);
const orderChanged = !newBlockIds.every((id, index) => id === oldOrder[index]);
if (!orderChanged) {
return;
}
// Reorder blocks based on visual positions
const newBlocks = newBlockIds.map(id => originalBlocks.find(b => b.id === id)!).filter(Boolean);
// Update blocks
this.component.blocks = newBlocks;
// Re-render blocks programmatically
this.component.renderBlocksProgrammatically();
// Update value
this.component.updateValue();
// Focus the moved block after a delay
setTimeout(() => {
if (draggedBlock.type !== 'divider') {
this.component.blockOperations.focusBlock(draggedBlock.id);
}
}, 100);
}
}

View File

@ -0,0 +1,369 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import { WysiwygSelection } from './wysiwyg.selection.js';
export interface IFormatButton {
command: string;
icon: string;
label: string;
shortcut?: string;
action?: () => void;
}
/**
* Handles text formatting with smart toggle behavior:
* - If selection contains ANY instance of a format, removes ALL instances
* - If selection has no formatting, applies the format
* - Works correctly with Shadow DOM using range-based operations
*/
export class WysiwygFormatting {
static readonly formatButtons: IFormatButton[] = [
{ command: 'bold', icon: 'B', label: 'Bold', shortcut: '⌘B' },
{ command: 'italic', icon: 'I', label: 'Italic', shortcut: '⌘I' },
{ command: 'underline', icon: 'U', label: 'Underline', shortcut: '⌘U' },
{ command: 'strikeThrough', icon: 'S̶', label: 'Strikethrough' },
{ command: 'code', icon: '{ }', label: 'Inline Code' },
{ command: 'link', icon: '🔗', label: 'Link', shortcut: '⌘K' },
];
static renderFormattingMenu(
position: { x: number; y: number },
onFormat: (command: string) => void
): TemplateResult {
return html`
<div
class="formatting-menu"
style="top: ${position.y}px; left: ${position.x}px;"
@mousedown="${(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}"
@click="${(e: MouseEvent) => e.stopPropagation()}"
>
${this.formatButtons.map(button => html`
<button
class="format-button ${button.command}"
@click="${() => onFormat(button.command)}"
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
>
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
</button>
`)}
</div>
`;
}
static applyFormat(command: string, value?: string, range?: Range, shadowRoots?: ShadowRoot[]): boolean {
// If range is provided, use it directly (Shadow DOM case)
// Otherwise fall back to window.getSelection()
let workingRange: Range;
if (range) {
workingRange = range;
} else {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
workingRange = selection.getRangeAt(0);
}
// Apply format based on command
switch (command) {
case 'bold':
this.wrapSelection(workingRange, 'strong');
break;
case 'italic':
this.wrapSelection(workingRange, 'em');
break;
case 'underline':
this.wrapSelection(workingRange, 'u');
break;
case 'strikeThrough':
this.wrapSelection(workingRange, 's');
break;
case 'code':
this.wrapSelection(workingRange, 'code');
break;
case 'link':
// Don't use prompt - return false to indicate we need async input
if (!value) {
return false;
}
this.wrapSelectionWithLink(workingRange, value);
break;
}
// If we have shadow roots, use our Shadow DOM selection utility
if (shadowRoots && shadowRoots.length > 0) {
WysiwygSelection.setSelectionFromRange(workingRange);
} else {
// Regular selection restoration
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(workingRange);
}
}
return true;
}
private static wrapSelection(range: Range, tagName: string): void {
const selection = window.getSelection();
if (!selection) return;
// Check if ANY part of the selection contains this formatting
const hasFormatting = this.selectionContainsTag(range, tagName);
if (hasFormatting) {
// Remove all instances of this tag from the selection
this.removeTagFromSelection(range, tagName);
} else {
// Wrap selection with the tag
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);
}
}
}
/**
* Check if the selection contains or is within any instances of a tag
*/
private static selectionContainsTag(range: Range, tagName: string): boolean {
// First check: Are we inside a tag? (even if selection doesn't include the tag)
let node: Node | null = range.startContainer;
while (node && node !== range.commonAncestorContainer.ownerDocument) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (element.tagName.toLowerCase() === tagName) {
return true;
}
}
node = node.parentNode;
}
// Also check the end container
node = range.endContainer;
while (node && node !== range.commonAncestorContainer.ownerDocument) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (element.tagName.toLowerCase() === tagName) {
return true;
}
}
node = node.parentNode;
}
// Second check: Does the selection contain any complete tags?
const tempDiv = document.createElement('div');
const contents = range.cloneContents();
tempDiv.appendChild(contents);
const tags = tempDiv.getElementsByTagName(tagName);
return tags.length > 0;
}
/**
* Remove all instances of a tag from the selection
*/
private static removeTagFromSelection(range: Range, tagName: string): void {
const selection = window.getSelection();
if (!selection) return;
// Special handling: Check if we need to expand the selection to include parent tags
let expandedRange = range.cloneRange();
// Check if start is inside a tag
let startNode: Node | null = range.startContainer;
let startTag: Element | null = null;
while (startNode && startNode !== range.commonAncestorContainer.ownerDocument) {
if (startNode.nodeType === Node.ELEMENT_NODE && (startNode as Element).tagName.toLowerCase() === tagName) {
startTag = startNode as Element;
break;
}
startNode = startNode.parentNode;
}
// Check if end is inside a tag
let endNode: Node | null = range.endContainer;
let endTag: Element | null = null;
while (endNode && endNode !== range.commonAncestorContainer.ownerDocument) {
if (endNode.nodeType === Node.ELEMENT_NODE && (endNode as Element).tagName.toLowerCase() === tagName) {
endTag = endNode as Element;
break;
}
endNode = endNode.parentNode;
}
// Expand range to include the tags if needed
if (startTag) {
expandedRange.setStartBefore(startTag);
}
if (endTag) {
expandedRange.setEndAfter(endTag);
}
// Extract the contents using the expanded range
const fragment = expandedRange.extractContents();
// Process the fragment to remove tags
const processedFragment = this.removeTagsFromFragment(fragment, tagName);
// Insert the processed content back
expandedRange.insertNode(processedFragment);
// Restore selection to match the original selection intent
// Find the text nodes that correspond to the original selection
const textNodes: Node[] = [];
const walker = document.createTreeWalker(
processedFragment,
NodeFilter.SHOW_TEXT,
null
);
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
if (textNodes.length > 0) {
const newRange = document.createRange();
newRange.setStart(textNodes[0], 0);
newRange.setEnd(textNodes[textNodes.length - 1], textNodes[textNodes.length - 1].textContent?.length || 0);
selection.removeAllRanges();
selection.addRange(newRange);
}
}
/**
* Remove all instances of a tag from a document fragment
*/
private static removeTagsFromFragment(fragment: DocumentFragment, tagName: string): DocumentFragment {
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
// Find all instances of the tag
const tags = tempDiv.getElementsByTagName(tagName);
// Convert to array to avoid live collection issues
const tagArray = Array.from(tags);
// Unwrap each tag
tagArray.forEach(tag => {
const parent = tag.parentNode;
if (parent) {
// Move all children out of the tag
while (tag.firstChild) {
parent.insertBefore(tag.firstChild, tag);
}
// Remove the empty tag
parent.removeChild(tag);
}
});
// Create a new fragment from the processed content
const newFragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
newFragment.appendChild(tempDiv.firstChild);
}
return newFragment;
}
private static wrapSelectionWithLink(range: Range, url: string): void {
const selection = window.getSelection();
if (!selection) return;
// First remove any existing links in the selection
if (this.selectionContainsTag(range, 'a')) {
this.removeTagFromSelection(range, 'a');
// Re-get the range after modification
if (selection.rangeCount > 0) {
range = selection.getRangeAt(0);
}
}
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(...shadowRoots: ShadowRoot[]): { x: number, y: number } | null {
// Get selection info using the new utility that handles Shadow DOM
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getSelectionCoordinates - selectionInfo:', selectionInfo);
if (!selectionInfo) {
console.log('No selection info available');
return null;
}
// Create a range from the selection info to get bounding rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
console.log('Range rect:', rect);
if (rect.width === 0 && rect.height === 0) {
console.log('Rect width and height are 0, trying different approach');
// Sometimes the rect is collapsed, let's try getting the caret position
if ('caretPositionFromPoint' in document) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const tempSpan = document.createElement('span');
tempSpan.textContent = '\u200B'; // Zero-width space
range.insertNode(tempSpan);
const spanRect = tempSpan.getBoundingClientRect();
tempSpan.remove();
if (spanRect.width > 0 || spanRect.height > 0) {
const coords = {
x: spanRect.left,
y: Math.max(45, spanRect.top - 45)
};
console.log('Used span trick for coords:', coords);
return coords;
}
}
}
return null;
}
const coords = {
x: rect.left + (rect.width / 2),
y: Math.max(45, rect.top - 45) // Position above selection, but ensure it's not negative
};
console.log('Returning coords:', coords);
return coords;
}
}

View File

@ -0,0 +1,167 @@
import { type IBlock } from './wysiwyg.types.js';
export interface IHistoryState {
blocks: IBlock[];
selectedBlockId: string | null;
cursorPosition?: {
blockId: string;
offset: number;
};
timestamp: number;
}
export class WysiwygHistory {
private history: IHistoryState[] = [];
private currentIndex: number = -1;
private maxHistorySize: number = 50;
private lastSaveTime: number = 0;
private saveDebounceMs: number = 500; // Debounce saves to avoid too many snapshots
constructor() {
// Initialize with empty state
this.history = [];
this.currentIndex = -1;
}
/**
* Save current state to history
*/
saveState(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
const now = Date.now();
// Debounce rapid changes (like typing)
if (now - this.lastSaveTime < this.saveDebounceMs && this.currentIndex >= 0) {
// Update the current state instead of creating a new one
this.history[this.currentIndex] = {
blocks: this.cloneBlocks(blocks),
selectedBlockId,
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
timestamp: now
};
return;
}
// Remove any states after current index (when we save after undoing)
if (this.currentIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.currentIndex + 1);
}
// Add new state
const newState: IHistoryState = {
blocks: this.cloneBlocks(blocks),
selectedBlockId,
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
timestamp: now
};
this.history.push(newState);
this.currentIndex++;
// Limit history size
if (this.history.length > this.maxHistorySize) {
this.history.shift();
this.currentIndex--;
}
this.lastSaveTime = now;
}
/**
* Force save a checkpoint (useful for operations like block deletion)
*/
saveCheckpoint(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
this.lastSaveTime = 0; // Reset debounce
this.saveState(blocks, selectedBlockId, cursorPosition);
}
/**
* Undo to previous state
*/
undo(): IHistoryState | null {
if (!this.canUndo()) {
return null;
}
this.currentIndex--;
return this.cloneState(this.history[this.currentIndex]);
}
/**
* Redo to next state
*/
redo(): IHistoryState | null {
if (!this.canRedo()) {
return null;
}
this.currentIndex++;
return this.cloneState(this.history[this.currentIndex]);
}
/**
* Check if undo is available
*/
canUndo(): boolean {
return this.currentIndex > 0;
}
/**
* Check if redo is available
*/
canRedo(): boolean {
return this.currentIndex < this.history.length - 1;
}
/**
* Get current state
*/
getCurrentState(): IHistoryState | null {
if (this.currentIndex >= 0 && this.currentIndex < this.history.length) {
return this.cloneState(this.history[this.currentIndex]);
}
return null;
}
/**
* Clear history
*/
clear(): void {
this.history = [];
this.currentIndex = -1;
this.lastSaveTime = 0;
}
/**
* Deep clone blocks
*/
private cloneBlocks(blocks: IBlock[]): IBlock[] {
return blocks.map(block => ({
...block,
metadata: block.metadata ? { ...block.metadata } : undefined
}));
}
/**
* Clone a history state
*/
private cloneState(state: IHistoryState): IHistoryState {
return {
blocks: this.cloneBlocks(state.blocks),
selectedBlockId: state.selectedBlockId,
cursorPosition: state.cursorPosition ? { ...state.cursorPosition } : undefined,
timestamp: state.timestamp
};
}
/**
* Get history info for debugging
*/
getHistoryInfo(): { size: number; currentIndex: number; canUndo: boolean; canRedo: boolean } {
return {
size: this.history.length,
currentIndex: this.currentIndex,
canUndo: this.canUndo(),
canRedo: this.canRedo()
};
}
}

View File

@ -0,0 +1,302 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
import { WysiwygModalManager } from './wysiwyg.modalmanager.js';
export class WysiwygInputHandler {
private component: IWysiwygComponent;
private saveTimeout: any = null;
constructor(component: IWysiwygComponent) {
this.component = component;
}
/**
* Handles input events for blocks
*/
handleBlockInput(e: InputEvent, block: IBlock): void {
if (this.component.isComposing) return;
const target = e.target as HTMLDivElement;
const textContent = target.textContent || '';
// Check for block type transformations BEFORE updating content
const detectedType = this.detectBlockTypeIntent(textContent);
if (detectedType && detectedType.type !== block.type) {
e.preventDefault();
this.handleBlockTransformation(block, detectedType, target);
return;
}
// Handle slash commands
this.handleSlashCommand(textContent, target);
// Don't update block content immediately - let the block handle its own content
// This prevents re-renders during typing
// Schedule auto-save (which will sync content later)
this.scheduleAutoSave();
}
/**
* Updates block content based on its type
*/
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
const newContent = blockComponent.getContent();
// Only update if content actually changed to avoid unnecessary updates
if (block.content !== newContent) {
block.content = newContent;
}
// 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') {
const listItems = target.querySelectorAll('li');
// Use innerHTML to preserve formatting
block.content = Array.from(listItems).map(li => li.innerHTML || '').join('\n');
const listElement = target.querySelector('ol, ul');
if (listElement) {
block.metadata = {
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
};
}
} else if (block.type === 'code') {
block.content = target.textContent || '';
} else {
block.content = target.innerHTML || '';
}
}
}
/**
* Detects if the user is trying to create a specific block type
*/
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
// Check heading patterns
const headingResult = WysiwygShortcuts.checkHeadingShortcut(content);
if (headingResult) {
return headingResult;
}
// Check list patterns
const listResult = WysiwygShortcuts.checkListShortcut(content);
if (listResult) {
return listResult;
}
// Check quote pattern
if (WysiwygShortcuts.checkQuoteShortcut(content)) {
return { type: 'quote' };
}
// Check code pattern
if (WysiwygShortcuts.checkCodeShortcut(content)) {
return { type: 'code' };
}
// Check divider pattern
if (WysiwygShortcuts.checkDividerShortcut(content)) {
return { type: 'divider' };
}
return null;
}
/**
* Handles block type transformation
*/
private async handleBlockTransformation(
block: IBlock,
detectedType: { type: IBlock['type'], listType?: 'bullet' | 'ordered' },
target: HTMLDivElement
): Promise<void> {
const blockOps = this.component.blockOperations;
if (detectedType.type === 'list') {
block.type = 'list';
block.content = '';
block.metadata = { listType: detectedType.listType };
const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul';
target.innerHTML = `<${listTag}><li></li></${listTag}>`;
this.component.updateValue();
// Update the block element programmatically
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
setTimeout(() => {
WysiwygBlocks.focusListItem(target);
}, 0);
} else if (detectedType.type === 'divider') {
block.type = 'divider';
block.content = ' ';
// Update the block element programmatically
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
const newBlock = blockOps.createBlock();
blockOps.insertBlockAfter(block, newBlock);
this.component.updateValue();
} else if (detectedType.type === 'code') {
const language = await WysiwygModalManager.showLanguageSelectionModal();
if (language) {
block.type = 'code';
block.content = '';
block.metadata = { language };
target.textContent = '';
this.component.updateValue();
// Update the block element programmatically
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
// Focus the code block
setTimeout(async () => {
await blockOps.focusBlock(block.id, 'start');
}, 50);
}
} else {
block.type = detectedType.type;
block.content = '';
target.textContent = '';
this.component.updateValue();
// Update the block element programmatically
if (this.component.editorContentRef) {
this.component.updateBlockElement(block.id);
}
// Focus the transformed block
setTimeout(async () => {
await blockOps.focusBlock(block.id, 'start');
}, 50);
}
}
/**
* Handles slash command detection and menu display
*/
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
const slashMenu = this.component.slashMenu;
const isSlashMenuVisible = slashMenu && slashMenu.visible;
if (textContent === '/' || (textContent.startsWith('/') && isSlashMenuVisible)) {
if (!isSlashMenuVisible && textContent === '/') {
// Get position for menu based on cursor location
const rect = this.getCaretCoordinates(target);
// Show the slash menu at the cursor position
slashMenu.show(
{ 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));
}
} else if (!textContent.startsWith('/')) {
this.component.closeSlashMenu();
}
}
/**
* Gets the coordinates of the caret/cursor
*/
private getCaretCoordinates(element: HTMLElement): DOMRect {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) {
return rect;
}
}
// Fallback to element position
return element.getBoundingClientRect();
}
/**
* Schedules auto-save after a delay
*/
private scheduleAutoSave(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
// Don't auto-save if slash menu is open
if (this.component.slashMenu && this.component.slashMenu.visible) {
return;
}
this.saveTimeout = setTimeout(() => {
// Sync all block content from DOM before saving
this.syncAllBlockContent();
// Only update value, don't trigger any re-renders
this.component.updateValue();
// Don't call requestUpdate() as it's not needed
}, 2000); // Increased delay to reduce interference with typing
}
/**
* Syncs content from all block DOMs to the data model
*/
private syncAllBlockContent(): void {
this.component.blocks.forEach((block: IBlock) => {
const wrapperElement = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent && blockComponent.getContent) {
const newContent = blockComponent.getContent();
// Only update if content actually changed
if (block.content !== newContent) {
block.content = newContent;
}
}
});
}
/**
* Cleans up resources
*/
destroy(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
}
}

View File

@ -0,0 +1,89 @@
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;
editorContentRef: HTMLDivElement;
draggedBlockId: string | null;
dragOverBlockId: string | null;
dragOverPosition: 'before' | 'after' | null;
isComposing: boolean;
// Menus
slashMenu: DeesSlashMenu;
formattingMenu: DeesFormattingMenu;
// Methods
updateValue(): void;
requestUpdate(): void;
updateComplete: Promise<boolean>;
insertBlock(type: string): Promise<void>;
closeSlashMenu(clearSlash?: boolean): void;
applyFormat(command: string): Promise<void>;
handleSlashMenuKeyboard(e: KeyboardEvent): void;
createBlockElement(block: IBlock): HTMLElement;
updateBlockElement(blockId: string): void;
handleDrop(e: DragEvent, targetBlock: IBlock): void;
renderBlocksProgrammatically(): void;
saveToHistory(debounce?: boolean): 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;
}
/**
* 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;
onRequestUpdate?: () => void; // Request immediate re-render of the block
}

View File

@ -0,0 +1,770 @@
import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
import { WysiwygSelection } from './wysiwyg.selection.js';
export class WysiwygKeyboardHandler {
private component: IWysiwygComponent;
constructor(component: IWysiwygComponent) {
this.component = component;
}
/**
* Handles keyboard events for blocks
*/
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
// Handle slash menu navigation
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
this.component.handleSlashMenuKeyboard(e);
return;
}
// Handle formatting shortcuts
if (this.handleFormattingShortcuts(e)) {
return;
}
// Handle special keys
switch (e.key) {
case 'Tab':
this.handleTab(e, block);
break;
case 'Enter':
await this.handleEnter(e, block);
break;
case 'Backspace':
await this.handleBackspace(e, block);
break;
case 'Delete':
await this.handleDelete(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;
}
}
/**
* Checks if key is for slash menu navigation
*/
private isSlashMenuKey(key: string): boolean {
return ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(key);
}
/**
* Handles formatting keyboard shortcuts
*/
private handleFormattingShortcuts(e: KeyboardEvent): boolean {
if (!(e.metaKey || e.ctrlKey)) return false;
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
// Use Promise to ensure focus is maintained
Promise.resolve().then(() => this.component.applyFormat('bold'));
return true;
case 'i':
e.preventDefault();
Promise.resolve().then(() => this.component.applyFormat('italic'));
return true;
case 'u':
e.preventDefault();
Promise.resolve().then(() => this.component.applyFormat('underline'));
return true;
case 'k':
e.preventDefault();
Promise.resolve().then(() => this.component.applyFormat('link'));
return true;
}
return false;
}
/**
* Handles Tab key
*/
private handleTab(e: KeyboardEvent, block: IBlock): void {
if (block.type === 'code') {
// Allow tab in code blocks - handled by CodeBlockHandler
// Let it bubble to the block handler
return;
} else if (block.type === 'list') {
// Future: implement list indentation
e.preventDefault();
}
}
/**
* Handles Enter key
*/
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
const blockOps = this.component.blockOperations;
// For non-editable blocks, create a new paragraph after
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
return;
}
if (block.type === 'code') {
if (e.shiftKey) {
// Shift+Enter in code blocks creates a new block
e.preventDefault();
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
// Normal Enter in code blocks creates new line (let browser handle it)
return;
}
if (!e.shiftKey) {
if (block.type === 'list') {
await this.handleEnterInList(e, block);
} else {
// Split content at cursor position
e.preventDefault();
// Get the block component - need to search in the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (blockComponent && blockComponent.getSplitContent) {
const splitContent = blockComponent.getSplitContent();
if (splitContent) {
// Update current block with content before cursor
blockComponent.setContent(splitContent.before);
block.content = splitContent.before;
// Create new block with content after cursor
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
// Insert the new block
await blockOps.insertBlockAfter(block, newBlock);
// Update the value after both blocks are set
this.component.updateValue();
} else {
// Fallback - just create empty block
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
} else {
// No block component or method, just create empty block
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
}
}
// Shift+Enter creates line break (let browser handle it)
}
/**
* Handles Enter key in list blocks
*/
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
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 blockOps = this.component.blockOperations;
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
// Otherwise, let browser create new list item
}
}
/**
* Handles Backspace key
*/
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
const blockOps = this.component.blockOperations;
// Handle non-editable blocks
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
// If it's the only block, delete it and create a new paragraph
if (this.component.blocks.length === 1) {
// Save state for undo
this.component.saveToHistory(false);
// Remove the block
blockOps.removeBlock(block.id);
// Create a new paragraph block
const newBlock = blockOps.createBlock('paragraph', '');
this.component.blocks = [newBlock];
// Re-render blocks
this.component.renderBlocksProgrammatically();
// Focus the new block
await blockOps.focusBlock(newBlock.id, 'start');
// Update value
this.component.updateValue();
return;
}
// Save state for undo
this.component.saveToHistory(false);
// Find the previous block to focus
const prevBlock = blockOps.getPreviousBlock(block.id);
const nextBlock = blockOps.getNextBlock(block.id);
// Remove the block
blockOps.removeBlock(block.id);
// Focus the appropriate block
if (prevBlock && prevBlock.type !== 'divider' && prevBlock.type !== 'image') {
await blockOps.focusBlock(prevBlock.id, 'end');
} else if (nextBlock && nextBlock.type !== 'divider' && nextBlock.type !== 'image') {
await blockOps.focusBlock(nextBlock.id, 'start');
} else if (prevBlock) {
// If previous block is also non-editable, just select it
await blockOps.focusBlock(prevBlock.id);
} else if (nextBlock) {
// If next block is also non-editable, just select it
await blockOps.focusBlock(nextBlock.id);
}
return;
}
// Get the block component to check cursor position
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get cursor position
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
const actualContent = blockComponent.getContent ? blockComponent.getContent() : target.textContent;
// Check if cursor is at the beginning of the block
if (cursorPos === 0) {
e.preventDefault();
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
// If previous block is non-editable, select it first
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(prevBlock.type)) {
await blockOps.focusBlock(prevBlock.id);
return;
}
// Save checkpoint for undo
this.component.saveToHistory(false);
// Special handling for different block types
if (prevBlock.type === 'code' && block.type !== 'code') {
// Can't merge non-code into code block, just remove empty block
if (block.content === '') {
blockOps.removeBlock(block.id);
await blockOps.focusBlock(prevBlock.id, 'end');
}
return;
}
if (block.type === 'code' && prevBlock.type !== 'code') {
// Can't merge code into non-code block
const actualContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
if (actualContent === '' || actualContent.trim() === '') {
blockOps.removeBlock(block.id);
await blockOps.focusBlock(prevBlock.id, 'end');
}
return;
}
// Get the content of both blocks
const prevBlockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${prevBlock.id}"]`);
const prevBlockComponent = prevBlockWrapper?.querySelector('dees-wysiwyg-block') as any;
const prevContent = prevBlockComponent?.getContent() || prevBlock.content || '';
const currentContent = blockComponent.getContent() || block.content || '';
// Merge content
let mergedContent = '';
if (prevBlock.type === 'code' && block.type === 'code') {
// For code blocks, join with newline
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
} else if (prevBlock.type === 'list' && block.type === 'list') {
// For lists, combine the list items
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
} else {
// For other blocks, join with space if both have content
mergedContent = prevContent + (prevContent && currentContent ? ' ' : '') + currentContent;
}
// Store cursor position (where the merge point is)
const mergePoint = prevContent.length;
// Update previous block with merged content
blockOps.updateBlockContent(prevBlock.id, mergedContent);
if (prevBlockComponent) {
prevBlockComponent.setContent(mergedContent);
}
// Remove current block
blockOps.removeBlock(block.id);
// Focus previous block at merge point
await blockOps.focusBlock(prevBlock.id, mergePoint);
}
} else if (this.component.blocks.length > 1) {
// Check if block is actually empty by getting current content from DOM
const currentContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
if (currentContent === '' || currentContent.trim() === '') {
// Empty block - just remove it
e.preventDefault();
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
blockOps.removeBlock(block.id);
if (prevBlock.type !== 'divider') {
await blockOps.focusBlock(prevBlock.id, 'end');
}
}
}
}
// Otherwise, let browser handle normal backspace
}
/**
* Handles Delete key
*/
private async handleDelete(e: KeyboardEvent, block: IBlock): Promise<void> {
const blockOps = this.component.blockOperations;
// Handle non-editable blocks - same as backspace
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
// If it's the only block, delete it and create a new paragraph
if (this.component.blocks.length === 1) {
// Save state for undo
this.component.saveToHistory(false);
// Remove the block
blockOps.removeBlock(block.id);
// Create a new paragraph block
const newBlock = blockOps.createBlock('paragraph', '');
this.component.blocks = [newBlock];
// Re-render blocks
this.component.renderBlocksProgrammatically();
// Focus the new block
await blockOps.focusBlock(newBlock.id, 'start');
// Update value
this.component.updateValue();
return;
}
// Save state for undo
this.component.saveToHistory(false);
// Find the previous block to focus
const prevBlock = blockOps.getPreviousBlock(block.id);
const nextBlock = blockOps.getNextBlock(block.id);
// Remove the block
blockOps.removeBlock(block.id);
// Focus the appropriate block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nextBlock && !nonEditableTypes.includes(nextBlock.type)) {
await blockOps.focusBlock(nextBlock.id, 'start');
} else if (prevBlock && !nonEditableTypes.includes(prevBlock.type)) {
await blockOps.focusBlock(prevBlock.id, 'end');
} else if (nextBlock) {
// If next block is also non-editable, just select it
await blockOps.focusBlock(nextBlock.id);
} else if (prevBlock) {
// If previous block is also non-editable, just select it
await blockOps.focusBlock(prevBlock.id);
}
return;
}
// For editable blocks, check if we're at the end and next block is non-editable
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get cursor position
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
const textLength = target.textContent?.length || 0;
// Check if cursor is at the end of the block
if (cursorPos === textLength) {
const nextBlock = blockOps.getNextBlock(block.id);
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nextBlock && nonEditableTypes.includes(nextBlock.type)) {
e.preventDefault();
await blockOps.focusBlock(nextBlock.id);
return;
}
}
// Otherwise, let browser handle normal delete
}
/**
* Handles ArrowUp key - navigate to previous block if at beginning or first line
*/
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
// For non-editable blocks, always navigate to previous block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
}
return;
}
// Get the block component from the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element - code blocks now use .code-editor
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get selection info with proper shadow DOM support
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo || !selectionInfo.collapsed) return;
// Check if we're on the first line
if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
}
}
// Otherwise, let browser handle normal navigation
}
/**
* Handles ArrowDown key - navigate to next block if at end or last line
*/
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
// For non-editable blocks, always navigate to next block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
}
return;
}
// Get the block component from the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element - code blocks now use .code-editor
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get selection info with proper shadow DOM support
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo || !selectionInfo.collapsed) return;
// Check if we're on the last line
if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
}
}
// Otherwise, let browser handle normal navigation
}
/**
* 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> {
// For non-editable blocks, navigate to previous block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
}
return;
}
// Get the block component from the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element - code blocks now use .code-editor
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get selection info with proper shadow DOM support
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo || !selectionInfo.collapsed) return;
// Check if cursor is at the beginning of the block
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
if (cursorPos === 0) {
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) {
e.preventDefault();
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
const position = nonEditableTypes.includes(prevBlock.type) ? undefined : 'end';
await blockOps.focusBlock(prevBlock.id, position);
}
}
// 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> {
// For non-editable blocks, navigate to next block
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
if (nonEditableTypes.includes(block.type)) {
e.preventDefault();
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock) {
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
}
return;
}
// Get the block component from the wysiwyg component's shadow DOM
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
// Get the actual editable element - code blocks now use .code-editor
const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Get selection info with proper shadow DOM support
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo || !selectionInfo.collapsed) return;
// Check if cursor is at the end of the block
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
const textLength = target.textContent?.length || 0;
if (cursorPos === textLength) {
const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock) {
e.preventDefault();
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
}
}
// Otherwise, let the browser handle normal right arrow navigation
}
/**
* Handles slash menu keyboard navigation
* Note: This is now handled by the component directly
*/
/**
* Check if cursor is on the first line of a block
*/
private isOnFirstLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
try {
// Create a range from the selection info
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Get the container element
let container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentElement;
}
// Get the top position of the container
const containerRect = (container as Element).getBoundingClientRect();
// Check if we're near the top (within 5px tolerance for line height variations)
const isNearTop = rect.top - containerRect.top < 5;
// For single-line content, also check if we're at the beginning
if (container.textContent && !container.textContent.includes('\n')) {
const cursorPos = WysiwygSelection.getCursorPositionInElement(container as Element, ...shadowRoots);
return cursorPos === 0;
}
return isNearTop;
} catch (e) {
console.warn('Error checking first line:', e);
// Fallback to position-based check
const cursorPos = selectionInfo.startOffset;
return cursorPos === 0;
}
}
/**
* Check if cursor is on the last line of a block
*/
private isOnLastLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
try {
// Create a range from the selection info
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Get the container element
let container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentElement;
}
// Get the bottom position of the container
const containerRect = (container as Element).getBoundingClientRect();
// Check if we're near the bottom (within 5px tolerance for line height variations)
const isNearBottom = containerRect.bottom - rect.bottom < 5;
// For single-line content, also check if we're at the end
if (container.textContent && !container.textContent.includes('\n')) {
const textLength = target.textContent?.length || 0;
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
return cursorPos === textLength;
}
return isNearBottom;
} catch (e) {
console.warn('Error checking last line:', e);
// Fallback to position-based check
const textLength = target.textContent?.length || 0;
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
return cursorPos === textLength;
}
}
}

View File

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

View File

@ -0,0 +1,283 @@
/**
* Utilities for handling selection across Shadow DOM boundaries
*/
export interface SelectionInfo {
startContainer: Node;
startOffset: number;
endContainer: Node;
endOffset: number;
collapsed: boolean;
}
// Type for the extended caretPositionFromPoint with Shadow DOM support
type CaretPositionFromPointExtended = (x: number, y: number, ...shadowRoots: ShadowRoot[]) => CaretPosition | null;
export class WysiwygSelection {
/**
* Gets selection info that works across Shadow DOM boundaries
* @param shadowRoots - Shadow roots to include in the selection search
*/
static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null {
const selection = window.getSelection();
console.log('WysiwygSelection.getSelectionInfo - selection:', selection, 'rangeCount:', selection?.rangeCount);
if (!selection) return null;
// Try using getComposedRanges if available (better Shadow DOM support)
if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
console.log('Using getComposedRanges with', shadowRoots.length, 'shadow roots');
try {
// Pass shadow roots in the correct format as per MDN
const ranges = selection.getComposedRanges({ shadowRoots });
console.log('getComposedRanges returned', ranges.length, 'ranges');
if (ranges.length > 0) {
const range = ranges[0];
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
collapsed: range.collapsed
};
}
} catch (error) {
console.warn('getComposedRanges failed, falling back to getRangeAt:', error);
}
} else {
console.log('getComposedRanges not available, using fallback');
}
// Fallback to traditional selection API
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
collapsed: range.collapsed
};
}
return null;
}
/**
* Checks if a selection is within a specific element (considering Shadow DOM)
*/
static isSelectionInElement(element: Element, shadowRoot?: ShadowRoot): boolean {
const selectionInfo = shadowRoot
? this.getSelectionInfo(shadowRoot)
: this.getSelectionInfo();
if (!selectionInfo) return false;
// Check if the selection's common ancestor is within the element
return element.contains(selectionInfo.startContainer) ||
element.contains(selectionInfo.endContainer);
}
/**
* Gets the selected text across Shadow DOM boundaries
*/
static getSelectedText(): string {
const selection = window.getSelection();
return selection ? selection.toString() : '';
}
/**
* Creates a range from selection info
*/
static createRangeFromInfo(info: SelectionInfo): Range {
const range = document.createRange();
range.setStart(info.startContainer, info.startOffset);
range.setEnd(info.endContainer, info.endOffset);
return range;
}
/**
* Sets selection from a range (works with Shadow DOM)
*/
static setSelectionFromRange(range: Range): void {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
/**
* Gets cursor position relative to a specific element
*/
static getCursorPositionInElement(element: Element, ...shadowRoots: ShadowRoot[]): number | null {
const selectionInfo = shadowRoots.length > 0
? this.getSelectionInfo(...shadowRoots)
: this.getSelectionInfo();
if (!selectionInfo || !selectionInfo.collapsed) return null;
// Create a range from start of element to cursor position
try {
const range = document.createRange();
range.selectNodeContents(element);
// Handle case where selection is in a text node that's a child of the element
// Use our Shadow DOM-aware contains method
const isContained = this.containsAcrossShadowDOM(element, selectionInfo.startContainer);
if (isContained) {
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
const position = range.toString().length;
return position;
} else {
// Selection might be in shadow DOM or different context
// Try to find the equivalent position in the element
const text = element.textContent || '';
const selectionText = selectionInfo.startContainer.textContent || '';
// If the selection is at the beginning or end, handle those cases
if (selectionInfo.startOffset === 0) {
return 0;
} else if (selectionInfo.startOffset === selectionText.length) {
return text.length;
}
// For other cases, try to match based on text content
console.warn('Selection container not within element, using text matching fallback');
return selectionInfo.startOffset;
}
} catch (error) {
console.warn('Failed to get cursor position:', error);
return null;
}
}
/**
* Gets cursor position from mouse coordinates with Shadow DOM support
*/
static getCursorPositionFromPoint(x: number, y: number, container: HTMLElement, ...shadowRoots: ShadowRoot[]): number | null {
// Try modern API with shadow root support
if ('caretPositionFromPoint' in document && document.caretPositionFromPoint) {
let caretPos: CaretPosition | null = null;
// Try with shadow roots first (newer API)
try {
caretPos = (document.caretPositionFromPoint as any)(x, y, ...shadowRoots);
} catch (e) {
// Fallback to standard API without shadow roots
caretPos = document.caretPositionFromPoint(x, y);
}
if (caretPos && container.contains(caretPos.offsetNode)) {
// Calculate total offset within the container
return this.getOffsetInElement(caretPos.offsetNode, caretPos.offset, container);
}
}
// Safari/WebKit fallback
if ('caretRangeFromPoint' in document) {
const range = (document as any).caretRangeFromPoint(x, y);
if (range && container.contains(range.startContainer)) {
return this.getOffsetInElement(range.startContainer, range.startOffset, container);
}
}
return null;
}
/**
* Helper to get the total character offset of a position within an element
*/
private static getOffsetInElement(node: Node, offset: number, container: HTMLElement): number {
let totalOffset = 0;
let found = false;
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null
);
let textNode: Node | null;
while (textNode = walker.nextNode()) {
if (textNode === node) {
totalOffset += offset;
found = true;
break;
} else {
totalOffset += textNode.textContent?.length || 0;
}
}
return found ? totalOffset : 0;
}
/**
* Sets cursor position in an element
*/
static setCursorPosition(element: Element, position: number): void {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null
);
let currentPosition = 0;
let targetNode: Text | null = null;
let targetOffset = 0;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const nodeLength = node.textContent?.length || 0;
if (currentPosition + nodeLength >= position) {
targetNode = node;
targetOffset = position - currentPosition;
break;
}
currentPosition += nodeLength;
}
if (targetNode) {
const range = document.createRange();
range.setStart(targetNode, targetOffset);
range.collapse(true);
this.setSelectionFromRange(range);
}
}
/**
* Check if a node is contained within an element across Shadow DOM boundaries
* This is needed because element.contains() doesn't work across Shadow DOM
*/
static containsAcrossShadowDOM(container: Node, node: Node): boolean {
if (!container || !node) return false;
// Start with the node and traverse up
let current: Node | null = node;
while (current) {
// Direct match
if (current === container) {
return true;
}
// If we're at a shadow root, check its host
if (current.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (current as any).host) {
const shadowRoot = current as ShadowRoot;
// Check if the container is within this shadow root
if (shadowRoot.contains(container)) {
return false; // Container is in a child shadow DOM
}
// Move to the host element
current = shadowRoot.host;
} else {
// Regular DOM traversal
current = current.parentNode;
}
}
return false;
}
}

View File

@ -0,0 +1,74 @@
import { type IBlock, type IShortcutPattern, type ISlashMenuItem } from './wysiwyg.types.js';
export class WysiwygShortcuts {
static readonly HEADING_PATTERNS: IShortcutPattern[] = [
{ pattern: /^#[\s\u00A0]$/, type: 'heading-1' },
{ pattern: /^##[\s\u00A0]$/, type: 'heading-2' },
{ pattern: /^###[\s\u00A0]$/, type: 'heading-3' }
];
static readonly LIST_PATTERNS: IShortcutPattern[] = [
{ pattern: /^[*-][\s\u00A0]$/, type: 'bullet' },
{ pattern: /^(\d+)\.[\s\u00A0]$/, type: 'ordered' },
{ pattern: /^(\d+)\)[\s\u00A0]$/, type: 'ordered' }
];
static readonly QUOTE_PATTERN = /^>[\s\u00A0]$/;
static readonly CODE_PATTERN = /^```$/;
static readonly DIVIDER_PATTERNS = ['---', '***', '___'];
static checkHeadingShortcut(content: string): { type: IBlock['type'] } | null {
for (const { pattern, type } of this.HEADING_PATTERNS) {
if (pattern.test(content)) {
return { type: type as IBlock['type'] };
}
}
return null;
}
static checkListShortcut(content: string): { type: 'list', listType: 'bullet' | 'ordered' } | null {
for (const { pattern, type } of this.LIST_PATTERNS) {
if (pattern.test(content)) {
return { type: 'list', listType: type as 'bullet' | 'ordered' };
}
}
return null;
}
static checkQuoteShortcut(content: string): boolean {
return this.QUOTE_PATTERN.test(content);
}
static checkCodeShortcut(content: string): boolean {
return this.CODE_PATTERN.test(content);
}
static checkDividerShortcut(content: string): boolean {
return this.DIVIDER_PATTERNS.includes(content);
}
static getSlashMenuItems(): ISlashMenuItem[] {
return [
{ type: 'paragraph', label: 'Paragraph', icon: '¶' },
{ type: 'heading-1', label: 'Heading 1', icon: 'H₁' },
{ type: 'heading-2', label: 'Heading 2', icon: 'H₂' },
{ type: 'heading-3', label: 'Heading 3', icon: 'H₃' },
{ type: 'quote', label: 'Quote', icon: '"' },
{ type: 'code', label: 'Code', icon: '<>' },
{ type: 'list', label: 'List', icon: '•' },
{ type: 'image', label: 'Image', icon: '🖼' },
{ type: 'divider', label: 'Divider', icon: '—' },
{ type: 'youtube', label: 'YouTube', icon: '▶️' },
{ type: 'markdown', label: 'Markdown', icon: 'M↓' },
{ type: 'html', label: 'HTML', icon: '</>' },
{ type: 'attachment', label: 'File Attachment', icon: '📎' },
];
}
static generateBlockId(): string {
return `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
}
// Re-export the type that is used in this module
export type { ISlashMenuItem } from './wysiwyg.types.js';

View File

@ -0,0 +1,575 @@
import { css, cssManager } from '@design.estate/dees-element';
export const wysiwygStyles = css`
:host {
display: block;
position: relative;
}
.wysiwyg-container {
background: ${cssManager.bdTheme('#ffffff', '#1a1a1a')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-radius: 8px;
min-height: 200px;
padding: 32px 40px;
position: relative;
transition: all 0.2s ease;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.wysiwyg-container:hover {
border-color: ${cssManager.bdTheme('#d0d0d0', '#444')};
}
.wysiwyg-container:focus-within {
border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(77, 148, 255, 0.1)')};
}
/* Visual hint for text selection */
.editor-content:hover {
cursor: text;
}
.editor-content {
outline: none;
min-height: 160px;
margin: 0 -8px;
padding: 0 8px;
}
.block {
margin: 0;
padding: 4px 0;
position: relative;
transition: all 0.15s ease;
min-height: 1.6em;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
}
/* First and last blocks don't need extra spacing */
.block-wrapper:first-child .block {
margin-top: 0 !important;
}
.block-wrapper:last-child .block {
margin-bottom: 0;
}
.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;
}
.block[contenteditable] {
outline: none;
}
.block.paragraph {
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
.block.paragraph:empty::before {
content: "Type '/' for commands...";
color: ${cssManager.bdTheme('#999', '#666')};
pointer-events: none;
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
.block.heading-1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-1:empty::before {
content: "Heading 1";
color: ${cssManager.bdTheme('#999', '#666')};
pointer-events: none;
font-size: 32px;
line-height: 1.2;
font-weight: 700;
}
.block.heading-2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-2:empty::before {
content: "Heading 2";
color: ${cssManager.bdTheme('#999', '#666')};
pointer-events: none;
font-size: 24px;
line-height: 1.3;
font-weight: 600;
}
.block.heading-3 {
font-size: 20px;
font-weight: 600;
line-height: 1.4;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-3:empty::before {
content: "Heading 3";
color: ${cssManager.bdTheme('#999', '#666')};
pointer-events: none;
font-size: 20px;
line-height: 1.4;
font-weight: 600;
}
.block.quote {
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
padding-left: 20px;
font-style: italic;
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
margin-left: 0;
margin-right: 0;
line-height: 1.6;
}
.block.quote:empty::before {
content: "Quote";
color: ${cssManager.bdTheme('#999', '#666')};
pointer-events: none;
font-size: 16px;
line-height: 1.6;
font-weight: 400;
font-style: italic;
}
.code-block-container {
position: relative;
margin: 20px 0;
}
.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;
}
.block.code {
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
border-radius: 6px;
padding: 16px 20px;
padding-top: 32px; /* Make room for language indicator */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
overflow-x: auto;
}
.block.code:empty::before {
content: "// Code block";
color: ${cssManager.bdTheme('#999', '#666')};
pointer-events: none;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
font-weight: 400;
}
.block.list {
padding-left: 0;
}
.block.list ul,
.block.list ol {
margin: 0;
padding: 0 0 0 24px;
list-style-position: outside;
}
.block.list ul {
list-style: disc;
}
.block.list ol {
list-style: decimal;
}
.block.list li {
margin-bottom: 8px;
line-height: 1.6;
}
.block.list li:last-child {
margin-bottom: 0;
}
.block.divider {
text-align: center;
padding: 20px 0;
cursor: default;
pointer-events: none;
}
.block.divider hr {
border: none;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
margin: 0;
}
.slash-menu {
position: absolute;
background: ${cssManager.bdTheme('#ffffff', '#262626')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 4px;
z-index: 1000;
min-width: 220px;
max-height: 300px;
overflow-y: auto;
pointer-events: auto;
user-select: none;
}
.slash-menu-item {
padding: 10px 12px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 12px;
border-radius: 4px;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
font-size: 14px;
}
.slash-menu-item:hover,
.slash-menu-item.selected {
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.slash-menu-item .icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: ${cssManager.bdTheme('#666', '#999')};
font-weight: 600;
}
.slash-menu-item:hover .icon,
.slash-menu-item.selected .icon {
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
.toolbar {
position: absolute;
top: -40px;
left: 0;
background: ${cssManager.bdTheme('#ffffff', '#262626')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
border-radius: 6px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
padding: 4px;
display: none;
gap: 4px;
z-index: 1000;
}
.toolbar.visible {
display: flex;
}
.toolbar-button {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
}
.toolbar-button:hover {
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
/* Drag and Drop Styles */
.block-wrapper {
position: relative;
transition: transform 0.3s ease, opacity 0.2s ease;
}
/* Ensure proper spacing context for blocks */
.block-wrapper + .block-wrapper .block {
margin-top: 16px;
}
/* Override for headings following other blocks */
.block-wrapper + .block-wrapper .block.heading-1,
.block-wrapper + .block-wrapper .block.heading-2,
.block-wrapper + .block-wrapper .block.heading-3 {
margin-top: 24px;
}
/* Code and quote blocks need consistent spacing */
.block-wrapper + .block-wrapper .block.code,
.block-wrapper + .block-wrapper .block.quote {
margin-top: 20px;
}
.drag-handle {
position: absolute;
left: -28px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
cursor: grab;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#999', '#666')};
border-radius: 4px;
}
.drag-handle::before {
content: "⋮⋮";
font-size: 12px;
letter-spacing: -2px;
}
.block-wrapper:hover .drag-handle {
opacity: 1;
}
.drag-handle:hover {
color: ${cssManager.bdTheme('#666', '#999')};
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
}
.drag-handle:active {
cursor: grabbing;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
}
.block-wrapper.dragging {
opacity: 0.8;
pointer-events: none;
position: relative;
z-index: 2001;
transition: none !important;
}
/* Blocks that should move out of the way */
.block-wrapper.move-down {
transform: translateY(var(--drag-offset, 0px));
}
.block-wrapper.move-up {
transform: translateY(calc(-1 * var(--drag-offset, 0px)));
}
/* Drop indicator */
.drop-indicator {
position: absolute;
left: 0;
right: 0;
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.08)', 'rgba(77, 148, 255, 0.08)')};
border: 2px dashed ${cssManager.bdTheme('#0066cc', '#4d94ff')};
border-radius: 8px;
transition: top 0.2s ease, height 0.2s ease;
pointer-events: none;
z-index: 1999;
box-sizing: border-box;
}
/* Remove old drag-over styles */
.block-wrapper.drag-over-before,
.block-wrapper.drag-over-after {
/* No longer needed, using drop indicator instead */
}
.editor-content.dragging * {
user-select: none;
}
/* Block Settings Button */
.block-settings {
position: absolute;
right: -40px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#999', '#666')};
border-radius: 4px;
}
.block-wrapper:hover .block-settings {
opacity: 1;
}
.block-settings:hover {
color: ${cssManager.bdTheme('#666', '#999')};
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
}
.block-settings:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
}
/* Text Selection Styles */
.block ::selection {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
color: inherit;
}
/* Formatting Menu */
.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;
z-index: 1001;
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;
}
/* Applied format styles in content */
.block strong,
.block b {
font-weight: 600;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block em,
.block i {
font-style: italic;
}
.block u {
text-decoration: underline;
}
.block strike,
.block s {
text-decoration: line-through;
opacity: 0.7;
}
.block code {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
padding: 2px 6px;
border-radius: 3px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 0.9em;
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;
}
.block a:hover {
border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
`;

View File

@ -0,0 +1,20 @@
export interface IBlock {
id: string;
type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider' | 'youtube' | 'markdown' | 'html' | 'attachment';
content: string;
metadata?: any;
}
export interface ISlashMenuItem {
type: string;
label: string;
icon: string;
action?: () => void;
}
export interface IShortcutPattern {
pattern: RegExp;
type: string;
}
export type OutputFormat = 'html' | 'markdown';

3
ts_web/pages/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './mainpage.js';
export * from './input-showcase.js';
export * from './zindex-showcase.js';

View File

@ -0,0 +1,617 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import '../elements/index.js';
export const inputShowcase = () => html`
<div class="page-wrapper">
<style>
${css`
.page-wrapper {
display: block;
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
min-height: 100%;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
}
.showcase-container {
max-width: 1200px;
margin: 0 auto;
padding: 48px 24px;
}
.showcase-header {
text-align: center;
margin-bottom: 48px;
}
.showcase-title {
font-size: 48px;
font-weight: 700;
margin: 0 0 16px 0;
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
}
.showcase-subtitle {
font-size: 20px;
color: ${cssManager.bdTheme('#666', '#999')};
margin: 0;
line-height: 1.6;
}
.showcase-section {
margin-bottom: 48px;
}
.showcase-section:last-child {
margin-bottom: 0;
padding-bottom: 48px;
}
/* Ensure all headings are theme-aware */
h1, h2, h3, h4, h5, h6 {
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
margin: 0;
}
p {
color: ${cssManager.bdTheme('#666', '#999')};
}
strong {
color: ${cssManager.bdTheme('#333', '#e0e0e0')};
}
.section-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
}
.section-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
}
.section-icon.text { background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')}; }
.section-icon.selection { background: ${cssManager.bdTheme('#f3e5f5', '#4a148c')}; }
.section-icon.numeric { background: ${cssManager.bdTheme('#e8f5e9', '#1b5e20')}; }
.section-icon.special { background: ${cssManager.bdTheme('#fff3e0', '#e65100')}; }
.section-icon.rich { background: ${cssManager.bdTheme('#fce4ec', '#880e4f')}; }
.section-icon.form { background: ${cssManager.bdTheme('#e0f2f1', '#004d40')}; }
.section-title {
font-size: 32px;
font-weight: 600;
margin: 0;
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
}
.section-description {
color: ${cssManager.bdTheme('#666', '#999')};
margin: 0 0 32px 64px;
font-size: 16px;
line-height: 1.6;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
@media (max-width: 900px) {
.demo-grid {
grid-template-columns: 1fr;
}
}
.nav-menu {
position: sticky;
top: 24px;
float: right;
margin-left: 24px;
margin-bottom: 24px;
background: ${cssManager.bdTheme('white', '#1a1a1a')};
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 12px ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(0, 0, 0, 0.5)')};
z-index: 100;
}
@media (max-width: 1200px) {
.nav-menu {
display: none;
}
}
.nav-item {
display: block;
padding: 8px 12px;
color: ${cssManager.bdTheme('#666', '#999')};
text-decoration: none;
font-size: 14px;
border-radius: 6px;
transition: all 0.2s;
}
.nav-item:hover {
background: ${cssManager.bdTheme('#f0f0f0', '#2a2a2a')};
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
}
dees-form {
margin-top: 32px;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.code-example {
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-radius: 8px;
padding: 16px;
margin-top: 16px;
font-family: 'Fira Code', monospace;
font-size: 14px;
overflow-x: auto;
color: ${cssManager.bdTheme('#333', '#e0e0e0')};
}
.feature-badge {
display: inline-block;
padding: 4px 12px;
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
color: ${cssManager.bdTheme('#1976d2', '#64b5f6')};
border-radius: 16px;
font-size: 12px;
font-weight: 500;
margin-left: 8px;
}
/* Form section specific styles */
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-top: 16px;
}
.feature-card {
padding: 16px;
border-radius: 8px;
}
.feature-card p {
margin: 8px 0 0 0;
font-size: 14px;
}
`}
</style>
<div class="showcase-container">
<!-- Navigation Menu -->
<nav class="nav-menu">
<a href="#text-inputs" class="nav-item">📝 Text Inputs</a>
<a href="#selection-inputs" class="nav-item">☑️ Selection Inputs</a>
<a href="#numeric-inputs" class="nav-item">🔢 Numeric Inputs</a>
<a href="#special-inputs" class="nav-item">✨ Special Inputs</a>
<a href="#rich-editors" class="nav-item">📄 Rich Editors</a>
<a href="#form-integration" class="nav-item">📋 Form Integration</a>
</nav>
<div class="showcase-header">
<h1 class="showcase-title">Input Components Showcase</h1>
<p class="showcase-subtitle">
A comprehensive collection of input components for building modern web forms and interfaces.
<br>All components support dark mode, validation, and integrate seamlessly with dees-form.
</p>
</div>
<!-- Text Inputs Section -->
<section id="text-inputs" class="showcase-section">
<div class="section-header">
<div class="section-icon text">📝</div>
<h2 class="section-title">Text Inputs</h2>
</div>
<p class="section-description">
Standard text input components for collecting various types of textual data.
Includes password fields, validation, and specialized formatting.
</p>
<dees-panel .title=${'Basic Text Inputs'}>
<div class="demo-grid">
<dees-input-text
.label=${'Username'}
.placeholder=${'Enter your username'}
.description=${'Choose a unique username'}
.required=${true}
></dees-input-text>
<dees-input-text
.label=${'Email Address'}
.inputType=${'email'}
.placeholder=${'user@example.com'}
.validationText=${'Please enter a valid email'}
.required=${true}
></dees-input-text>
<dees-input-text
.label=${'Password'}
.isPasswordBool=${true}
.placeholder=${'Enter secure password'}
.description=${'Must be at least 8 characters'}
></dees-input-text>
<dees-input-text
.label=${'Website URL'}
.inputType=${'url'}
.placeholder=${'https://example.com'}
.value=${'https://design.estate'}
></dees-input-text>
</div>
</dees-panel>
<dees-panel .title=${'Search Bar'} .subtitle=${'Advanced search with suggestions'}>
<dees-searchbar
.placeholder=${'Search for anything...'}
></dees-searchbar>
<div class="code-example">
// Search with custom suggestions
&lt;dees-searchbar
.placeholder="Search products..."
.suggestions=${['Laptop', 'Phone', 'Tablet']}
&gt;&lt;/dees-searchbar&gt;
</div>
</dees-panel>
</section>
<!-- Selection Inputs Section -->
<section id="selection-inputs" class="showcase-section">
<div class="section-header">
<div class="section-icon selection">☑️</div>
<h2 class="section-title">Selection Inputs</h2>
</div>
<p class="section-description">
Components for selecting from predefined options. Includes checkboxes, radio buttons,
dropdowns, and multi-select controls.
</p>
<dees-panel .title=${'Checkboxes and Radio Buttons'}>
<div class="demo-grid">
<div>
<dees-input-checkbox
.label=${'Accept Terms & Conditions'}
.description=${'You must accept to continue'}
.required=${true}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Subscribe to Newsletter'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Enable Notifications'}
.disabled=${true}
></dees-input-checkbox>
</div>
<div>
<dees-input-radiogroup
.label=${'Select Plan'}
.options=${['Free', 'Pro', 'Enterprise']}
.selectedOption=${'Pro'}
.required=${true}
></dees-input-radiogroup>
</div>
</div>
</dees-panel>
<dees-panel .title=${'Dropdown Selection'}>
<div class="demo-grid">
<dees-input-dropdown
.label=${'Country'}
.options=${[
{option: 'United States', key: 'us', payload: 'US'},
{option: 'Canada', key: 'ca', payload: 'CA'},
{option: 'United Kingdom', key: 'uk', payload: 'UK'},
{option: 'Germany', key: 'de', payload: 'DE'},
{option: 'France', key: 'fr', payload: 'FR'},
{option: 'Japan', key: 'jp', payload: 'JP'}
]}
.placeholder=${'Select your country'}
.required=${true}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Preferred Language'}
.options=${[
{option: 'English', key: 'en', payload: 'EN'},
{option: 'Spanish', key: 'es', payload: 'ES'},
{option: 'French', key: 'fr', payload: 'FR'},
{option: 'German', key: 'de', payload: 'DE'},
{option: 'Japanese', key: 'ja', payload: 'JA'}
]}
.value=${{option: 'English', key: 'en', payload: 'EN'}}
></dees-input-dropdown>
</div>
</dees-panel>
<dees-panel .title=${'Multi Toggle'} .subtitle=${'Toggle between multiple options'}>
<dees-input-multitoggle
.label=${'Theme Preference'}
.options=${['Light', 'Dark', 'Auto']}
.value=${'Auto'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'View Mode'}
.options=${['Grid', 'List', 'Cards']}
.value=${'Grid'}
.description=${'Choose how to display items'}
></dees-input-multitoggle>
</dees-panel>
<dees-panel .title=${'Type List'} .subtitle=${'Dynamic list of typed items'}>
<dees-input-typelist
.label=${'Skills'}
.description=${'Add your technical skills'}
.placeholder=${'Type and press Enter'}
></dees-input-typelist>
</dees-panel>
<dees-panel .title=${'Tags Input'} .subtitle=${'Add and manage tags with suggestions'}>
<div class="demo-grid">
<dees-input-tags
.label=${'Project Tags'}
.placeholder=${'Add tags...'}
.value=${['frontend', 'typescript', 'webcomponents']}
.description=${'Press Enter or comma to add'}
></dees-input-tags>
<dees-input-tags
.label=${'Technologies'}
.placeholder=${'Type to see suggestions...'}
.suggestions=${[
'React', 'Vue', 'Angular', 'Svelte',
'Node.js', 'Deno', 'Express', 'MongoDB'
]}
.value=${['React', 'Node.js']}
.maxTags=${5}
.description=${'Maximum 5 tags, with suggestions'}
></dees-input-tags>
</div>
</dees-panel>
</section>
<!-- Numeric Inputs Section -->
<section id="numeric-inputs" class="showcase-section">
<div class="section-header">
<div class="section-icon numeric">🔢</div>
<h2 class="section-title">Numeric Inputs</h2>
</div>
<p class="section-description">
Specialized inputs for numeric values, including quantity selectors and formatted inputs.
</p>
<dees-panel .title=${'Quantity Selector'}>
<div class="demo-grid">
<dees-input-quantityselector
.label=${'Product Quantity'}
.value=${1}
.min=${1}
.max=${100}
.description=${'Select quantity (1-100)'}
></dees-input-quantityselector>
<dees-input-quantityselector
.label=${'Team Size'}
.value=${5}
.min=${1}
.max=${50}
.step=${5}
></dees-input-quantityselector>
</div>
</dees-panel>
</section>
<!-- Special Inputs Section -->
<section id="special-inputs" class="showcase-section">
<div class="section-header">
<div class="section-icon special">✨</div>
<h2 class="section-title">Special Inputs</h2>
</div>
<p class="section-description">
Specialized input components for specific data types like phone numbers, IBAN, and file uploads.
</p>
<dees-panel .title=${'Phone & IBAN'}>
<div class="demo-grid">
<dees-input-phone
.label=${'Phone Number'}
.placeholder=${'+1 (555) 123-4567'}
.required=${true}
.description=${'International format supported'}
></dees-input-phone>
<dees-input-iban
.label=${'Bank Account (IBAN)'}
.placeholder=${'DE89 3704 0044 0532 0130 00'}
.description=${'European IBAN format'}
></dees-input-iban>
</div>
</dees-panel>
<dees-panel .title=${'File Upload'} .subtitle=${'Drag & drop or click to upload'}>
<dees-input-fileupload
.label=${'Upload Documents'}
.description=${'PDF, DOC, DOCX up to 10MB'}
.accept=${'.pdf,.doc,.docx'}
.multiple=${true}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Profile Picture'}
.description=${'JPG, PNG up to 5MB'}
.accept=${'image/*'}
></dees-input-fileupload>
</dees-panel>
</section>
<!-- Rich Editors Section -->
<section id="rich-editors" class="showcase-section">
<div class="section-header">
<div class="section-icon rich">📄</div>
<h2 class="section-title">Rich Text Editors</h2>
<span class="feature-badge">New!</span>
</div>
<p class="section-description">
Advanced text editors for creating rich content with formatting, images, and structured blocks.
</p>
<dees-panel .title=${'Rich Text Editor'} .subtitle=${'TipTap-based rich text editing'}>
<dees-input-richtext
.label=${'Article Content'}
.placeholder=${'Start writing...'}
.description=${'Full formatting toolbar with markdown shortcuts'}
.minHeight=${300}
.showWordCount=${true}
></dees-input-richtext>
</dees-panel>
<dees-panel .title=${'WYSIWYG Block Editor'} .subtitle=${'Block-based editor with slash commands'}>
<dees-input-wysiwyg
.label=${'Page Content'}
.description=${'Type "/" for commands or use markdown shortcuts'}
.outputFormat=${'html'}
></dees-input-wysiwyg>
</dees-panel>
</section>
<!-- Form Integration Section -->
<section id="form-integration" class="showcase-section">
<div class="section-header">
<div class="section-icon form">📋</div>
<h2 class="section-title">Form Integration</h2>
</div>
<p class="section-description">
All input components integrate seamlessly with dees-form for validation,
submission handling, and data management.
</p>
<dees-panel .title=${'Complete Form Example'} .subtitle=${'All inputs working together'}>
<dees-form>
<h3>User Registration</h3>
<div class="demo-grid">
<dees-input-text
.label=${'First Name'}
.required=${true}
.key=${'firstName'}
></dees-input-text>
<dees-input-text
.label=${'Last Name'}
.required=${true}
.key=${'lastName'}
></dees-input-text>
</div>
<dees-input-text
.label=${'Email'}
.inputType=${'email'}
.required=${true}
.key=${'email'}
></dees-input-text>
<dees-input-phone
.label=${'Phone Number'}
.required=${true}
.key=${'phone'}
></dees-input-phone>
<dees-input-dropdown
.label=${'Country'}
.options=${[
{option: 'United States', key: 'us', payload: 'US'},
{option: 'Canada', key: 'ca', payload: 'CA'},
{option: 'United Kingdom', key: 'uk', payload: 'UK'},
{option: 'Germany', key: 'de', payload: 'DE'},
{option: 'France', key: 'fr', payload: 'FR'}
]}
.required=${true}
.key=${'country'}
></dees-input-dropdown>
<dees-input-radiogroup
.label=${'Account Type'}
.options=${['Personal', 'Business']}
.required=${true}
.key=${'accountType'}
.selectedOption=${'Personal'}
></dees-input-radiogroup>
<dees-input-richtext
.label=${'Bio'}
.placeholder=${'Tell us about yourself...'}
.minHeight=${150}
.key=${'bio'}
></dees-input-richtext>
<dees-input-checkbox
.label=${'I agree to the Terms of Service'}
.required=${true}
.key=${'terms'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Subscribe to newsletter'}
.key=${'newsletter'}
></dees-input-checkbox>
<dees-form-submit .text=${'Create Account'}></dees-form-submit>
</dees-form>
</dees-panel>
<dees-panel .title=${'Form Features'}>
<div class="feature-grid">
<div class="feature-card" style="background: rgba(0, 150, 136, 0.1);">
<strong>✅ Validation</strong>
<p>Built-in validation for all input types</p>
</div>
<div class="feature-card" style="background: rgba(33, 150, 243, 0.1);">
<strong>🔄 Two-way Binding</strong>
<p>Automatic data synchronization</p>
</div>
<div class="feature-card" style="background: rgba(156, 39, 176, 0.1);">
<strong>📊 Data Collection</strong>
<p>Easy form data extraction</p>
</div>
<div class="feature-card" style="background: rgba(255, 152, 0, 0.1);">
<strong>🎨 Theming</strong>
<p>Consistent styling across all inputs</p>
</div>
</div>
</dees-panel>
</section>
</div>
</div>
`;

6
ts_web/pages/mainpage.ts Normal file
View File

@ -0,0 +1,6 @@
import { html } from '@design.estate/dees-element';
export const mainPage = () => html`
<dees-input-text label="my-test-label"></dees-input-text>
<dees-input-text label="my-test-label"></dees-input-text>
`;

View File

@ -0,0 +1,795 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import { DeesModal } from '../elements/dees-modal.js';
import { DeesToast } from '../elements/dees-toast.js';
import { DeesContextmenu } from '../elements/dees-contextmenu.js';
import '../elements/dees-button.js';
import '../elements/dees-input-dropdown.js';
import '../elements/dees-form.js';
import '../elements/dees-panel.js';
import '../elements/dees-input-text.js';
import '../elements/dees-input-radiogroup.js';
import '../elements/dees-input-tags.js';
import '../elements/dees-input-wysiwyg.js';
import '../elements/dees-appui-profiledropdown.js';
export const showcasePage = () => html`
<style>
${css`
.page-wrapper {
display: block;
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
min-height: 100%;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
}
.showcase-container {
max-width: 1200px;
margin: 0 auto;
padding: 48px 24px;
}
.showcase-header {
text-align: center;
margin-bottom: 48px;
}
.showcase-title {
font-size: 48px;
font-weight: 700;
margin: 0 0 16px 0;
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
}
.showcase-subtitle {
font-size: 20px;
color: ${cssManager.bdTheme('#666', '#999')};
margin: 0 0 32px 0;
line-height: 1.6;
}
.showcase-section {
margin-bottom: 48px;
}
.showcase-section:last-child {
margin-bottom: 0;
padding-bottom: 48px;
}
/* Ensure all headings are theme-aware */
h1, h2, h3, h4, h5, h6 {
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
margin: 0;
}
p {
color: ${cssManager.bdTheme('#666', '#999')};
line-height: 1.6;
}
strong {
color: ${cssManager.bdTheme('#333', '#e0e0e0')};
}
.section-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
}
.section-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
}
.section-icon.layers { background: ${cssManager.bdTheme('#f3e5f5', '#4a148c')}; }
.section-icon.registry { background: ${cssManager.bdTheme('#e8f5e9', '#1b5e20')}; }
.section-icon.demo { background: ${cssManager.bdTheme('#fff3e0', '#e65100')}; }
.section-icon.guidelines { background: ${cssManager.bdTheme('#e0f2f1', '#004d40')}; }
.section-title {
font-size: 32px;
font-weight: 600;
margin: 0;
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
}
.section-description {
color: ${cssManager.bdTheme('#666', '#999')};
font-size: 16px;
line-height: 1.6;
margin-bottom: 24px;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.demo-card {
background: ${cssManager.bdTheme('#fff', '#1a1a1a')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-radius: 12px;
padding: 24px;
transition: all 0.2s ease;
}
.demo-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
}
.demo-card h4 {
margin-bottom: 16px;
font-size: 18px;
font-weight: 600;
}
.hierarchy-visual {
background: ${cssManager.bdTheme('#fff', '#1a1a1a')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-radius: 12px;
padding: 32px;
margin-bottom: 40px;
}
.hierarchy-visual h3 {
margin-top: 0;
margin-bottom: 24px;
font-size: 24px;
font-weight: 600;
color: ${cssManager.bdTheme('#1a1a1a', '#fff')};
}
.layer-stack {
display: flex;
flex-direction: column-reverse;
gap: 8px;
margin-top: 16px;
}
.layer {
padding: 16px 20px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
font-family: 'Geist Mono', monospace;
font-size: 14px;
transition: all 0.2s;
border: 1px solid transparent;
}
.layer:hover {
transform: translateX(4px);
border-color: ${cssManager.bdTheme('#e0e0e0', '#444')};
}
.layer.base {
background: ${cssManager.bdTheme('#f0f0f0', '#222')};
color: ${cssManager.bdTheme('#666', '#999')};
}
.layer.fixed {
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
color: ${cssManager.bdTheme('#1976d2', '#90caf9')};
}
.layer.dropdown {
background: ${cssManager.bdTheme('#f3e5f5', '#4a148c')};
color: ${cssManager.bdTheme('#7b1fa2', '#ce93d8')};
}
.layer.modal {
background: ${cssManager.bdTheme('#e8f5e9', '#1b5e20')};
color: ${cssManager.bdTheme('#388e3c', '#81c784')};
}
.layer.context {
background: ${cssManager.bdTheme('#fff3e0', '#e65100')};
color: ${cssManager.bdTheme('#f57c00', '#ffb74d')};
}
.layer.toast {
background: ${cssManager.bdTheme('#ffebee', '#b71c1c')};
color: ${cssManager.bdTheme('#d32f2f', '#ef5350')};
}
.layer-name {
font-weight: 600;
}
.layer-value {
opacity: 0.8;
}
.warning-box {
background: ${cssManager.bdTheme('#fff8e1', '#332701')};
border: 1px solid ${cssManager.bdTheme('#ffe082', '#664400')};
border-radius: 12px;
padding: 20px;
margin-bottom: 32px;
color: ${cssManager.bdTheme('#f57f17', '#ffecb5')};
display: flex;
align-items: flex-start;
gap: 12px;
}
.warning-box::before {
content: '⚠️';
font-size: 20px;
flex-shrink: 0;
}
.warning-box strong {
color: ${cssManager.bdTheme('#f57f17', '#ffd93d')};
}
code {
background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')};
padding: 2px 6px;
border-radius: 3px;
font-family: 'Geist Mono', monospace;
font-size: 14px;
}
.test-area {
position: relative;
height: 200px;
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
border: 2px dashed ${cssManager.bdTheme('#ccc', '#444')};
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 16px;
}
.profile-demo {
position: relative;
display: inline-block;
}
.registry-status {
background: ${cssManager.bdTheme('#e8f5e9', '#1a2e1a')};
border: 1px solid ${cssManager.bdTheme('#4caf50', '#2e7d32')};
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
position: relative;
overflow: hidden;
}
.registry-status::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: ${cssManager.bdTheme('#4caf50', '#2e7d32')};
}
.registry-status h4 {
margin-top: 0;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#2e7d32', '#81c784')};
font-size: 18px;
font-weight: 600;
}
.registry-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
color: ${cssManager.bdTheme('#558b2f', '#aed581')};
font-family: 'Geist Mono', monospace;
font-size: 14px;
border-bottom: 1px solid ${cssManager.bdTheme('#e0f2e1', '#1b5e20')};
}
.registry-item:last-child {
border-bottom: none;
}
.registry-item.active {
color: ${cssManager.bdTheme('#2e7d32', '#4ade80')};
font-weight: 600;
}
.registry-item span:last-child {
font-weight: 600;
}
`}
</style>
<div class="page-wrapper">
<div class="showcase-container">
<div class="showcase-header">
<h1 class="showcase-title">Z-Index Management</h1>
<p class="showcase-subtitle">
A comprehensive system for managing overlay stacking order across all components.
Test different scenarios to see how the dynamic z-index registry ensures proper layering.
</p>
</div>
<div class="warning-box">
<div>
<strong>Important:</strong> The z-index values are managed centrally in <code>00zindex.ts</code>.
Never use arbitrary z-index values in components - always import and use the z-index registry.
</div>
</div>
<!-- Registry Status Section -->
<div class="showcase-section">
<div class="section-header">
<div class="section-icon registry">📊</div>
<div>
<h2 class="section-title">Live Registry Status</h2>
</div>
</div>
<div class="registry-status" id="registryStatus">
<h4>Z-Index Registry</h4>
<div class="registry-item">
<span>Active Elements:</span>
<span id="activeCount">0</span>
</div>
<div class="registry-item">
<span>Current Z-Index:</span>
<span id="currentZIndex">1000</span>
</div>
</div>
</div>
<script>
// Update registry status periodically
setInterval(() => {
const registryDiv = document.getElementById('registryStatus');
if (registryDiv && window.zIndexRegistry) {
const activeCount = document.getElementById('activeCount');
const currentZIndex = document.getElementById('currentZIndex');
if (activeCount) activeCount.textContent = window.zIndexRegistry.getActiveCount();
if (currentZIndex) currentZIndex.textContent = window.zIndexRegistry.getCurrentZIndex();
// Update active state
const items = registryDiv.querySelectorAll('.registry-item');
const count = window.zIndexRegistry.getActiveCount();
if (count > 0) {
items[0].classList.add('active');
} else {
items[0].classList.remove('active');
}
}
}, 500);
// Make registry available globally for the demo
import('../elements/00zindex.js').then(module => {
window.zIndexRegistry = module.zIndexRegistry;
});
</script>
<!-- Layer Hierarchy Section -->
<div class="showcase-section">
<div class="section-header">
<div class="section-icon layers">📚</div>
<div>
<h2 class="section-title">Layer Hierarchy</h2>
</div>
</div>
<p class="section-description">
The traditional z-index layers are still defined for reference, but the new registry system
dynamically assigns z-indexes based on creation order.
</p>
<div class="hierarchy-visual">
<h3>Legacy Z-Index Layers (Reference)</h3>
<div class="layer-stack">
<div class="layer base">
<span class="layer-name">Base Content</span>
<span class="layer-value">z-index: auto</span>
</div>
<div class="layer fixed">
<span class="layer-name">Fixed Navigation</span>
<span class="layer-value">z-index: 10-250</span>
</div>
<div class="layer dropdown">
<span class="layer-name">Dropdown Overlays</span>
<span class="layer-value">z-index: 1999-2000</span>
</div>
<div class="layer modal">
<span class="layer-name">Modal Dialogs</span>
<span class="layer-value">z-index: 2999-3000</span>
</div>
<div class="layer context">
<span class="layer-name">Context Menus</span>
<span class="layer-value">z-index: 4000</span>
</div>
<div class="layer toast">
<span class="layer-name">Toast Notifications</span>
<span class="layer-value">z-index: 5000</span>
</div>
</div>
</div>
</div>
<!-- Interactive Demos Section -->
<div class="showcase-section">
<div class="section-header">
<div class="section-icon demo">🎮</div>
<div>
<h2 class="section-title">Interactive Demos</h2>
</div>
</div>
<p class="section-description">
Test the z-index registry in action with these interactive examples. Each element gets the next
available z-index when created, ensuring proper stacking order.
</p>
<dees-panel .title=${'Basic Overlay Tests'} .subtitle=${'Test individual overlay components'}>
<div class="demo-grid">
<div class="demo-card">
<h4>Dropdown Test</h4>
<dees-input-dropdown
.label=${'Select Option'}
.options=${[
{option: 'Show Toast', key: 'toast', payload: 'toast'},
{option: 'Option 2', key: 'opt2', payload: '2'},
{option: 'Option 3', key: 'opt3', payload: '3'},
{option: 'Option 4', key: 'opt4', payload: '4'},
]}
@change=${async (e: CustomEvent) => {
if (e.detail.value?.payload === 'toast') {
DeesToast.createAndShow({ message: 'Toast appears above dropdown!', type: 'success' });
}
}}
></dees-input-dropdown>
</div>
<div class="demo-card">
<h4>Context Menu Test</h4>
<div class="test-area" @contextmenu=${(e: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(e, [
{ name: 'Show Toast', iconName: 'bell', action: async () => {
DeesToast.createAndShow({ message: 'Toast from context menu!', type: 'info' });
}},
{ divider: true },
{ name: 'Item 2', iconName: 'check', action: async () => {} },
{ name: 'Item 3', iconName: 'copy', action: async () => {} },
]);
}}>
<span style="color: ${cssManager.bdTheme('#999', '#666')}">Right-click here for context menu</span>
</div>
</div>
<div class="demo-card">
<h4>Toast Notification</h4>
<dees-button @click=${async () => {
DeesToast.createAndShow({ message: 'I appear on top of everything!', type: 'success' });
}}>Show Toast</dees-button>
</div>
</div>
</dees-panel>
<dees-panel .title=${'Modal with Dropdown'} .subtitle=${'Critical test: Dropdown inside modal should appear above modal'}>
<p>This tests the most common z-index conflict scenario.</p>
<dees-button @click=${async () => {
const modal = await DeesModal.createAndShow({
heading: 'Modal with Dropdown',
width: 'medium',
showHelpButton: true,
onHelp: async () => {
DeesToast.createAndShow({ message: 'Help requested! Toast appears above modal.', type: 'info' });
},
content: html`
<p>The dropdown below should appear <strong>above</strong> this modal:</p>
<dees-form>
<dees-input-dropdown
.label=${'Select Country'}
.options=${[
{option: 'United States', key: 'us', payload: 'US'},
{option: 'Canada', key: 'ca', payload: 'CA'},
{option: 'United Kingdom', key: 'uk', payload: 'UK'},
{option: 'Germany', key: 'de', payload: 'DE'},
{option: 'France', key: 'fr', payload: 'FR'},
{option: 'Japan', key: 'jp', payload: 'JP'},
{option: 'Australia', key: 'au', payload: 'AU'},
{option: 'Brazil', key: 'br', payload: 'BR'},
]}
.required=${true}
></dees-input-dropdown>
<dees-input-text
.label=${'Additional Field'}
.placeholder=${'Just to show form context'}
></dees-input-text>
<dees-input-tags
.label=${'Tags'}
.placeholder=${'Add tags...'}
.suggestions=${['urgent', 'bug', 'feature', 'documentation', 'testing']}
.description=${'Add relevant tags'}
></dees-input-tags>
</dees-form>
<p style="margin-top: 16px; color: ${cssManager.bdTheme('#666', '#999')}">
You can also right-click anywhere in this modal to test context menus.
</p>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal) => modal.destroy() },
{ name: 'Save', action: async (modal) => modal.destroy() }
]
});
// Add context menu to modal content
const modalContent = modal.shadowRoot.querySelector('.modal .content');
if (modalContent) {
modalContent.addEventListener('contextmenu', async (e: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(e, [
{ name: 'Context menu in modal', iconName: 'check', action: async () => {} },
{ divider: true },
{ name: 'Show Toast', iconName: 'bell', action: async () => {
DeesToast.createAndShow({ message: 'Toast from modal context menu!', type: 'warning' });
}}
]);
});
}
}}>Open Modal with Dropdown</dees-button>
</dees-panel>
<dees-panel .title=${'Complex Stacking Scenario'} .subtitle=${'Multiple overlays active simultaneously'}>
<p>This creates a complex scenario with multiple overlays to test the complete hierarchy.</p>
<dees-button @click=${async () => {
// Show base modal
await DeesModal.createAndShow({
heading: 'Base Modal',
width: 'large',
content: html`
<h4>Level 1: Modal</h4>
<p>This is the base modal. Try the following:</p>
<ol>
<li>Open the dropdown below</li>
<li>Right-click for context menu</li>
<li>Click "Show Second Modal" to stack modals</li>
</ol>
<dees-input-dropdown
.label=${'Test Dropdown in Modal'}
.options=${[
{option: 'Trigger Toast', key: 'toast', payload: 'toast'},
{option: 'Option 2', key: 'opt2', payload: '2'},
{option: 'Option 3', key: 'opt3', payload: '3'},
]}
@change=${async (e: CustomEvent) => {
if (e.detail.value?.payload === 'toast') {
DeesToast.createAndShow({ message: 'Toast triggered from dropdown in modal!', type: 'success' });
}
}}
></dees-input-dropdown>
<div style="margin-top: 16px;">
<dees-button @click=${async () => {
await DeesModal.createAndShow({
heading: 'Second Modal',
width: 'small',
content: html`
<h4>Level 2: Stacked Modal</h4>
<p>This modal appears on top of the first one.</p>
<p>The dropdown here should still work:</p>
<dees-input-dropdown
.label=${'Nested Dropdown'}
.options=${[
{option: 'Option A', key: 'a'},
{option: 'Option B', key: 'b'},
{option: 'Option C', key: 'c'},
]}
></dees-input-dropdown>
`,
menuOptions: [
{ name: 'Close', action: async (modal) => modal.destroy() }
]
});
}}>Show Second Modal</dees-button>
</div>
`,
menuOptions: [
{ name: 'Close All', action: async (modal) => {
modal.destroy();
// Also show a toast
DeesToast.createAndShow({ message: 'All modals closed!', type: 'info' });
}}
]
});
}}>Start Complex Stack Test</dees-button>
</dees-panel>
<dees-panel .title=${'Profile Dropdown'} .subtitle=${'Testing app UI dropdowns'}>
<p>Profile dropdowns and similar UI elements use the dropdown z-index layer.</p>
<div class="profile-demo">
<dees-appui-profiledropdown
.user=${{
name: 'Test User',
email: 'test@example.com',
avatar: 'https://randomuser.me/api/portraits/lego/1.jpg',
status: 'online' as const
}}
.menuItems=${[
{ name: 'Show Toast', iconName: 'bell', shortcut: '', action: async () => {
DeesToast.createAndShow({ message: 'Profile action triggered!', type: 'success' });
}},
{ divider: true } as const,
{ name: 'Settings', iconName: 'settings', shortcut: '', action: async () => {} },
{ name: 'Logout', iconName: 'logOut', shortcut: '', action: async () => {} }
]}
></dees-appui-profiledropdown>
</div>
</dees-panel>
<dees-panel .title=${'Edge Cases'} .subtitle=${'Special scenarios and gotchas'}>
<div class="demo-grid">
<div class="demo-card">
<h4>Multiple Toasts</h4>
<dees-button @click=${async () => {
DeesToast.createAndShow({ message: 'First toast', type: 'info' });
setTimeout(() => {
DeesToast.createAndShow({ message: 'Second toast', type: 'warning' });
}, 500);
setTimeout(() => {
DeesToast.createAndShow({ message: 'Third toast', type: 'success' });
}, 1000);
}}>Show Multiple Toasts</dees-button>
</div>
<div class="demo-card">
<h4>Modal with WYSIWYG Editor</h4>
<dees-button @click=${async () => {
await DeesModal.createAndShow({
heading: 'WYSIWYG Editor Test',
width: 'large',
content: html`
<p>Test the WYSIWYG editor slash commands and formatting menus in a modal:</p>
<dees-form>
<dees-input-wysiwyg
.label=${'Document Content'}
.placeholder=${'Type "/" to see slash commands or select text to format...'}
.outputFormat=${'html'}
.description=${'The slash menu and formatting menu should appear above this modal'}
></dees-input-wysiwyg>
</dees-form>
<p style="margin-top: 16px; color: ${cssManager.bdTheme('#666', '#999')}">
<strong>Tips:</strong><br>
• Type "/" to open the slash command menu<br>
• Select text to see the formatting toolbar<br>
• Both menus should appear above this modal
</p>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal) => modal.destroy() },
{ name: 'Save', action: async (modal) => {
DeesToast.createAndShow({ message: 'Document saved!', type: 'success' });
modal.destroy();
}}
]
});
}}>Test WYSIWYG in Modal</dees-button>
</div>
<div class="demo-card">
<h4>Modal with Tags Input</h4>
<dees-button @click=${async () => {
await DeesModal.createAndShow({
heading: 'Tags Input Test',
width: 'medium',
content: html`
<p>Test the tags input component in a modal:</p>
<dees-form>
<dees-input-tags
.label=${'Search Terms'}
.placeholder=${'Enter search terms...'}
.value=${['typescript', 'modal']}
.suggestions=${[
'javascript', 'typescript', 'css', 'html',
'react', 'vue', 'angular', 'svelte',
'modal', 'dropdown', 'form', 'input'
]}
.description=${'Add search terms to filter results'}
></dees-input-tags>
<dees-input-tags
.label=${'Categories'}
.placeholder=${'Add categories...'}
.required=${true}
.maxTags=${3}
.description=${'Select up to 3 categories'}
></dees-input-tags>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal) => modal.destroy() },
{ name: 'Apply', action: async (modal) => {
DeesToast.createAndShow({ message: 'Tags applied!', type: 'success' });
modal.destroy();
}}
]
});
}}>Test Tags in Modal</dees-button>
</div>
<div class="demo-card">
<h4>Fullscreen Modal</h4>
<dees-button @click=${async () => {
await DeesModal.createAndShow({
heading: 'Fullscreen Modal Test',
width: 'fullscreen',
content: html`
<p>Even in fullscreen, overlays should work properly:</p>
<dees-input-radiogroup
.label=${'Select Option'}
.options=${['Option 1', 'Option 2', 'Option 3']}
></dees-input-radiogroup>
<dees-input-dropdown
.label=${'Dropdown in Fullscreen'}
.options=${[
{option: 'Works properly', key: '1'},
{option: 'Above modal', key: '2'},
]}
></dees-input-dropdown>
`,
menuOptions: [
{ name: 'Exit Fullscreen', action: async (modal) => modal.destroy() }
]
});
}}>Open Fullscreen</dees-button>
</div>
</div>
</dees-panel>
</div>
<!-- Guidelines Section -->
<div class="showcase-section">
<div class="section-header">
<div class="section-icon guidelines">📖</div>
<div>
<h2 class="section-title">Usage Guidelines</h2>
</div>
</div>
<dees-panel>
<h4>Best Practices:</h4>
<ul>
<li>Always use the z-index registry from <code>00zindex.ts</code></li>
<li>Never use arbitrary z-index values like <code>z-index: 9999</code></li>
<li>Get z-index from registry when showing elements: <code>zIndexRegistry.getNextZIndex()</code></li>
<li>Register elements to track them: <code>zIndexRegistry.register(element, zIndex)</code></li>
<li>Unregister on cleanup: <code>zIndexRegistry.unregister(element)</code></li>
<li>Elements created later automatically appear on top</li>
<li>Test overlay interactions, especially dropdowns in modals</li>
<li>WYSIWYG menus (slash commands, formatting) now use dynamic z-index</li>
</ul>
<h4>Import Example:</h4>
<pre style="background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')}; padding: 16px; border-radius: 6px; overflow-x: auto;">
<code>import { zIndexRegistry } from './00zindex.js';
// In your component:
const myZIndex = zIndexRegistry.getNextZIndex();
element.style.zIndex = myZIndex.toString();
zIndexRegistry.register(element, myZIndex);
// On cleanup:
zIndexRegistry.unregister(element);</code></pre>
</dees-panel>
</div>
</div>
</div>
`;