Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d48cd063c4 | |||
| bb04895be8 | |||
| 54b34b6faa | |||
| 12c85fa4cb | |||
| d90df9717b | |||
| d4b161437b | |||
| ad033c8104 | |||
| d7d6d650bc | |||
| 53c5d839ca | |||
| 6cbfd714eb | |||
| 7c8c194fd8 | |||
| 278000bb36 | |||
| 52ffe81352 | |||
| 91194f6388 | |||
| 904bb92057 | |||
| 4c23739d9a | |||
| dd048d42a8 | |||
| ca28dbd9db | |||
| 7148b12066 | |||
| 309d708830 | |||
| 923bedc4fc | |||
| e8b771bde4 | |||
| 7a248993bc | |||
| 03f215e0f1 | |||
| 216cb0288d | |||
| 65acda3de1 | |||
| 88ff74bb86 | |||
| 98a5b1b5a3 | |||
| bbf738d4e2 | |||
| 4f8ca7061a | |||
| d26d99dbff | |||
| c1d8e347de | |||
| b6c41caf44 | |||
| b858b3b9e2 | |||
| 4ed37086ae | |||
| b4c0de47b9 | |||
| e11f0df950 | |||
| c64b106569 | |||
| 3d1948b93e | |||
| 21b7158a35 | |||
| 992af2668e | |||
| 0cd84b28b4 | |||
| 0709267bd5 | |||
| d4fce8a939 | |||
| 578f87a8f9 | |||
| 0acf341071 | |||
| 87bbf0bbdc | |||
| bed41da573 | |||
| 48f77e7ba2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,7 +3,6 @@
|
||||
# artifacts
|
||||
coverage/
|
||||
public/
|
||||
pages/
|
||||
|
||||
# installs
|
||||
node_modules/
|
||||
|
||||
BIN
.playwright-mcp/recording-panel.png
Normal file
BIN
.playwright-mcp/recording-panel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
.playwright-mcp/wcctools-dashboard.png
Normal file
BIN
.playwright-mcp/wcctools-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
.playwright-mcp/wcctools-with-element.png
Normal file
BIN
.playwright-mcp/wcctools-with-element.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
104
changelog.md
Normal file
104
changelog.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-11 - 2.0.1 - fix(@git.zone/tswatch)
|
||||
Bump @git.zone/tswatch devDependency to ^2.3.12
|
||||
|
||||
- Updated devDependency @git.zone/tswatch from ^2.3.11 to ^2.3.12 in package.json
|
||||
|
||||
## 2025-12-11 - 2.0.0 - BREAKING CHANGE(recorder)
|
||||
Remove FFmpeg-based MP4 conversion; simplify recorder/export to WebM and improve recorder/editor robustness
|
||||
|
||||
- Removed FFmpegService and all client-side MP4 conversion logic — exports are now WebM-only (MP4 conversion and related UI/controls removed).
|
||||
- ts_web/elements/wcc-recording-panel: dropped outputFormat and conversion states/UI; download flow simplified to always export WebM.
|
||||
- ts_web/index.ts: removed FFmpegService exports and conversion types from public API.
|
||||
- package.json: removed @ffmpeg/* dependencies.
|
||||
- RecorderService: handleRecordingComplete is now async and fixes recorded blob assignment and cleanup timing.
|
||||
- wcc-properties: improved element detection and robustness — recursive search through light/shadow DOM with retry/delay, plus an advanced JSON editor for Object/Array props (supports multiple open editors and frame resize events).
|
||||
- wcc-sidebar: force re-render after selecting demos to ensure child demo selection indicators update correctly.
|
||||
- dees-demowrapper: ensure slotted content is rendered before calling runAfterRender (small timing/stability improvements).
|
||||
- Test update: demo definitions can be arrays (multiple demos) — test-demoelement updated to use multiple demo entries.
|
||||
|
||||
## 2025-12-11 - 1.3.0 - feat(recording-panel)
|
||||
Add demo wrapper utilities, improve recording trim behavior, and harden property panel element detection; update documentation
|
||||
|
||||
- Add dees-demowrapper (ts_demotools) with runAfterRender callback to run post-render demo logic (supports async callbacks).
|
||||
- Improve recording UI and trimming: handle WebM files with Infinity/NaN durations by falling back to tracked recording duration; replace numeric handle positioning with CSS calc strings for responsive trim handles.
|
||||
- Harden property extraction: implement recursive element search (including shadowRoots), add an initial delay and retry loop to wait for demo rendering, and add an advanced JSON editor for Object/Array properties with open/save/cancel and per-editor error reporting.
|
||||
- Add and expand documentation: new ts_web/ and ts_demotools/ READMEs, reorganized main README with clearer feature list, usage examples, and API reference.
|
||||
- Minor exports and module/docs housekeeping (index exports, readme reorder, examples updated to import classes).
|
||||
|
||||
## 2025-11-16 - 1.2.1 - fix(dependencies)
|
||||
Bump dependencies and developer tooling versions
|
||||
|
||||
- Bump @design.estate/dees-domtools from ^2.3.3 to ^2.3.6
|
||||
- Bump @design.estate/dees-element from ^2.1.2 to ^2.1.3
|
||||
- Upgrade @git.zone/tsbuild from ^2.6.8 to ^2.7.1
|
||||
- Upgrade @git.zone/tsrun from ^1.2.44 to ^1.6.2
|
||||
- Upgrade @git.zone/tstest from ^2.3.8 to ^2.7.0
|
||||
|
||||
## 2025-09-19 - 1.2.0 - feat(wcc-properties)
|
||||
Add advanced property editors, recursive element detection, demo wrapper, UI refresh and test fixtures
|
||||
|
||||
- Advanced JSON property editor: multiple side-by-side editors with save/cancel, syntax validation and inline error display; editors affect frame layout (frame bottom increases when editors open).
|
||||
- Improved properties panel element detection: recursive search through nested children and shadow DOM, initial delay and retry mechanism to handle async Lit rendering.
|
||||
- Add dees-demowrapper component in ts_demotools to run post-render callbacks and support async demo setup and DOM access for demos.
|
||||
- UI refresh with shadcn-like styles: CSS variables for theming, redesigned properties panel and sidebar, improved form controls, theme and viewport selectors.
|
||||
- Viewport and frame improvements: responsive padding based on viewport type, theme-aware background rendering, and scroll position tracking with URL/state restoration for frame and sidebar.
|
||||
- Add test fixtures and demo elements/pages under test/ to exercise properties, complex types, nested elements and scroll restoration; include node test for resolveTemplateFactory.
|
||||
- Expose setupWccTools entry point and plugin wiring (wcctools.plugins exports for dees-domtools and smartdelay) for easier integration.
|
||||
|
||||
## 2025-06-27 - 1.1.0 - feat(wcctools)
|
||||
Enhance component tools with an advanced property editor, improved element detection and modernized UI styling for a more responsive dashboard experience.
|
||||
|
||||
- Updated documentation and in-code hints with new shadcn-like design patterns for the dashboard UI.
|
||||
- Introduced an advanced complex properties editor supporting JSON validation and multi-editor handling.
|
||||
- Refined recursive element search in the properties panel to improve asynchronous rendering detection.
|
||||
- Expanded test coverage with scenarios for edge cases, nested elements and wrapper components.
|
||||
|
||||
## 2025-06-26 - 1.0.101 - fix(wcc-dashboard)
|
||||
Improve scroll listener management and add new test pages
|
||||
|
||||
- Removed the pages/ directory entry from .gitignore to allow test pages to be tracked
|
||||
- Added new test pages: page1 and pageLongScroll for enhanced scroll and navigation testing
|
||||
- Refactored wcc-dashboard: changed scroll position properties to private variables and added a flag to prevent duplicate scroll listener attachment
|
||||
|
||||
## 2025-06-26 - 1.0.100 - fix(wcc-dashboard)
|
||||
Prevent duplicate application of scroll positions in dashboard to avoid interfering with user scrolling
|
||||
|
||||
- Added a private 'scrollPositionsApplied' property to track if scroll positions have already been applied
|
||||
- Introduced a guard in the applyScrollPositions method to ensure the scroll state is applied only once
|
||||
|
||||
## 2025-06-26 - 1.0.99 - fix(dashboard)
|
||||
Fix scroll state preservation in dashboard by tracking frame and sidebar scroll positions and updating the URL accordingly.
|
||||
|
||||
- Added frameScrollY and sidebarScrollY properties to capture scroll positions.
|
||||
- Set up scroll listeners on wcc-frame and wcc-sidebar to update scroll state.
|
||||
- Implemented debounced updates to modify the URL with current scroll positions without navigation.
|
||||
- Restored scroll positions from URL query parameters during initialization.
|
||||
|
||||
## 2025-06-16 - 1.0.97 - properties-panel
|
||||
- Improve element detection timing and value handling in properties panel
|
||||
|
||||
## 2025-06-16 - 1.0.96 - properties-panel
|
||||
- Enhance element detection and error handling for nested structures
|
||||
|
||||
## 2025-06-16 - 1.0.95 - package
|
||||
- Correct path for demotools export in package.json
|
||||
|
||||
## 2025-06-16 - 1.0.94 - demotools
|
||||
- Enhance runAfterRender to provide full DOM API access and improve element selection
|
||||
|
||||
## 2025-06-16 - 1.0.92 - demotools
|
||||
- Update DeesDemoWrapper to handle multiple slotted elements in runAfterRender callback
|
||||
|
||||
## 2025-06-16 - 1.0.91 - readme
|
||||
- Update documentation with comprehensive overview, quick start guide, and detailed feature descriptions
|
||||
|
||||
## 2025-06-16 - 1.0.90 - demo/properties/refactor
|
||||
- Add DeesDemoWrapper component for enhanced demo element handling
|
||||
- Enhance element detection in properties panel with recursive search and retry mechanism
|
||||
- Refactor code structure for improved readability and maintainability
|
||||
|
||||
## 2024-05-06 to 2020-05-10 - 1.0.89–1.0.17 - core
|
||||
- Over a series of releases, trivial core fixes and updates were applied.
|
||||
- (Note: Version 1.0.87 also included an update to the documentation.)
|
||||
24
package.json
24
package.json
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@design.estate/dees-wcctools",
|
||||
"version": "1.0.91",
|
||||
"version": "2.0.1",
|
||||
"private": false,
|
||||
"description": "A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.",
|
||||
"exports": {
|
||||
".": "./dist_ts_web/index.js",
|
||||
"./demoTools": "./dist_ts_demotools"
|
||||
"./demotools": "./dist_ts_demotools/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,18 +17,20 @@
|
||||
"author": "Lossless GmbH",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@design.estate/dees-domtools": "^2.0.57",
|
||||
"@design.estate/dees-element": "^2.0.34",
|
||||
"@design.estate/dees-domtools": "^2.3.6",
|
||||
"@design.estate/dees-element": "^2.1.3",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"lit": "^3.1.3"
|
||||
"lit": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@api.global/typedserver": "^3.0.29",
|
||||
"@git.zone/tsbuild": "^2.1.72",
|
||||
"@git.zone/tsbundle": "^2.0.15",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tswatch": "^2.0.23",
|
||||
"@push.rocks/projectinfo": "^5.0.2"
|
||||
"@api.global/typedserver": "^7.11.1",
|
||||
"@git.zone/tsbuild": "^3.1.2",
|
||||
"@git.zone/tsbundle": "^2.6.3",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@git.zone/tswatch": "^2.3.12",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@types/node": "^25.0.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
|
||||
6698
pnpm-lock.yaml
generated
6698
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
149
readme.hints.md
149
readme.hints.md
@@ -1,5 +1,107 @@
|
||||
# Project Hints and Findings
|
||||
|
||||
## UI Redesign with Shadcn-like Styles (2025-06-27)
|
||||
|
||||
### Changes Made
|
||||
Updated the WCC Dashboard UI components (properties and sidebar) to use shadcn-like design patterns:
|
||||
|
||||
1. **Color System**: Implemented CSS variables for theming:
|
||||
- `--background`, `--foreground`, `--card`, `--primary`, `--secondary`
|
||||
- `--muted`, `--accent`, `--border`, `--input`, `--ring`
|
||||
- Consistent dark theme with subtle borders and proper contrast
|
||||
- Dynamic theme switching between light and dark modes
|
||||
|
||||
2. **Properties Panel Improvements (Updated)**:
|
||||
- Changed from fixed 3-column grid to flexible flexbox layout
|
||||
- Properties now wrap and use space more efficiently
|
||||
- Added rounded corners (using --radius-md) and better spacing
|
||||
- Property items use flexbox with min-width for responsive layout
|
||||
- Property labels now show as styled headers with type info
|
||||
- Form controls updated with shadcn-style focus states and transitions
|
||||
- Complex properties (Objects/Arrays) show "Edit" button
|
||||
- Advanced JSON editor appears above properties panel when editing complex types
|
||||
- Dynamic height adjustment (50px when editor is open, 120px normally)
|
||||
|
||||
3. **Sidebar Styling**:
|
||||
- Updated with consistent color scheme
|
||||
- Added rounded corners to menu items
|
||||
- Improved hover states with smooth transitions
|
||||
- Better typography with proper font weights
|
||||
|
||||
4. **Advanced Property Editor**:
|
||||
- JSON editor for complex types (Objects and Arrays)
|
||||
- Monaco-style monospace font for code editing
|
||||
- Live updates to element properties
|
||||
- Positioned above the properties panel with smooth transitions
|
||||
|
||||
5. **Theme and Viewport Selectors (New)**:
|
||||
- Redesigned buttons with flexbox layout for better icon/text alignment
|
||||
- Added hover effects with transform and shadow
|
||||
- Smooth transitions on all interactive elements
|
||||
- Selected state uses primary color variables
|
||||
- Icons reduced in size for better balance
|
||||
|
||||
6. **Form Controls (New)**:
|
||||
- Input fields and selects now have:
|
||||
- Rounded corners (--radius-sm)
|
||||
- Consistent padding (0.5rem 0.75rem)
|
||||
- Focus states with ring effect using box-shadow
|
||||
- Smooth transition animations
|
||||
- Checkboxes use accent-color for theming
|
||||
|
||||
### Technical Details
|
||||
- Uses system font stack ('Inter' preferred) for better native appearance
|
||||
- Subtle borders with CSS variables for consistency
|
||||
- Consistent spacing using rem units
|
||||
- Smooth transitions (0.2s ease) for interactive elements
|
||||
- Custom scrollbar styling for better visual integration
|
||||
- Grid layout with 1px gaps creating subtle dividers
|
||||
- Warning display with backdrop blur and rounded corners
|
||||
|
||||
## Advanced Complex Properties Editor (2025-06-27)
|
||||
|
||||
### Overview
|
||||
Implemented an advanced editor for complex properties (Arrays and Objects) that appears between the wcc-properties panel and frame when activated.
|
||||
|
||||
### Features
|
||||
1. **Dynamic Layout**: Frame shrinks by 300px from bottom when editor opens
|
||||
2. **Multiple Editors**: Can edit multiple properties simultaneously side by side
|
||||
3. **JSON Editor**:
|
||||
- Monospace font for code editing
|
||||
- Tab key support for indentation
|
||||
- Syntax validation with error messages
|
||||
- Live preview of changes
|
||||
4. **Smooth Transitions**: Animated opening/closing with 0.3s ease
|
||||
5. **Error Handling**: Invalid JSON shows clear error messages that disappear on typing
|
||||
6. **Close All Button**: Single button to close all open editors at once
|
||||
|
||||
### Technical Implementation (Updated)
|
||||
- **State Management**: Changed from single editor to array of editors with unique IDs
|
||||
- **Editor Structure**: Each editor instance contains:
|
||||
- `id`: Unique identifier (`propertyName-timestamp`)
|
||||
- `name`: Property name
|
||||
- `value`: Original value
|
||||
- `element`: Reference to the element
|
||||
- `editorValue`: Current JSON string
|
||||
- `editorError`: Validation error message
|
||||
- **Event System**: Uses custom 'editorStateChanged' event to communicate with parent dashboard
|
||||
- **Dynamic Styling**: wcc-frame's bottom position changes from 100px to 400px when any editor is open
|
||||
- **Property Types**: Object and Array properties show "Edit Object/Array" button instead of inline controls
|
||||
|
||||
### User Flow
|
||||
1. Click "Edit Object/Array" button on complex property
|
||||
2. Editor slides up between properties panel and frame
|
||||
3. Click additional "Edit" buttons to open more properties side by side
|
||||
4. Each editor can be saved/cancelled independently
|
||||
5. "Close All" button dismisses all editors at once
|
||||
6. Frame automatically resizes back when all editors are closed
|
||||
|
||||
### Layout Details
|
||||
- **Container**: Flexbox with horizontal scrolling when multiple editors overflow
|
||||
- **Editor Width**: Min 300px, max 500px, flexible between
|
||||
- **Scrollbar**: Custom styled thin scrollbar for horizontal overflow
|
||||
- **Header Bar**: Fixed top bar with "Property Editors" title and "Close All" button
|
||||
|
||||
## Properties Panel Element Detection Issue (Fixed)
|
||||
|
||||
### Problem
|
||||
@@ -12,16 +114,24 @@ The properties panel had timing issues detecting rendered elements because:
|
||||
1. Added a 100ms initial delay to allow render completion
|
||||
2. Implemented recursive element search that:
|
||||
- Searches through nested children up to 5 levels deep
|
||||
- Checks shadow roots of elements
|
||||
- Handles complex DOM structures
|
||||
- Checks both light DOM and shadow DOM for all elements
|
||||
- Handles complex DOM structures generically
|
||||
- Works with any wrapper elements, not specific to dees-demowrapper
|
||||
3. Added retry mechanism with up to 5 attempts (200ms between retries)
|
||||
4. Improved error messages to show retry count
|
||||
5. Comprehensive error handling:
|
||||
- Errors in element search don't break the update cycle
|
||||
- Individual property errors don't prevent other properties from rendering
|
||||
- scheduleUpdate always completes even if createProperties fails
|
||||
- Clears warnings and property content appropriately on errors
|
||||
|
||||
### Code Flow
|
||||
1. Dashboard renders element demo into viewport using `render(anonItem.demo(), viewport)`
|
||||
2. Properties panel waits, then searches recursively for the element instance
|
||||
3. If not found, retries with delays to handle async rendering
|
||||
4. Once found, extracts and displays element properties
|
||||
2. Properties panel waits 200ms for demo wrappers to run and set initial values
|
||||
3. Searches recursively for the element instance
|
||||
4. If not found, retries with delays to handle async rendering
|
||||
5. Once found, extracts and displays element properties
|
||||
6. Uses property binding (`.value=`) instead of attribute binding to prevent input events during initialization
|
||||
|
||||
## Demo Tools
|
||||
|
||||
@@ -30,22 +140,37 @@ A utility component for wrapping demo elements with post-render functionality.
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { DeesDemoWrapper } from '@design.estate/dees-wcctools/demoTools';
|
||||
import * as demoTools from '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
// In your demo function:
|
||||
demo: () => html`
|
||||
<dees-demowrapper .runAfterRender=${(element) => {
|
||||
// Do something with the rendered element
|
||||
element.setAttribute('data-demo', 'true');
|
||||
console.log('Element rendered:', element);
|
||||
<dees-demowrapper .runAfterRender=${(wrapper) => {
|
||||
// Use querySelector for specific elements
|
||||
const myElement = wrapper.querySelector('my-custom-element');
|
||||
myElement?.setAttribute('data-demo', 'true');
|
||||
|
||||
// Access all children
|
||||
console.log('All children:', wrapper.children);
|
||||
|
||||
// Use querySelectorAll for multiple elements
|
||||
wrapper.querySelectorAll('div').forEach(div => {
|
||||
console.log('Found div:', div);
|
||||
});
|
||||
|
||||
// Full DOM API available
|
||||
const firstChild = wrapper.firstElementChild;
|
||||
const hasClass = wrapper.querySelector('.my-class');
|
||||
}}>
|
||||
<my-custom-element></my-custom-element>
|
||||
<div>Additional content</div>
|
||||
</dees-demowrapper>
|
||||
`
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Wraps demo elements without affecting layout (uses `display: contents`)
|
||||
- Provides access to the rendered element instance after mounting
|
||||
- Provides the wrapper element itself with full DOM API access
|
||||
- Use querySelector/querySelectorAll for powerful element selection
|
||||
- Access children via wrapper.children property
|
||||
- Supports async operations in runAfterRender callback
|
||||
- Automatically handles timing to ensure element is fully rendered
|
||||
- Automatically handles timing to ensure elements are fully rendered
|
||||
416
readme.md
416
readme.md
@@ -1,117 +1,397 @@
|
||||
# @design.estate/dees-wcctools
|
||||
wcc tools for creating element catalogues
|
||||
|
||||
## Install
|
||||
To install `@design.estate/dees-wcctools`, you can use npm:
|
||||
🛠️ **Web Component Development Tools** — A powerful framework for building, testing, documenting, and recording web components
|
||||
|
||||
## Overview
|
||||
|
||||
`@design.estate/dees-wcctools` provides a comprehensive development environment for web components, featuring:
|
||||
|
||||
- 🎨 **Interactive Component Catalogue** — Live preview with sidebar navigation
|
||||
- 🔧 **Real-time Property Editing** — Modify component props on the fly with auto-detected editors
|
||||
- 🌓 **Theme Switching** — Test light/dark modes instantly
|
||||
- 📱 **Responsive Viewport Testing** — Phone, phablet, tablet, and desktop views
|
||||
- 🎬 **Screen Recording** — Record component demos with audio support and video trimming
|
||||
- 🧪 **Advanced Demo Tools** — Post-render hooks for interactive testing
|
||||
- 🚀 **Zero-config Setup** — TypeScript and Lit support out of the box
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @design.estate/dees-wcctools --save
|
||||
# Using pnpm (recommended)
|
||||
pnpm add -D @design.estate/dees-wcctools
|
||||
|
||||
# Using npm
|
||||
npm install @design.estate/dees-wcctools --save-dev
|
||||
```
|
||||
|
||||
## Usage
|
||||
The `@design.estate/dees-wcctools` package provides a set of tools for creating element catalogues using Web Components. It leverages LitElement for creating custom elements and provides a structured way to showcase and test these elements in various environments and themes.
|
||||
## Quick Start
|
||||
|
||||
### Setting Up
|
||||
First, ensure that your project is set up to use TypeScript and ESM syntax. This guide assumes you have a basic understanding of TypeScript and modern JavaScript development practices.
|
||||
|
||||
Start by importing the necessary tools from `@design.estate/dees-wcctools` in your main TypeScript file.
|
||||
### 1. Create Your Component
|
||||
|
||||
```typescript
|
||||
import { DeesElement, customElement, html, css, property } from '@design.estate/dees-element';
|
||||
|
||||
@customElement('my-button')
|
||||
export class MyButton extends DeesElement {
|
||||
// Define a demo for the catalogue
|
||||
public static demo = () => html`
|
||||
<my-button .label=${'Click me!'} .variant=${'primary'}></my-button>
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
accessor label: string = 'Button';
|
||||
|
||||
@property({ type: String })
|
||||
accessor variant: 'primary' | 'secondary' = 'primary';
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
button.primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
button.secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<button class="${this.variant}">
|
||||
${this.label}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Set Up Your Catalogue
|
||||
|
||||
```typescript
|
||||
// catalogue.ts
|
||||
import { setupWccTools } from '@design.estate/dees-wcctools';
|
||||
import { html } from 'lit';
|
||||
|
||||
// Import your components
|
||||
import { MyButton } from './components/my-button.js';
|
||||
import { MyCard } from './components/my-card.js';
|
||||
|
||||
// Define elements for the catalogue
|
||||
const elements = {
|
||||
'my-button': MyButton,
|
||||
'my-card': MyCard,
|
||||
};
|
||||
|
||||
// Optionally define pages
|
||||
const pages = {
|
||||
'home': () => html`
|
||||
<div style="padding: 20px;">
|
||||
<h1>Welcome to My Component Library</h1>
|
||||
<p>Browse components using the sidebar.</p>
|
||||
</div>
|
||||
`,
|
||||
'getting-started': () => html`
|
||||
<div style="padding: 20px;">
|
||||
<h2>Getting Started</h2>
|
||||
<p>Installation and usage instructions...</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
// Initialize the catalogue
|
||||
setupWccTools(elements, pages);
|
||||
```
|
||||
|
||||
### Defining Custom Elements
|
||||
Define your custom elements using LitElement. Here's a simple example of an element:
|
||||
### 3. Create an HTML Entry Point
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Component Catalogue</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0;">
|
||||
<script type="module" src="./catalogue.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 🎯 Live Property Editing
|
||||
|
||||
The properties panel automatically detects and allows editing of:
|
||||
|
||||
| Property Type | Editor |
|
||||
|--------------|--------|
|
||||
| **String** | Text input |
|
||||
| **Number** | Number input |
|
||||
| **Boolean** | Checkbox |
|
||||
| **Enum** | Select dropdown |
|
||||
| **Object/Array** | JSON editor modal |
|
||||
|
||||
### 📱 Viewport Testing
|
||||
|
||||
Test your components across different screen sizes:
|
||||
|
||||
- **Phone** — 320px width
|
||||
- **Phablet** — 600px width
|
||||
- **Tablet** — 768px width
|
||||
- **Desktop** — Full width (native)
|
||||
|
||||
### 🌓 Theme Support
|
||||
|
||||
Components automatically adapt to light/dark themes using the `goBright` property:
|
||||
|
||||
```typescript
|
||||
import { LitElement, html, customElement } from 'lit';
|
||||
|
||||
@customElement('my-element')
|
||||
class MyElement extends LitElement {
|
||||
render() {
|
||||
return html`<p>Hello, world!</p>`;
|
||||
}
|
||||
public render() {
|
||||
return html`
|
||||
<div class="${this.goBright ? 'light-theme' : 'dark-theme'}">
|
||||
<!-- Your component content -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
### Bootstrapping the WCCTools Dashboard
|
||||
To showcase your elements, `@design.estate/dees-wcctools` provides a handy way to bootstrap a dashboard where your elements can be registered and displayed.
|
||||
|
||||
Create a bootstrap function in your main file or a separate module:
|
||||
Or use CSS custom properties with the theme manager:
|
||||
|
||||
```typescript
|
||||
async function bootstrapWCCTools() {
|
||||
// Define your elements here
|
||||
const elements = {
|
||||
'my-element': MyElement, // Assuming MyElement is imported
|
||||
};
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
// Optionally, define pages as functions returning Lit HTML Templates
|
||||
const pages = {
|
||||
home: () => html`<h1>Welcome to My Element Catalogue</h1>`,
|
||||
};
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
background: ${cssManager.bdTheme('#fff', '#000')};
|
||||
}
|
||||
`
|
||||
];
|
||||
```
|
||||
|
||||
// Setup the WCCTools dashboard
|
||||
setupWccTools(elements, pages);
|
||||
### 🎬 Screen Recording
|
||||
|
||||
Record component demos directly from the catalogue! The built-in recorder supports:
|
||||
|
||||
- **Viewport Recording** — Record just the component viewport
|
||||
- **Full Screen Recording** — Capture the entire screen
|
||||
- **Audio Support** — Add microphone commentary with live level monitoring
|
||||
- **Video Trimming** — Trim start/end before export with visual timeline
|
||||
- **WebM Export** — High-quality video output
|
||||
|
||||
Click the red record button in the bottom toolbar to start.
|
||||
|
||||
### 🧪 Demo Tools
|
||||
|
||||
The demotools module provides enhanced testing capabilities with `dees-demowrapper`:
|
||||
|
||||
```typescript
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
@customElement('my-component')
|
||||
export class MyComponent extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${async (wrapper) => {
|
||||
// Find elements using standard DOM APIs
|
||||
const myComponent = wrapper.querySelector('my-component');
|
||||
|
||||
// Simulate user interactions
|
||||
myComponent.value = 'Test value';
|
||||
await myComponent.updateComplete;
|
||||
|
||||
// Work with multiple elements
|
||||
wrapper.querySelectorAll('.item').forEach((el, i) => {
|
||||
console.log(`Item ${i}:`, el.textContent);
|
||||
});
|
||||
}}>
|
||||
<my-component></my-component>
|
||||
<div class="item">Item 1</div>
|
||||
<div class="item">Item 2</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
Call this function to initialize your catalogue:
|
||||
### ⏳ Async Demos
|
||||
|
||||
Return a `Promise` from `demo` for async setup. The dashboard waits for resolution:
|
||||
|
||||
```typescript
|
||||
bootstrapWCCTools();
|
||||
public static demo = async () => {
|
||||
const data = await fetchSomeData();
|
||||
return html`<my-component .data=${data}></my-component>`;
|
||||
};
|
||||
```
|
||||
|
||||
### Configurations and Customizations
|
||||
The `setupWccTools` function accepts two arguments: `elements` and `pages`.
|
||||
### 🎭 Container Queries
|
||||
|
||||
- `elements`: An object where keys are element tags (e.g., 'my-element') and values are the corresponding class definitions.
|
||||
- `pages`: An optional object where keys are page identifiers and values are functions returning Lit HTML templates.
|
||||
|
||||
### Testing Elements
|
||||
Once the dashboard is set up, navigate to your project in a web browser. You'll see a sidebar listing all registered elements and pages. Clicking on an element name will display it in the main view, allowing you to interact with it and see it in action.
|
||||
|
||||
### Theme and Environment Testing
|
||||
The dashboard also provides options for testing your elements in different environments (e.g., desktop, tablet) and themes (light or dark). This helps ensure that your elements are versatile and adaptable to varying conditions.
|
||||
|
||||
### Expanding Your Catalogue
|
||||
To add more elements to your catalogue, simply extend the `elements` object and rerun `bootstrapWCCTools()`. This modular approach makes it easy to maintain and expand your element catalogue.
|
||||
|
||||
### Leveraging TypeScript
|
||||
Using TypeScript allows you to enforce typing and build more reliable web components. Define properties with decorators, and use TypeScript's features to enhance your component development process.
|
||||
Components can respond to their container size using the `wccToolsViewport` container:
|
||||
|
||||
```typescript
|
||||
import { LitElement, property, html, customElement } from 'lit';
|
||||
public static styles = [
|
||||
css`
|
||||
@container wccToolsViewport (min-width: 768px) {
|
||||
:host {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('typed-element')
|
||||
class TypedElement extends LitElement {
|
||||
@property({type: String})
|
||||
name: string = 'World';
|
||||
|
||||
render() {
|
||||
return html`<p>Hello, ${this.name}!</p>`;
|
||||
}
|
||||
@container wccToolsViewport (max-width: 767px) {
|
||||
:host {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`
|
||||
];
|
||||
```
|
||||
|
||||
## Component Guidelines
|
||||
|
||||
### Required for Catalogue Display
|
||||
|
||||
1. Components must expose a static `demo` property returning a Lit template
|
||||
2. Use `@property()` decorators with the `accessor` keyword for editable properties
|
||||
3. Export component classes for proper detection
|
||||
|
||||
### Best Practices
|
||||
|
||||
```typescript
|
||||
@customElement('best-practice-component')
|
||||
export class BestPracticeComponent extends DeesElement {
|
||||
// ✅ Static demo property
|
||||
public static demo = () => html`
|
||||
<best-practice-component
|
||||
.complexProp=${{ key: 'value' }}
|
||||
simpleAttribute="test"
|
||||
></best-practice-component>
|
||||
`;
|
||||
|
||||
// ✅ Typed properties with defaults (TC39 decorators)
|
||||
@property({ type: String })
|
||||
accessor title: string = 'Default Title';
|
||||
|
||||
// ✅ Complex property without attribute
|
||||
@property({ attribute: false })
|
||||
accessor complexProp: { key: string } = { key: 'default' };
|
||||
|
||||
// ✅ Enum with proper typing
|
||||
@property({ type: String })
|
||||
accessor variant: 'small' | 'medium' | 'large' = 'medium';
|
||||
}
|
||||
```
|
||||
|
||||
### Conclusion
|
||||
`@design.estate/dees-wcctools` provides a powerful, flexible platform for developing, showcasing, and testing web components. By leveraging modern development practices like TypeScript and LitElement, you can build a robust catalogue of reusable web components ready for any project.
|
||||
## URL Routing
|
||||
|
||||
The catalogue uses URL routing for deep linking:
|
||||
|
||||
```
|
||||
/wcctools-route/:type/:name/:viewport/:theme
|
||||
|
||||
Examples:
|
||||
/wcctools-route/element/my-button/desktop/dark
|
||||
/wcctools-route/page/home/tablet/bright
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `setupWccTools(elements, pages?)`
|
||||
|
||||
Initialize the WCC Tools dashboard.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `elements` | `Record<string, typeof LitElement>` | Map of element names to classes |
|
||||
| `pages` | `Record<string, TTemplateFactory>` | Optional map of page names to template functions |
|
||||
|
||||
### `DeesDemoWrapper`
|
||||
|
||||
Component for wrapping demos with post-render logic.
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `runAfterRender` | `(wrapper) => void \| Promise<void>` | Callback after wrapped elements render |
|
||||
|
||||
The wrapper provides full DOM API access:
|
||||
- `wrapper.querySelector()` — Find single element
|
||||
- `wrapper.querySelectorAll()` — Find multiple elements
|
||||
- `wrapper.children` — Access child elements directly
|
||||
|
||||
### Recording Components (Advanced)
|
||||
|
||||
For custom recording integrations:
|
||||
|
||||
```typescript
|
||||
import { RecorderService } from '@design.estate/dees-wcctools';
|
||||
|
||||
const recorder = new RecorderService({
|
||||
onDurationUpdate: (duration) => console.log(`${duration}s`),
|
||||
onRecordingComplete: (blob) => console.log('Recording done!', blob),
|
||||
onAudioLevelUpdate: (level) => console.log(`Audio: ${level}%`),
|
||||
});
|
||||
|
||||
await recorder.startRecording({ mode: 'viewport' });
|
||||
// ... later
|
||||
recorder.stopRecording();
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
my-components/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── my-button.ts
|
||||
│ │ └── my-card.ts
|
||||
│ └── catalogue.ts
|
||||
├── dist/
|
||||
├── index.html
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
- ✅ Chrome/Edge (latest)
|
||||
- ✅ Firefox (latest)
|
||||
- ✅ Safari (latest)
|
||||
- ✅ Mobile browsers with Web Components support
|
||||
|
||||
## 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](license) file within this repository.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
@@ -34,8 +34,74 @@ The properties panel has timing issues detecting rendered elements because:
|
||||
## Created DeesDemoWrapper Component
|
||||
- Location: ts_demotools/demotools.ts
|
||||
- Allows wrapping demo elements with post-render functionality
|
||||
- Provides runAfterRender callback that receives the rendered element
|
||||
- Provides runAfterRender callback that receives ALL slotted elements as HTMLCollection
|
||||
- Uses display: contents to not affect layout
|
||||
- Handles timing automatically with 50ms delay after firstUpdated
|
||||
- Supports both sync and async callbacks
|
||||
- Exports available at @design.estate/dees-wcctools/demoTools
|
||||
- Exports available at @design.estate/dees-wcctools/demotools (lowercase)
|
||||
|
||||
# Documentation Update (COMPLETED)
|
||||
|
||||
## Updated readme.md with:
|
||||
- Comprehensive overview with feature highlights
|
||||
- Quick start guide with code examples
|
||||
- Detailed feature documentation
|
||||
- Advanced demo tools usage
|
||||
- Best practices and guidelines
|
||||
- API reference
|
||||
- Browser support information
|
||||
- Complete examples for all major features
|
||||
|
||||
# Enhanced DemoWrapper (COMPLETED)
|
||||
|
||||
## Modified runAfterRender callback:
|
||||
- Now receives the wrapper element itself instead of just children
|
||||
- Provides full DOM API access (querySelector, querySelectorAll, etc.)
|
||||
- querySelector works on slotted content (light DOM children)
|
||||
- Access children via wrapper.children property
|
||||
- Updated documentation with correct import path (lowercase 'demotools')
|
||||
- Examples show how to use querySelector for powerful element selection
|
||||
- Added clarifying comment about querySelector working on slotted content
|
||||
|
||||
## Fixed Properties Panel Compatibility:
|
||||
- Made element search generic - works with any container elements
|
||||
- Searches both light DOM and shadow DOM recursively
|
||||
- Improved error handling to prevent breaking the update cycle
|
||||
- Errors in one property don't prevent others from rendering
|
||||
- Detection continues working even after errors occur
|
||||
- Maintains compatibility with all element structures
|
||||
|
||||
# Test Elements Created (COMPLETED)
|
||||
|
||||
## Created comprehensive test elements:
|
||||
1. **test-noprops** - Element with no @property decorators
|
||||
2. **test-complextypes** - Element with arrays, objects, dates, and complex nested data
|
||||
3. **test-withwrapper** - Element that uses dees-demowrapper in its demo
|
||||
4. **test-edgecases** - Element with edge cases (null, undefined, NaN, Infinity, circular refs)
|
||||
5. **test-nested** - Element with deeply nested structure to test recursive search
|
||||
|
||||
These test various scenarios:
|
||||
- Properties panel handling of elements without properties
|
||||
- Complex data type display and editing
|
||||
- Element detection inside dees-demowrapper
|
||||
- Error handling for problematic values
|
||||
- Deep nesting and shadow DOM traversal
|
||||
|
||||
# Fixed Demo Value Overwriting (COMPLETED)
|
||||
|
||||
## Issue:
|
||||
Properties panel was overwriting values set by demo functions
|
||||
|
||||
## Solution:
|
||||
1. Changed from attribute binding (`value=`) to property binding (`.value=`)
|
||||
2. This prevents browser from firing input events during initialization
|
||||
3. Added proper number parsing for number inputs
|
||||
4. Increased initial wait to 200ms for demo wrappers to complete
|
||||
5. Simplified select element handling to use property binding
|
||||
# Async Demo Support (IN PROGRESS)
|
||||
|
||||
## Tasks
|
||||
- [ ] Allow dashboard-selected items to return Promise-based TemplateResults
|
||||
- [ ] Await async demos/pages before rendering them into the viewport
|
||||
- [ ] Add regression test covering async demo usage
|
||||
- [ ] Document async demo pattern in README and verify with pnpm scripts
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export * from './test-demoelement.js';
|
||||
export * from './test-noprops.js';
|
||||
export * from './test-complextypes.js';
|
||||
export * from './test-withwrapper.js';
|
||||
export * from './test-edgecases.js';
|
||||
export * from './test-nested.js';
|
||||
|
||||
137
test/elements/test-complextypes.ts
Normal file
137
test/elements/test-complextypes.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
interface IComplexData {
|
||||
name: string;
|
||||
age: number;
|
||||
tags: string[];
|
||||
metadata: {
|
||||
created: Date;
|
||||
modified: Date;
|
||||
author: string;
|
||||
};
|
||||
}
|
||||
|
||||
@customElement('test-complextypes')
|
||||
export class TestComplexTypes extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<test-complextypes
|
||||
.complexData=${{
|
||||
name: 'Test User',
|
||||
age: 25,
|
||||
tags: ['developer', 'designer'],
|
||||
metadata: {
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
author: 'System'
|
||||
}
|
||||
}}
|
||||
></test-complextypes>
|
||||
`;
|
||||
|
||||
@property({ type: Array })
|
||||
accessor stringArray: string[] = ['apple', 'banana', 'cherry'];
|
||||
|
||||
@property({ type: Array })
|
||||
accessor numberArray: number[] = [1, 2, 3, 4, 5];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor complexData: IComplexData = {
|
||||
name: 'Default Name',
|
||||
age: 0,
|
||||
tags: [],
|
||||
metadata: {
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
author: 'Unknown'
|
||||
}
|
||||
};
|
||||
|
||||
@property({ type: Object })
|
||||
accessor simpleObject = {
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
key3: 123
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor functionProperty = () => {
|
||||
console.log('This is a function property');
|
||||
};
|
||||
|
||||
@property({ type: Date })
|
||||
accessor dateProperty = new Date();
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.section {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.value {
|
||||
color: #666;
|
||||
margin-left: 10px;
|
||||
}
|
||||
pre {
|
||||
background: #f0f0f0;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div class="section">
|
||||
<span class="label">String Array:</span>
|
||||
<span class="value">${this.stringArray.join(', ')}</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<span class="label">Number Array:</span>
|
||||
<span class="value">${this.numberArray.join(', ')}</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<span class="label">Complex Data:</span>
|
||||
<pre>${JSON.stringify(this.complexData, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<span class="label">Simple Object:</span>
|
||||
<pre>${JSON.stringify(this.simpleObject, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<span class="label">Date Property:</span>
|
||||
<span class="value">${this.dateProperty.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<span class="label">Function Property:</span>
|
||||
<span class="value">${typeof this.functionProperty}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -18,38 +18,42 @@ enum ETestEnum {
|
||||
|
||||
@customElement('test-demoelement')
|
||||
export class TestDemoelement extends DeesElement {
|
||||
public static demo = () => html`<test-demoelement>This is a slot text</test-demoelement>`;
|
||||
public static demo = [
|
||||
() => html`<test-demoelement>This is demo 1</test-demoelement>`,
|
||||
() => html`<test-demoelement>This is demo 2</test-demoelement>`,
|
||||
() => html`<test-demoelement>This is demo 2</test-demoelement>`,
|
||||
]
|
||||
|
||||
@property()
|
||||
public notTyped = 'hello';
|
||||
accessor notTyped = 'hello';
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public typedAndNotInitizalized: string;
|
||||
accessor typedAndNotInitizalized: string;
|
||||
|
||||
@property()
|
||||
public notTypedAndNotInitizalized: string;
|
||||
accessor notTypedAndNotInitizalized: string;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
public demoBoolean = false;
|
||||
accessor demoBoolean = false;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public demoString = 'default demo string';
|
||||
accessor demoString = 'default demo string';
|
||||
|
||||
@property({
|
||||
type: Number,
|
||||
})
|
||||
public demoNumber = 2;
|
||||
accessor demoNumber = 2;
|
||||
|
||||
@property({
|
||||
type: ETestEnum,
|
||||
})
|
||||
public demoENum: ETestEnum = ETestEnum.first;
|
||||
accessor demoENum: ETestEnum = ETestEnum.first;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
195
test/elements/test-edgecases.ts
Normal file
195
test/elements/test-edgecases.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('test-edgecases')
|
||||
export class TestEdgeCases extends DeesElement {
|
||||
public static demo = () => html`<test-edgecases></test-edgecases>`;
|
||||
|
||||
// Property with null value
|
||||
@property({ type: String })
|
||||
accessor nullableString: string | null = null;
|
||||
|
||||
// Property with undefined value
|
||||
@property({ type: Number })
|
||||
accessor undefinedNumber: number | undefined = undefined;
|
||||
|
||||
// Very long string
|
||||
@property({ type: String })
|
||||
accessor longString: string = 'Lorem ipsum '.repeat(50);
|
||||
|
||||
// Property with special characters
|
||||
@property({ type: String })
|
||||
accessor specialChars: string = '!@#$%^&*()_+-=[]{}|;\':",./<>?`~';
|
||||
|
||||
// Property that could cause rendering issues
|
||||
@property({ type: String })
|
||||
accessor htmlString: string = '<script>alert("test")</script><b>Bold text</b>';
|
||||
|
||||
// Numeric edge cases
|
||||
@property({ type: Number })
|
||||
accessor infinityNumber: number = Infinity;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor nanNumber: number = NaN;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor veryLargeNumber: number = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor verySmallNumber: number = Number.MIN_SAFE_INTEGER;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor floatNumber: number = 3.14159265359;
|
||||
|
||||
// Boolean-like values
|
||||
@property({ type: String })
|
||||
accessor booleanString: string = 'false';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor booleanNumber: number = 0;
|
||||
|
||||
// Empty values
|
||||
@property({ type: String })
|
||||
accessor emptyString: string = '';
|
||||
|
||||
@property({ type: Array })
|
||||
accessor emptyArray: any[] = [];
|
||||
|
||||
@property({ type: Object })
|
||||
accessor emptyObject: {} = {};
|
||||
|
||||
// Circular reference (should not break properties panel)
|
||||
@property({ attribute: false })
|
||||
accessor circularRef: any = (() => {
|
||||
const obj: any = { name: 'circular' };
|
||||
obj.self = obj;
|
||||
return obj;
|
||||
})();
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 20px;
|
||||
background: #fff3e0;
|
||||
border: 2px solid #ff9800;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.warning {
|
||||
background: #ffe0b2;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
color: #e65100;
|
||||
}
|
||||
.property {
|
||||
margin: 5px 0;
|
||||
padding: 5px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.label {
|
||||
font-weight: bold;
|
||||
color: #f57c00;
|
||||
}
|
||||
.value {
|
||||
color: #666;
|
||||
}
|
||||
.special {
|
||||
background: #ffccbc;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
private formatValue(value: any): string {
|
||||
if (value === null) return 'null';
|
||||
if (value === undefined) return 'undefined';
|
||||
if (value === Infinity) return 'Infinity';
|
||||
if (Number.isNaN(value)) return 'NaN';
|
||||
if (typeof value === 'string' && value.length > 50) {
|
||||
return value.substring(0, 50) + '...';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (e) {
|
||||
return '[Circular Reference]';
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div class="warning">
|
||||
⚠️ This element tests edge cases and problematic values
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">Nullable String:</span>
|
||||
<span class="value special">${this.formatValue(this.nullableString)}</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">Undefined Number:</span>
|
||||
<span class="value special">${this.formatValue(this.undefinedNumber)}</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">Long String:</span>
|
||||
<span class="value">${this.formatValue(this.longString)}</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">Special Characters:</span>
|
||||
<span class="value">${this.specialChars}</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">HTML String (escaped):</span>
|
||||
<span class="value">${this.htmlString}</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">Infinity:</span>
|
||||
<span class="value special">${this.formatValue(this.infinityNumber)}</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">NaN:</span>
|
||||
<span class="value special">${this.formatValue(this.nanNumber)}</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">Very Large Number:</span>
|
||||
<span class="value">${this.veryLargeNumber}</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">Float Number:</span>
|
||||
<span class="value">${this.floatNumber}</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">Empty String:</span>
|
||||
<span class="value special">[empty]</span>
|
||||
</div>
|
||||
|
||||
<div class="property">
|
||||
<span class="label">Circular Reference:</span>
|
||||
<span class="value special">${this.formatValue(this.circularRef)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
127
test/elements/test-nested.ts
Normal file
127
test/elements/test-nested.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
// Helper component for nesting
|
||||
@customElement('test-nested-wrapper')
|
||||
class TestNestedWrapper extends DeesElement {
|
||||
public render() {
|
||||
return html`
|
||||
<div style="border: 1px dashed #ccc; padding: 10px; margin: 5px;">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// The actual test element deeply nested
|
||||
@customElement('test-nested-target')
|
||||
class TestNestedTarget extends DeesElement {
|
||||
@property({ type: String })
|
||||
accessor message: string = 'I am deeply nested!';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor depth: number = 0;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor found: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 15px;
|
||||
background: #e1f5fe;
|
||||
border: 2px solid #0288d1;
|
||||
border-radius: 4px;
|
||||
margin: 5px;
|
||||
}
|
||||
.info {
|
||||
font-family: monospace;
|
||||
color: #01579b;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div class="info">
|
||||
<strong>Nested Target Element</strong><br>
|
||||
Message: ${this.message}<br>
|
||||
Depth: ${this.depth}<br>
|
||||
Found by properties panel: ${this.found ? '✅' : '❌'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('test-nested')
|
||||
export class TestNested extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<test-nested></test-nested>
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
accessor testId: string = 'nested-test';
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #999;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.explanation {
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.structure {
|
||||
background: #f0f0f0;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div class="explanation">
|
||||
<h3>Nested Structure Test</h3>
|
||||
<p>The actual element with properties is nested deep inside multiple layers:</p>
|
||||
</div>
|
||||
|
||||
<div class="structure">
|
||||
<test-nested-wrapper>
|
||||
<div style="padding: 10px; background: #ffe;">
|
||||
<test-nested-wrapper>
|
||||
<div style="padding: 10px; background: #efe;">
|
||||
<test-nested-wrapper>
|
||||
<div style="padding: 10px; background: #eef;">
|
||||
<!-- The target element is here, 3 levels deep -->
|
||||
<test-nested-target
|
||||
.message=${'Found me at depth 3!'}
|
||||
.depth=${3}
|
||||
></test-nested-target>
|
||||
</div>
|
||||
</test-nested-wrapper>
|
||||
</div>
|
||||
</test-nested-wrapper>
|
||||
</div>
|
||||
</test-nested-wrapper>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px; font-style: italic; color: #666;">
|
||||
Properties panel should find the test-nested-target element despite the deep nesting.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
37
test/elements/test-noprops.ts
Normal file
37
test/elements/test-noprops.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('test-noprops')
|
||||
export class TestNoProps extends DeesElement {
|
||||
public static demo = () => html`<test-noprops></test-noprops>`;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 20px;
|
||||
background: #f0f0f0;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.message {
|
||||
font-family: monospace;
|
||||
color: #666;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div class="message">
|
||||
This element has no @property decorators.
|
||||
Properties panel should handle this gracefully.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
111
test/elements/test-withwrapper.ts
Normal file
111
test/elements/test-withwrapper.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
// Import from local demotools
|
||||
import '../../ts_demotools/demotools.js';
|
||||
|
||||
@customElement('test-withwrapper')
|
||||
export class TestWithWrapper extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${async (wrapper) => {
|
||||
console.log('DemoWrapper: Found wrapper element', wrapper);
|
||||
|
||||
const testElement = wrapper.querySelector('test-withwrapper');
|
||||
if (testElement) {
|
||||
console.log('DemoWrapper: Found test-withwrapper element');
|
||||
testElement.dynamicValue = 'Set by demo wrapper!';
|
||||
testElement.counter = 100;
|
||||
|
||||
// Test querySelector functionality
|
||||
const innerDiv = wrapper.querySelector('.inner-content');
|
||||
console.log('DemoWrapper: Found inner div:', innerDiv);
|
||||
|
||||
// Test querySelectorAll
|
||||
const allButtons = wrapper.querySelectorAll('button');
|
||||
console.log(`DemoWrapper: Found ${allButtons.length} buttons`);
|
||||
}
|
||||
}}>
|
||||
<test-withwrapper></test-withwrapper>
|
||||
<div style="margin-top: 10px; padding: 10px; background: #e0e0e0;">
|
||||
This div is also inside the wrapper
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
accessor dynamicValue: string = 'Initial value';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor counter: number = 0;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor isActive: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 20px;
|
||||
background: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wrapper-info {
|
||||
background: #c8e6c9;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.inner-content {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
button {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
button:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div class="wrapper-info">
|
||||
This element is wrapped with dees-demowrapper in its demo
|
||||
</div>
|
||||
|
||||
<div class="inner-content">
|
||||
<h3>Dynamic Value: ${this.dynamicValue}</h3>
|
||||
<p>Counter: ${this.counter}</p>
|
||||
<p>Active: ${this.isActive ? 'Yes' : 'No'}</p>
|
||||
|
||||
<button @click=${() => this.counter++}>Increment</button>
|
||||
<button @click=${() => this.isActive = !this.isActive}>Toggle Active</button>
|
||||
<button @click=${() => this.dynamicValue = 'Clicked!'}>Change Value</button>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
Properties panel should detect this element inside the wrapper
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
2
test/pages/index.ts
Normal file
2
test/pages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './page1.js';
|
||||
export * from './pageLongScroll.js';
|
||||
3
test/pages/page1.ts
Normal file
3
test/pages/page1.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
export const page1 = () => html` <test-demoelement></test-demoelement> `;
|
||||
138
test/pages/pageLongScroll.ts
Normal file
138
test/pages/pageLongScroll.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
export const pageLongScroll = () => html`
|
||||
<style>
|
||||
.long-scroll-container {
|
||||
padding: 40px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 60px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 15px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
color: #ccc;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
height: 300px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #888;
|
||||
font-size: 1.2em;
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="long-scroll-container">
|
||||
<h1>Long Scroll Test Page</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Section 1: Introduction</h2>
|
||||
<p>This is a long page designed to test scroll position preservation. Scroll down to see more content and then reload the page to verify that the scroll position is restored correctly.</p>
|
||||
<p>The URL should update with scroll position parameters as you scroll, and when you reload the page, it should automatically scroll to the last position.</p>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-content">
|
||||
<span>Placeholder Content Block 1</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Section 2: Testing Scroll Behavior</h2>
|
||||
<p>As you scroll through this page, the dashboard should track your scroll position and update the URL accordingly. The updates should be debounced to avoid excessive URL changes.</p>
|
||||
<p>Try scrolling quickly and slowly to see how the debouncing works. The URL should update smoothly without interfering with your scrolling experience.</p>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-content">
|
||||
<span>Placeholder Content Block 2</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Section 3: Reload Testing</h2>
|
||||
<p>Once you've scrolled to this section, try reloading the page. The page should automatically scroll back to this position after the content loads.</p>
|
||||
<p>This demonstrates that the scroll position is being preserved across page reloads using URL parameters.</p>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-content">
|
||||
<span>Placeholder Content Block 3</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Section 4: Navigation Testing</h2>
|
||||
<p>Try navigating to a different element or page in the sidebar, then use the browser's back button to return here. The scroll position should be preserved.</p>
|
||||
<p>This tests that the browser history correctly maintains scroll state for each navigation entry.</p>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-content">
|
||||
<span>Placeholder Content Block 4</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Section 5: Deep Scroll Testing</h2>
|
||||
<p>Keep scrolling! This page has plenty of content to ensure we can test scroll positions at various depths.</p>
|
||||
<p>The scroll tracking should work reliably regardless of how far down the page you scroll.</p>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-content">
|
||||
<span>Placeholder Content Block 5</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Section 6: Performance Testing</h2>
|
||||
<p>Even with continuous scroll tracking, the page should remain responsive and smooth. The debouncing mechanism ensures that URL updates don't impact scrolling performance.</p>
|
||||
<p>Try scrolling rapidly up and down to verify that the scrolling remains smooth.</p>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-content">
|
||||
<span>Placeholder Content Block 6</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Section 7: Sidebar Scroll Testing</h2>
|
||||
<p>Don't forget to test the sidebar scrolling as well! If the sidebar has enough items to scroll, its position should also be tracked and restored.</p>
|
||||
<p>Both the main content and sidebar scroll positions should be preserved independently.</p>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-content">
|
||||
<span>Placeholder Content Block 7</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Section 8: Edge Cases</h2>
|
||||
<p>This section tests edge cases like scrolling to the very bottom of the page, then reloading.</p>
|
||||
<p>The scroll restoration should handle these cases gracefully without any visual glitches or errors.</p>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-content">
|
||||
<span>Placeholder Content Block 8</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Section 9: Final Section</h2>
|
||||
<p>You've reached the end of the scroll test page! Try reloading from here to ensure that even the bottom-most scroll positions are correctly preserved.</p>
|
||||
<p>The scroll position tracking has been successfully implemented if you can reload and return to this exact position.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
22
test/test.demoresolver.node.ts
Normal file
22
test/test.demoresolver.node.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { resolveTemplateFactory } from '../ts_web/elements/wcctools.helpers.js';
|
||||
import { html } from 'lit';
|
||||
|
||||
const waitFor = (durationMs: number) => new Promise(resolve => setTimeout(resolve, durationMs));
|
||||
|
||||
tap.test('resolveTemplateFactory returns sync TemplateResult', async () => {
|
||||
const template = html`<p>sync demo</p>`;
|
||||
const resolvedTemplate = await resolveTemplateFactory(() => template);
|
||||
expect(resolvedTemplate).toEqual(template);
|
||||
});
|
||||
|
||||
tap.test('resolveTemplateFactory awaits async TemplateResult', async () => {
|
||||
const template = html`<p>async demo</p>`;
|
||||
const resolvedTemplate = await resolveTemplateFactory(async () => {
|
||||
await waitFor(5);
|
||||
return template;
|
||||
});
|
||||
expect(resolvedTemplate).toEqual(template);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,7 +3,7 @@ import { DeesElement, customElement, html, css, property, type TemplateResult }
|
||||
@customElement('dees-demowrapper')
|
||||
export class DeesDemoWrapper extends DeesElement {
|
||||
@property({ attribute: false })
|
||||
public runAfterRender: (element: HTMLElement) => void | Promise<void>;
|
||||
accessor runAfterRender: (wrapperElement: DeesDemoWrapper) => void | Promise<void>;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
@@ -25,14 +25,13 @@ export class DeesDemoWrapper extends DeesElement {
|
||||
// Wait a bit for slotted content to render
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Find the first element child (excluding text nodes)
|
||||
const slottedElements = this.children;
|
||||
if (slottedElements.length > 0 && this.runAfterRender) {
|
||||
const firstElement = slottedElements[0] as HTMLElement;
|
||||
|
||||
// Call the runAfterRender function with the element
|
||||
// Check if there are slotted elements and runAfterRender is defined
|
||||
if (this.children.length > 0 && this.runAfterRender) {
|
||||
// Call the runAfterRender function with the wrapper element itself
|
||||
// Note: querySelector/querySelectorAll will work on slotted content
|
||||
// because slotted elements remain in the light DOM as children
|
||||
try {
|
||||
await this.runAfterRender(firstElement);
|
||||
await this.runAfterRender(this);
|
||||
} catch (error) {
|
||||
console.error('Error in runAfterRender:', error);
|
||||
}
|
||||
|
||||
147
ts_demotools/readme.md
Normal file
147
ts_demotools/readme.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# @design.estate/dees-wcctools/demotools
|
||||
|
||||
🧪 **Demo Wrapper Utilities** — Enhanced testing tools for web component demos
|
||||
|
||||
## Overview
|
||||
|
||||
The demotools module provides `dees-demowrapper`, a utility component for executing post-render logic in component demos. Perfect for simulating user interactions, setting up test data, or validating component state.
|
||||
|
||||
## Installation
|
||||
|
||||
This module is included with `@design.estate/dees-wcctools`:
|
||||
|
||||
```bash
|
||||
pnpm add -D @design.estate/dees-wcctools
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Import the demotools subpath:
|
||||
|
||||
```typescript
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
```
|
||||
|
||||
## DeesDemoWrapper
|
||||
|
||||
A wrapper component that executes a callback after its slotted content renders.
|
||||
|
||||
### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `runAfterRender` | `(wrapper: DeesDemoWrapper) => void \| Promise<void>` | Callback executed after content renders |
|
||||
|
||||
### Example: Basic Usage
|
||||
|
||||
```typescript
|
||||
import { html } from 'lit';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${(wrapper) => {
|
||||
const button = wrapper.querySelector('my-button');
|
||||
console.log('Button found:', button);
|
||||
}}>
|
||||
<my-button>Click Me</my-button>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
```
|
||||
|
||||
### Example: Async Operations
|
||||
|
||||
```typescript
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${async (wrapper) => {
|
||||
const form = wrapper.querySelector('my-form');
|
||||
|
||||
// Wait for component initialization
|
||||
await form.updateComplete;
|
||||
|
||||
// Simulate user input
|
||||
form.values = { name: 'Test User', email: 'test@example.com' };
|
||||
|
||||
// Trigger validation
|
||||
await form.validate();
|
||||
|
||||
console.log('Form state:', form.isValid);
|
||||
}}>
|
||||
<my-form></my-form>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
```
|
||||
|
||||
### Example: Multiple Elements
|
||||
|
||||
```typescript
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${(wrapper) => {
|
||||
// Find all cards
|
||||
const cards = wrapper.querySelectorAll('my-card');
|
||||
console.log(`Found ${cards.length} cards`);
|
||||
|
||||
// Access by index
|
||||
Array.from(wrapper.children).forEach((child, i) => {
|
||||
console.log(`Child ${i}:`, child.tagName);
|
||||
});
|
||||
|
||||
// Add event listeners
|
||||
wrapper.querySelectorAll('button').forEach(btn => {
|
||||
btn.addEventListener('click', () => console.log('Clicked!'));
|
||||
});
|
||||
}}>
|
||||
<my-card title="Card 1"></my-card>
|
||||
<my-card title="Card 2"></my-card>
|
||||
<my-card title="Card 3"></my-card>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
```
|
||||
|
||||
### Example: Component State Manipulation
|
||||
|
||||
```typescript
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${async (wrapper) => {
|
||||
const tabs = wrapper.querySelector('my-tabs');
|
||||
|
||||
// Programmatically switch tabs
|
||||
tabs.activeTab = 'settings';
|
||||
await tabs.updateComplete;
|
||||
|
||||
// Verify content updated
|
||||
const content = tabs.shadowRoot.querySelector('.tab-content');
|
||||
console.log('Active content:', content.textContent);
|
||||
}}>
|
||||
<my-tabs>
|
||||
<div slot="home">Home Content</div>
|
||||
<div slot="settings">Settings Content</div>
|
||||
</my-tabs>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The wrapper renders its slot content immediately
|
||||
2. After a brief delay (50ms) to allow slotted content to initialize
|
||||
3. The `runAfterRender` callback is invoked with the wrapper element
|
||||
4. You have full DOM API access to query and manipulate children
|
||||
|
||||
## Key Features
|
||||
|
||||
- 📦 **Light DOM Access** — Slotted elements remain accessible via standard DOM APIs
|
||||
- ⏱️ **Async Support** — Return a Promise for async operations
|
||||
- 🎯 **Full DOM API** — Use `querySelector`, `querySelectorAll`, `children`, etc.
|
||||
- 🛡️ **Error Handling** — Errors in callbacks are caught and logged
|
||||
|
||||
## CSS Behavior
|
||||
|
||||
The wrapper uses `display: contents` so it doesn't affect layout:
|
||||
|
||||
```css
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
```
|
||||
|
||||
This means the wrapper is "invisible" in the layout — its children render as if they were direct children of the wrapper's parent.
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-wcctools',
|
||||
version: '1.0.90',
|
||||
version: '2.0.1',
|
||||
description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, queryAsync, render, domtools } from '@design.estate/dees-element';
|
||||
import { resolveTemplateFactory, getDemoAtIndex, getDemoCount, hasMultipleDemos } from './wcctools.helpers.js';
|
||||
import type { TTemplateFactory } from './wcctools.helpers.js';
|
||||
|
||||
import * as plugins from '../wcctools.plugins.js';
|
||||
|
||||
@@ -15,35 +17,45 @@ import { WccFrame } from './wcc-frame.js';
|
||||
export class WccDashboard extends DeesElement {
|
||||
|
||||
@property()
|
||||
public selectedType: TElementType;
|
||||
accessor selectedType: TElementType;
|
||||
|
||||
@property()
|
||||
public selectedItemName: string;
|
||||
accessor selectedItemName: string;
|
||||
|
||||
@property()
|
||||
public selectedItem: (() => TemplateResult) | DeesElement;
|
||||
accessor selectedItem: TTemplateFactory | DeesElement;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor selectedDemoIndex: number = 0;
|
||||
|
||||
@property()
|
||||
public selectedViewport: plugins.deesDomtools.breakpoints.TViewport = 'desktop';
|
||||
accessor selectedViewport: plugins.deesDomtools.breakpoints.TViewport = 'desktop';
|
||||
|
||||
@property()
|
||||
public selectedTheme: TTheme = 'dark';
|
||||
accessor selectedTheme: TTheme = 'dark';
|
||||
|
||||
@property()
|
||||
public pages: { [key: string]: () => TemplateResult } = {};
|
||||
accessor isFullscreen: boolean = false;
|
||||
|
||||
@property()
|
||||
public elements: { [key: string]: DeesElement } = {};
|
||||
accessor pages: Record<string, TTemplateFactory> = {};
|
||||
|
||||
@property()
|
||||
public warning: string = null;
|
||||
accessor elements: { [key: string]: DeesElement } = {};
|
||||
|
||||
@property()
|
||||
accessor warning: string = null;
|
||||
|
||||
private frameScrollY: number = 0;
|
||||
private sidebarScrollY: number = 0;
|
||||
private scrollPositionsApplied: boolean = false;
|
||||
|
||||
@queryAsync('wcc-frame')
|
||||
public wccFrame: Promise<WccFrame>;
|
||||
accessor wccFrame: Promise<WccFrame>;
|
||||
|
||||
constructor(
|
||||
elementsArg?: { [key: string]: DeesElement },
|
||||
pagesArg?: { [key: string]: () => TemplateResult }
|
||||
pagesArg?: Record<string, TTemplateFactory>
|
||||
) {
|
||||
super();
|
||||
if (elementsArg) {
|
||||
@@ -72,6 +84,7 @@ export class WccDashboard extends DeesElement {
|
||||
<wcc-sidebar
|
||||
.dashboardRef=${this}
|
||||
.selectedItem=${this.selectedItem}
|
||||
.isFullscreen=${this.isFullscreen}
|
||||
@selectedType=${(eventArg) => {
|
||||
this.selectedType = eventArg.detail;
|
||||
}}
|
||||
@@ -88,6 +101,7 @@ export class WccDashboard extends DeesElement {
|
||||
.selectedItem=${this.selectedItem}
|
||||
.selectedViewport=${this.selectedViewport}
|
||||
.selectedTheme=${this.selectedTheme}
|
||||
.isFullscreen=${this.isFullscreen}
|
||||
@selectedViewport=${(eventArg) => {
|
||||
this.selectedViewport = eventArg.detail;
|
||||
this.scheduleUpdate();
|
||||
@@ -95,8 +109,18 @@ export class WccDashboard extends DeesElement {
|
||||
@selectedTheme=${(eventArg) => {
|
||||
this.selectedTheme = eventArg.detail;
|
||||
}}
|
||||
@editorStateChanged=${async (eventArg) => {
|
||||
const frame = await this.wccFrame;
|
||||
if (frame) {
|
||||
frame.advancedEditorOpen = eventArg.detail.isOpen;
|
||||
frame.requestUpdate();
|
||||
}
|
||||
}}
|
||||
@toggleFullscreen=${() => {
|
||||
this.toggleFullscreen();
|
||||
}}
|
||||
></wcc-properties>
|
||||
<wcc-frame id="wccFrame" viewport=${this.selectedViewport}>
|
||||
<wcc-frame id="wccFrame" viewport=${this.selectedViewport} .isFullscreen=${this.isFullscreen}>
|
||||
</wcc-frame>
|
||||
`;
|
||||
}
|
||||
@@ -111,13 +135,32 @@ export class WccDashboard extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
public toggleFullscreen() {
|
||||
this.isFullscreen = !this.isFullscreen;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
this.domtools = await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
|
||||
// Add ESC key handler for fullscreen mode
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && this.isFullscreen) {
|
||||
this.isFullscreen = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up scroll listeners after DOM is ready
|
||||
setTimeout(() => {
|
||||
this.setupScrollListeners();
|
||||
}, 500);
|
||||
|
||||
// Route with demo index (new format)
|
||||
this.domtools.router.on(
|
||||
'/wcctools-route/:itemType/:itemName/:viewport/:theme',
|
||||
'/wcctools-route/:itemType/:itemName/:demoIndex/:viewport/:theme',
|
||||
async (routeInfo) => {
|
||||
this.selectedType = routeInfo.params.itemType as TElementType;
|
||||
this.selectedItemName = routeInfo.params.itemName;
|
||||
this.selectedDemoIndex = parseInt(routeInfo.params.demoIndex) || 0;
|
||||
this.selectedViewport = routeInfo.params.viewport as breakpoints.TViewport;
|
||||
this.selectedTheme = routeInfo.params.theme as TTheme;
|
||||
if (routeInfo.params.itemType === 'element') {
|
||||
@@ -125,6 +168,65 @@ export class WccDashboard extends DeesElement {
|
||||
} else if (routeInfo.params.itemType === 'page') {
|
||||
this.selectedItem = this.pages[routeInfo.params.itemName];
|
||||
}
|
||||
|
||||
// Restore scroll positions from query parameters
|
||||
if (routeInfo.queryParams) {
|
||||
const frameScrollY = routeInfo.queryParams.frameScrollY;
|
||||
const sidebarScrollY = routeInfo.queryParams.sidebarScrollY;
|
||||
|
||||
if (frameScrollY) {
|
||||
this.frameScrollY = parseInt(frameScrollY);
|
||||
}
|
||||
if (sidebarScrollY) {
|
||||
this.sidebarScrollY = parseInt(sidebarScrollY);
|
||||
}
|
||||
|
||||
// Apply scroll positions after a short delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
this.applyScrollPositions();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
const domtoolsInstance = await plugins.deesDomtools.elementBasic.setup();
|
||||
this.selectedTheme === 'bright'
|
||||
? domtoolsInstance.themeManager.goBright()
|
||||
: domtoolsInstance.themeManager.goDark();
|
||||
}
|
||||
);
|
||||
|
||||
// Legacy route without demo index (for backwards compatibility)
|
||||
this.domtools.router.on(
|
||||
'/wcctools-route/:itemType/:itemName/:viewport/:theme',
|
||||
async (routeInfo) => {
|
||||
this.selectedType = routeInfo.params.itemType as TElementType;
|
||||
this.selectedItemName = routeInfo.params.itemName;
|
||||
this.selectedDemoIndex = 0; // Default to first demo
|
||||
this.selectedViewport = routeInfo.params.viewport as breakpoints.TViewport;
|
||||
this.selectedTheme = routeInfo.params.theme as TTheme;
|
||||
if (routeInfo.params.itemType === 'element') {
|
||||
this.selectedItem = this.elements[routeInfo.params.itemName];
|
||||
} else if (routeInfo.params.itemType === 'page') {
|
||||
this.selectedItem = this.pages[routeInfo.params.itemName];
|
||||
}
|
||||
|
||||
// Restore scroll positions from query parameters
|
||||
if (routeInfo.queryParams) {
|
||||
const frameScrollY = routeInfo.queryParams.frameScrollY;
|
||||
const sidebarScrollY = routeInfo.queryParams.sidebarScrollY;
|
||||
|
||||
if (frameScrollY) {
|
||||
this.frameScrollY = parseInt(frameScrollY);
|
||||
}
|
||||
if (sidebarScrollY) {
|
||||
this.sidebarScrollY = parseInt(sidebarScrollY);
|
||||
}
|
||||
|
||||
// Apply scroll positions after a short delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
this.applyScrollPositions();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
const domtoolsInstance = await plugins.deesDomtools.elementBasic.setup();
|
||||
this.selectedTheme === 'bright'
|
||||
? domtoolsInstance.themeManager.goBright()
|
||||
@@ -136,7 +238,6 @@ export class WccDashboard extends DeesElement {
|
||||
public async updated(changedPropertiesArg: Map<string, any>) {
|
||||
this.domtools = await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
await this.domtools.router._handleRouteState();
|
||||
const storeElement = this.selectedItem;
|
||||
const wccFrame: WccFrame = this.shadowRoot.querySelector('wcc-frame');
|
||||
|
||||
if (changedPropertiesArg.has('selectedItemName')) {
|
||||
@@ -147,7 +248,9 @@ export class WccDashboard extends DeesElement {
|
||||
if (typeof this.selectedItem === 'function') {
|
||||
console.log('slotting page.');
|
||||
const viewport = await wccFrame.getViewportElement();
|
||||
render(this.selectedItem(), viewport);
|
||||
const pageFactory = this.selectedItem as TTemplateFactory;
|
||||
const pageTemplate = await resolveTemplateFactory(pageFactory);
|
||||
render(pageTemplate, viewport);
|
||||
console.log('rendered page.');
|
||||
} else {
|
||||
console.error('The selected item looks strange:');
|
||||
@@ -160,21 +263,125 @@ export class WccDashboard extends DeesElement {
|
||||
this.setWarning(`component ${anonItem.name} does not expose a demo property.`);
|
||||
return;
|
||||
}
|
||||
if (!(typeof anonItem.demo === 'function')) {
|
||||
|
||||
// Support both single demo (function) and multiple demos (array)
|
||||
const isArray = Array.isArray(anonItem.demo);
|
||||
const isFunction = typeof anonItem.demo === 'function';
|
||||
|
||||
if (!isArray && !isFunction) {
|
||||
this.setWarning(
|
||||
`component ${anonItem.name} has demo property, but it is not of type function`
|
||||
`component ${anonItem.name} has demo property, but it is not a function or array of functions`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the specific demo to render
|
||||
const demoFactory = getDemoAtIndex(anonItem.demo, this.selectedDemoIndex);
|
||||
if (!demoFactory) {
|
||||
this.setWarning(
|
||||
`component ${anonItem.name} does not have a demo at index ${this.selectedDemoIndex + 1}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setWarning(null);
|
||||
const viewport = await wccFrame.getViewportElement();
|
||||
render(anonItem.demo(), viewport);;
|
||||
const demoTemplate = await resolveTemplateFactory(demoFactory);
|
||||
render(demoTemplate, viewport);
|
||||
}
|
||||
}
|
||||
|
||||
public buildUrl() {
|
||||
this.domtools.router.pushUrl(
|
||||
`/wcctools-route/${this.selectedType}/${this.selectedItemName}/${this.selectedViewport}/${this.selectedTheme}`
|
||||
);
|
||||
const baseUrl = `/wcctools-route/${this.selectedType}/${this.selectedItemName}/${this.selectedDemoIndex}/${this.selectedViewport}/${this.selectedTheme}`;
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (this.frameScrollY > 0) {
|
||||
queryParams.set('frameScrollY', this.frameScrollY.toString());
|
||||
}
|
||||
if (this.sidebarScrollY > 0) {
|
||||
queryParams.set('sidebarScrollY', this.sidebarScrollY.toString());
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const fullUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
|
||||
this.domtools.router.pushUrl(fullUrl);
|
||||
}
|
||||
|
||||
private scrollUpdateTimeout: NodeJS.Timeout;
|
||||
private scrollListenersAttached: boolean = false;
|
||||
|
||||
public async setupScrollListeners() {
|
||||
// Prevent duplicate listeners
|
||||
if (this.scrollListenersAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wccFrame = await this.wccFrame;
|
||||
const wccSidebar = this.shadowRoot.querySelector('wcc-sidebar');
|
||||
|
||||
if (wccFrame) {
|
||||
// The frame element itself is the scrollable container
|
||||
wccFrame.addEventListener('scroll', () => {
|
||||
this.frameScrollY = wccFrame.scrollTop;
|
||||
this.debouncedScrollUpdate();
|
||||
});
|
||||
this.scrollListenersAttached = true;
|
||||
}
|
||||
|
||||
if (wccSidebar) {
|
||||
// The sidebar element itself is the scrollable container
|
||||
wccSidebar.addEventListener('scroll', () => {
|
||||
this.sidebarScrollY = wccSidebar.scrollTop;
|
||||
this.debouncedScrollUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private debouncedScrollUpdate() {
|
||||
clearTimeout(this.scrollUpdateTimeout);
|
||||
this.scrollUpdateTimeout = setTimeout(() => {
|
||||
this.updateUrlWithScrollState();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private updateUrlWithScrollState() {
|
||||
const baseUrl = `/wcctools-route/${this.selectedType}/${this.selectedItemName}/${this.selectedDemoIndex}/${this.selectedViewport}/${this.selectedTheme}`;
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (this.frameScrollY > 0) {
|
||||
queryParams.set('frameScrollY', this.frameScrollY.toString());
|
||||
}
|
||||
if (this.sidebarScrollY > 0) {
|
||||
queryParams.set('sidebarScrollY', this.sidebarScrollY.toString());
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const fullUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
|
||||
// Use replaceState to update URL without navigation
|
||||
window.history.replaceState(null, '', fullUrl);
|
||||
}
|
||||
|
||||
public async applyScrollPositions() {
|
||||
// Only apply scroll positions once to avoid interfering with user scrolling
|
||||
if (this.scrollPositionsApplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wccFrame = await this.wccFrame;
|
||||
const wccSidebar = this.shadowRoot.querySelector('wcc-sidebar');
|
||||
|
||||
if (wccFrame && this.frameScrollY > 0) {
|
||||
// The frame element itself is the scrollable container
|
||||
wccFrame.scrollTop = this.frameScrollY;
|
||||
}
|
||||
|
||||
if (wccSidebar && this.sidebarScrollY > 0) {
|
||||
// The sidebar element itself is the scrollable container
|
||||
wccSidebar.scrollTop = this.sidebarScrollY;
|
||||
}
|
||||
|
||||
this.scrollPositionsApplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ declare global {
|
||||
@customElement('wcc-frame')
|
||||
export class WccFrame extends DeesElement {
|
||||
@property()
|
||||
public viewport: string;
|
||||
accessor viewport: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor advancedEditorOpen: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor isFullscreen: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
@@ -22,7 +28,6 @@ export class WccFrame extends DeesElement {
|
||||
left: 200px;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
bottom: 100px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
@@ -41,7 +46,19 @@ export class WccFrame extends DeesElement {
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
${(() => {
|
||||
${this.isFullscreen ? `
|
||||
border: none !important;
|
||||
left: 0px !important;
|
||||
right: 0px !important;
|
||||
top: 0px !important;
|
||||
bottom: 0px !important;
|
||||
` : `
|
||||
bottom: ${this.advancedEditorOpen ? '400px' : '100px'};
|
||||
border: 10px solid #ffaeaf;
|
||||
left: 200px;
|
||||
`}
|
||||
transition: all 0.3s ease;
|
||||
${this.isFullscreen ? 'padding: 0px;' : (() => {
|
||||
switch (this.viewport) {
|
||||
case 'desktop':
|
||||
return `
|
||||
@@ -70,7 +87,7 @@ export class WccFrame extends DeesElement {
|
||||
}
|
||||
|
||||
.viewport {
|
||||
${this.viewport !== 'desktop'
|
||||
${!this.isFullscreen && this.viewport !== 'desktop'
|
||||
? html` border-right: 1px dotted #444; border-left: 1px dotted #444; `
|
||||
: html``
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
108
ts_web/elements/wcc-record-button.ts
Normal file
108
ts_web/elements/wcc-record-button.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { DeesElement, customElement, html, css, property, type TemplateResult } from '@design.estate/dees-element';
|
||||
|
||||
@customElement('wcc-record-button')
|
||||
export class WccRecordButton extends DeesElement {
|
||||
@property({ type: String })
|
||||
accessor state: 'idle' | 'recording' = 'idle';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor duration: number = 0;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: #666;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
:host(.recording) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.rec-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
:host(.recording) .rec-icon {
|
||||
animation: pulse-recording 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-recording {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.9); }
|
||||
}
|
||||
|
||||
.recording-timer {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="content">
|
||||
<div class="rec-icon"></div>
|
||||
${this.state === 'recording' ? html`
|
||||
<span class="recording-timer">${this.formatDuration(this.duration)}</span>
|
||||
` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async connectedCallback(): Promise<void> {
|
||||
await super.connectedCallback();
|
||||
this.addEventListener('click', this.handleClick);
|
||||
}
|
||||
|
||||
async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
this.removeEventListener('click', this.handleClick);
|
||||
}
|
||||
|
||||
private handleClick = (): void => {
|
||||
this.dispatchEvent(new CustomEvent('record-click', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
};
|
||||
|
||||
updated(changedProperties: Map<string, unknown>): void {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('state')) {
|
||||
if (this.state === 'recording') {
|
||||
this.classList.add('recording');
|
||||
} else {
|
||||
this.classList.remove('recording');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
978
ts_web/elements/wcc-recording-panel.ts
Normal file
978
ts_web/elements/wcc-recording-panel.ts
Normal file
@@ -0,0 +1,978 @@
|
||||
import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { RecorderService } from '../services/recorder.service.js';
|
||||
import type { WccDashboard } from './wcc-dashboard.js';
|
||||
|
||||
@customElement('wcc-recording-panel')
|
||||
export class WccRecordingPanel extends DeesElement {
|
||||
// External configuration
|
||||
@property({ attribute: false })
|
||||
accessor dashboardRef: WccDashboard;
|
||||
|
||||
// Panel state
|
||||
@state()
|
||||
accessor panelState: 'options' | 'recording' | 'preview' = 'options';
|
||||
|
||||
// Recording options
|
||||
@state()
|
||||
accessor recordingMode: 'viewport' | 'screen' = 'viewport';
|
||||
|
||||
@state()
|
||||
accessor audioEnabled: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor selectedMicrophoneId: string = '';
|
||||
|
||||
@state()
|
||||
accessor availableMicrophones: MediaDeviceInfo[] = [];
|
||||
|
||||
@state()
|
||||
accessor audioLevel: number = 0;
|
||||
|
||||
// Recording state
|
||||
@state()
|
||||
accessor recordingDuration: number = 0;
|
||||
|
||||
// Preview/trim state
|
||||
@state()
|
||||
accessor previewVideoUrl: string = '';
|
||||
|
||||
@state()
|
||||
accessor trimStart: number = 0;
|
||||
|
||||
@state()
|
||||
accessor trimEnd: number = 0;
|
||||
|
||||
@state()
|
||||
accessor videoDuration: number = 0;
|
||||
|
||||
@state()
|
||||
accessor isDraggingTrim: 'start' | 'end' | null = null;
|
||||
|
||||
@state()
|
||||
accessor isExporting: boolean = false;
|
||||
|
||||
// Service instance
|
||||
private recorderService: RecorderService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.recorderService = new RecorderService({
|
||||
onDurationUpdate: (duration) => {
|
||||
this.recordingDuration = duration;
|
||||
this.dispatchEvent(new CustomEvent('duration-update', {
|
||||
detail: { duration },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
},
|
||||
onRecordingComplete: (blob) => {
|
||||
this.handleRecordingComplete(blob);
|
||||
},
|
||||
onAudioLevelUpdate: (level) => {
|
||||
this.audioLevel = level;
|
||||
},
|
||||
onStreamEnded: () => {
|
||||
this.stopRecording();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
/* CSS Variables */
|
||||
--background: #0a0a0a;
|
||||
--foreground: #e5e5e5;
|
||||
--input: #141414;
|
||||
--primary: #3b82f6;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
}
|
||||
|
||||
/* Recording Options Panel */
|
||||
.recording-options-panel {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 116px;
|
||||
width: 360px;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.recording-options-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recording-options-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.recording-options-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.recording-options-close:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.recording-options-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.recording-option-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recording-option-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.recording-option-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recording-mode-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.recording-mode-btn {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--input);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #999;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recording-mode-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.recording-mode-btn.selected {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.audio-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.audio-toggle input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.audio-toggle label {
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.microphone-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--input);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-size: 0.75rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.microphone-select:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.microphone-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.audio-level-container {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.audio-level-label {
|
||||
font-size: 0.65rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.audio-level-bar {
|
||||
height: 8px;
|
||||
background: var(--input);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.audio-level-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #22c55e, #84cc16, #eab308);
|
||||
border-radius: 4px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.start-recording-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #dc2626;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.start-recording-btn:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.start-recording-btn .rec-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Preview Modal */
|
||||
.preview-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.preview-modal {
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.preview-modal-header {
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-modal-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.preview-modal-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.preview-modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.preview-modal-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.preview-video-container {
|
||||
background: #000;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-modal-actions {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.preview-btn.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.preview-btn.secondary:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.preview-btn.primary {
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.preview-btn.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.preview-btn.primary:disabled {
|
||||
background: #1e3a5f;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Trim Timeline Styles */
|
||||
.trim-section {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.trim-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.trim-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.trim-duration-info {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.trim-timeline {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
background: var(--input);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.75rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.trim-track {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 6px;
|
||||
background: #333;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.trim-selected {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 6px;
|
||||
background: var(--primary);
|
||||
transform: translateY(-50%);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trim-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 36px;
|
||||
background: white;
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: 4px;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: ew-resize;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.trim-handle:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.trim-handle:active {
|
||||
background: var(--primary);
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
|
||||
.trim-handle::before {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: #666;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.trim-handle:active::before {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.trim-time-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.65rem;
|
||||
color: #666;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.trim-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.trim-action-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--input);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #999;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trim-action-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.export-spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
`
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.panelState === 'options') {
|
||||
return this.renderOptionsPanel();
|
||||
} else if (this.panelState === 'preview') {
|
||||
return this.renderPreviewModal();
|
||||
}
|
||||
return html``;
|
||||
}
|
||||
|
||||
private renderOptionsPanel(): TemplateResult {
|
||||
return html`
|
||||
<div class="recording-options-panel">
|
||||
<div class="recording-options-header">
|
||||
<span class="recording-options-title">Recording Settings</span>
|
||||
<button class="recording-options-close" @click=${() => this.close()}>✕</button>
|
||||
</div>
|
||||
<div class="recording-options-content">
|
||||
<div class="recording-option-group">
|
||||
<div class="recording-option-label">Record Area</div>
|
||||
<div class="recording-mode-buttons">
|
||||
<button
|
||||
class="recording-mode-btn ${this.recordingMode === 'viewport' ? 'selected' : ''}"
|
||||
@click=${() => this.recordingMode = 'viewport'}
|
||||
>
|
||||
Viewport Only
|
||||
</button>
|
||||
<button
|
||||
class="recording-mode-btn ${this.recordingMode === 'screen' ? 'selected' : ''}"
|
||||
@click=${() => this.recordingMode = 'screen'}
|
||||
>
|
||||
Entire Screen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recording-option-group">
|
||||
<div class="recording-option-label">Audio</div>
|
||||
<div class="audio-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="audioToggle"
|
||||
?checked=${this.audioEnabled}
|
||||
@change=${(e: Event) => this.handleAudioToggle((e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<label for="audioToggle">Enable Microphone</label>
|
||||
</div>
|
||||
|
||||
${this.audioEnabled ? html`
|
||||
<select
|
||||
class="microphone-select"
|
||||
.value=${this.selectedMicrophoneId}
|
||||
@change=${(e: Event) => this.handleMicrophoneChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Select Microphone...</option>
|
||||
${this.availableMicrophones.map(mic => html`
|
||||
<option value=${mic.deviceId}>${mic.label || `Microphone ${mic.deviceId.slice(0, 8)}`}</option>
|
||||
`)}
|
||||
</select>
|
||||
|
||||
${this.selectedMicrophoneId ? html`
|
||||
<div class="audio-level-container">
|
||||
<div class="audio-level-label">Input Level</div>
|
||||
<div class="audio-level-bar">
|
||||
<div class="audio-level-fill" style="width: ${this.audioLevel}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : null}
|
||||
` : null}
|
||||
</div>
|
||||
|
||||
<button class="start-recording-btn" @click=${() => this.startRecording()}>
|
||||
<div class="rec-dot"></div>
|
||||
Start Recording
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPreviewModal(): TemplateResult {
|
||||
return html`
|
||||
<div class="preview-modal-overlay" @click=${(e: Event) => {
|
||||
if ((e.target as HTMLElement).classList.contains('preview-modal-overlay')) {
|
||||
this.discardRecording();
|
||||
}
|
||||
}}>
|
||||
<div class="preview-modal">
|
||||
<div class="preview-modal-header">
|
||||
<span class="preview-modal-title">Recording Preview</span>
|
||||
<button class="preview-modal-close" @click=${() => this.discardRecording()}>✕</button>
|
||||
</div>
|
||||
<div class="preview-modal-content">
|
||||
<div class="preview-video-container">
|
||||
<video
|
||||
class="preview-video"
|
||||
src=${this.previewVideoUrl}
|
||||
controls
|
||||
@loadedmetadata=${(e: Event) => this.handleVideoLoaded(e.target as HTMLVideoElement)}
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<!-- Trim Section -->
|
||||
<div class="trim-section">
|
||||
<div class="trim-section-header">
|
||||
<span class="trim-section-title">Trim Video</span>
|
||||
<span class="trim-duration-info">
|
||||
${this.formatDuration(Math.floor(this.trimEnd - this.trimStart))}
|
||||
${this.trimStart > 0 || this.trimEnd < this.videoDuration
|
||||
? `(trimmed from ${this.formatDuration(Math.floor(this.videoDuration))})`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="trim-timeline"
|
||||
@mousedown=${(e: MouseEvent) => this.handleTimelineClick(e)}
|
||||
@mousemove=${(e: MouseEvent) => this.handleTimelineDrag(e)}
|
||||
@mouseup=${() => this.handleTimelineDragEnd()}
|
||||
@mouseleave=${() => this.handleTimelineDragEnd()}
|
||||
>
|
||||
<div class="trim-track"></div>
|
||||
<div
|
||||
class="trim-selected"
|
||||
style="left: ${this.getHandlePositionStyle(this.trimStart)}; right: ${this.getHandlePositionFromEndStyle(this.trimEnd)};"
|
||||
></div>
|
||||
<div
|
||||
class="trim-handle start-handle"
|
||||
style="left: ${this.getHandlePositionStyle(this.trimStart)};"
|
||||
@mousedown=${(e: MouseEvent) => { e.stopPropagation(); this.isDraggingTrim = 'start'; }}
|
||||
></div>
|
||||
<div
|
||||
class="trim-handle end-handle"
|
||||
style="left: ${this.getHandlePositionStyle(this.trimEnd)};"
|
||||
@mousedown=${(e: MouseEvent) => { e.stopPropagation(); this.isDraggingTrim = 'end'; }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="trim-time-labels">
|
||||
<span>${this.formatDuration(Math.floor(this.trimStart))}</span>
|
||||
<span>${this.formatDuration(Math.floor(this.trimEnd))}</span>
|
||||
</div>
|
||||
|
||||
<div class="trim-actions">
|
||||
<button class="trim-action-btn" @click=${() => this.resetTrim()}>
|
||||
Reset Trim
|
||||
</button>
|
||||
<button class="trim-action-btn" @click=${() => this.previewTrimmedSection()}>
|
||||
Preview Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="preview-modal-actions">
|
||||
<button class="preview-btn secondary" @click=${() => this.discardRecording()}>Discard</button>
|
||||
<button
|
||||
class="preview-btn primary"
|
||||
?disabled=${this.isExporting}
|
||||
@click=${() => this.downloadRecording()}
|
||||
>
|
||||
${this.isExporting ? html`<span class="export-spinner"></span>Exporting...` : 'Download WebM'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==================== Audio Methods ====================
|
||||
|
||||
private async handleAudioToggle(enabled: boolean): Promise<void> {
|
||||
this.audioEnabled = enabled;
|
||||
if (enabled) {
|
||||
this.availableMicrophones = await this.recorderService.loadMicrophones(true);
|
||||
if (this.availableMicrophones.length > 0 && !this.selectedMicrophoneId) {
|
||||
this.selectedMicrophoneId = this.availableMicrophones[0].deviceId;
|
||||
await this.recorderService.startAudioMonitoring(this.selectedMicrophoneId);
|
||||
}
|
||||
} else {
|
||||
this.recorderService.stopAudioMonitoring();
|
||||
this.selectedMicrophoneId = '';
|
||||
this.audioLevel = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMicrophoneChange(deviceId: string): Promise<void> {
|
||||
this.selectedMicrophoneId = deviceId;
|
||||
if (deviceId) {
|
||||
await this.recorderService.startAudioMonitoring(deviceId);
|
||||
} else {
|
||||
this.recorderService.stopAudioMonitoring();
|
||||
this.audioLevel = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Recording Methods ====================
|
||||
|
||||
private async startRecording(): Promise<void> {
|
||||
try {
|
||||
let viewportElement: HTMLElement | undefined;
|
||||
if (this.recordingMode === 'viewport' && this.dashboardRef) {
|
||||
const wccFrame = await this.dashboardRef.wccFrame;
|
||||
viewportElement = await wccFrame.getViewportElement();
|
||||
}
|
||||
|
||||
await this.recorderService.startRecording({
|
||||
mode: this.recordingMode,
|
||||
audioDeviceId: this.audioEnabled ? this.selectedMicrophoneId : undefined,
|
||||
viewportElement
|
||||
});
|
||||
|
||||
this.panelState = 'recording';
|
||||
this.dispatchEvent(new CustomEvent('recording-start', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
this.panelState = 'options';
|
||||
}
|
||||
}
|
||||
|
||||
public stopRecording(): void {
|
||||
this.recorderService.stopRecording();
|
||||
}
|
||||
|
||||
private handleRecordingComplete(blob: Blob): void {
|
||||
if (this.previewVideoUrl) {
|
||||
URL.revokeObjectURL(this.previewVideoUrl);
|
||||
}
|
||||
this.previewVideoUrl = URL.createObjectURL(blob);
|
||||
this.panelState = 'preview';
|
||||
this.dispatchEvent(new CustomEvent('recording-stop', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private discardRecording(): void {
|
||||
if (this.previewVideoUrl) {
|
||||
URL.revokeObjectURL(this.previewVideoUrl);
|
||||
this.previewVideoUrl = '';
|
||||
}
|
||||
this.recorderService.reset();
|
||||
this.trimStart = 0;
|
||||
this.trimEnd = 0;
|
||||
this.videoDuration = 0;
|
||||
this.isExporting = false;
|
||||
this.recordingDuration = 0;
|
||||
this.close();
|
||||
}
|
||||
|
||||
private async downloadRecording(): Promise<void> {
|
||||
const recordedBlob = this.recorderService.recordedBlob;
|
||||
if (!recordedBlob) return;
|
||||
|
||||
this.isExporting = true;
|
||||
|
||||
try {
|
||||
let blobToDownload: Blob;
|
||||
|
||||
// Handle trimming if needed
|
||||
const needsTrim = this.trimStart > 0.1 || this.trimEnd < this.videoDuration - 0.1;
|
||||
|
||||
if (needsTrim) {
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (video) {
|
||||
blobToDownload = await this.recorderService.exportTrimmedVideo(video, this.trimStart, this.trimEnd);
|
||||
} else {
|
||||
blobToDownload = recordedBlob;
|
||||
}
|
||||
} else {
|
||||
blobToDownload = recordedBlob;
|
||||
}
|
||||
|
||||
// Trigger download
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const filename = `wcctools-recording-${timestamp}.webm`;
|
||||
|
||||
const url = URL.createObjectURL(blobToDownload);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
this.discardRecording();
|
||||
} catch (error) {
|
||||
console.error('Error exporting video:', error);
|
||||
this.isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Trim Methods ====================
|
||||
|
||||
private handleVideoLoaded(video: HTMLVideoElement): void {
|
||||
// WebM files from MediaRecorder may have Infinity/NaN duration
|
||||
// Fall back to the tracked recording duration
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : this.recordingDuration;
|
||||
this.videoDuration = duration;
|
||||
this.trimStart = 0;
|
||||
this.trimEnd = duration;
|
||||
}
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private getHandlePositionStyle(time: number): string {
|
||||
if (this.videoDuration === 0) return '12px';
|
||||
const percentage = time / this.videoDuration;
|
||||
// Formula: 12px padding + percentage of remaining width (total - 24px padding)
|
||||
// At 0%: 12px (left edge of track)
|
||||
// At 100%: calc(100% - 12px) (right edge of track)
|
||||
return `calc(12px + ${(percentage * 100).toFixed(2)}% - ${(percentage * 24).toFixed(2)}px)`;
|
||||
}
|
||||
|
||||
private getHandlePositionFromEndStyle(time: number): string {
|
||||
if (this.videoDuration === 0) return '12px';
|
||||
const percentage = time / this.videoDuration;
|
||||
const remainingPercentage = 1 - percentage;
|
||||
// For CSS 'right' property: distance from right edge
|
||||
// At trimEnd = 100%: right = 12px (at right edge of track)
|
||||
// At trimEnd = 0%: right = calc(100% - 12px) (at left edge of track)
|
||||
return `calc(12px + ${(remainingPercentage * 100).toFixed(2)}% - ${(remainingPercentage * 24).toFixed(2)}px)`;
|
||||
}
|
||||
|
||||
private handleTimelineClick(e: MouseEvent): void {
|
||||
if (this.isDraggingTrim) return;
|
||||
|
||||
const timeline = e.currentTarget as HTMLElement;
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, (x - 12) / (rect.width - 24)));
|
||||
const time = percentage * this.videoDuration;
|
||||
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (video) {
|
||||
video.currentTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
private handleTimelineDrag(e: MouseEvent): void {
|
||||
if (!this.isDraggingTrim) return;
|
||||
|
||||
const timeline = e.currentTarget as HTMLElement;
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, (x - 12) / (rect.width - 24)));
|
||||
const time = percentage * this.videoDuration;
|
||||
|
||||
const minDuration = 1;
|
||||
|
||||
if (this.isDraggingTrim === 'start') {
|
||||
this.trimStart = Math.min(time, this.trimEnd - minDuration);
|
||||
this.trimStart = Math.max(0, this.trimStart);
|
||||
} else if (this.isDraggingTrim === 'end') {
|
||||
this.trimEnd = Math.max(time, this.trimStart + minDuration);
|
||||
this.trimEnd = Math.min(this.videoDuration, this.trimEnd);
|
||||
}
|
||||
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (video) {
|
||||
video.currentTime = this.isDraggingTrim === 'start' ? this.trimStart : this.trimEnd;
|
||||
}
|
||||
}
|
||||
|
||||
private handleTimelineDragEnd(): void {
|
||||
this.isDraggingTrim = null;
|
||||
}
|
||||
|
||||
private resetTrim(): void {
|
||||
this.trimStart = 0;
|
||||
this.trimEnd = this.videoDuration;
|
||||
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (video) {
|
||||
video.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private previewTrimmedSection(): void {
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = this.trimStart;
|
||||
video.play();
|
||||
|
||||
const checkTime = () => {
|
||||
if (video.currentTime >= this.trimEnd) {
|
||||
video.pause();
|
||||
video.removeEventListener('timeupdate', checkTime);
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', checkTime);
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
private close(): void {
|
||||
this.recorderService.stopAudioMonitoring();
|
||||
this.dispatchEvent(new CustomEvent('close', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
this.recorderService.dispose();
|
||||
if (this.previewVideoUrl) {
|
||||
URL.revokeObjectURL(this.previewVideoUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,93 @@
|
||||
import * as plugins from '../wcctools.plugins.js';
|
||||
import { DeesElement, property, html, customElement, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, state } from '@design.estate/dees-element';
|
||||
import { WccDashboard } from './wcc-dashboard.js';
|
||||
import type { TTemplateFactory } from './wcctools.helpers.js';
|
||||
import { getDemoCount, hasMultipleDemos } from './wcctools.helpers.js';
|
||||
|
||||
export type TElementType = 'element' | 'page';
|
||||
|
||||
@customElement('wcc-sidebar')
|
||||
export class WccSidebar extends DeesElement {
|
||||
@property({ attribute: false })
|
||||
public selectedItem: DeesElement | (() => TemplateResult);
|
||||
accessor selectedItem: DeesElement | TTemplateFactory;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selectedType: TElementType;
|
||||
accessor selectedType: TElementType;
|
||||
|
||||
@property()
|
||||
public dashboardRef: WccDashboard;
|
||||
accessor dashboardRef: WccDashboard;
|
||||
|
||||
@property()
|
||||
accessor isFullscreen: boolean = false;
|
||||
|
||||
// Track which elements are expanded (for multi-demo elements)
|
||||
@state()
|
||||
accessor expandedElements: Set<string> = new Set();
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet" />
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
border-right: 1px solid #999;
|
||||
font-family: 'Roboto', 'Inter', sans-serif;
|
||||
font-size: 12px;
|
||||
/* CSS Variables - Always dark theme to match wcc-properties */
|
||||
--background: #0a0a0a;
|
||||
--foreground: #e5e5e5;
|
||||
--card: #0f0f0f;
|
||||
--card-foreground: #f0f0f0;
|
||||
--muted: #1a1a1a;
|
||||
--muted-foreground: #666;
|
||||
--accent: #222;
|
||||
--accent-foreground: #fff;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--input: #141414;
|
||||
--primary: #3b82f6;
|
||||
--primary-foreground: #fff;
|
||||
--ring: #3b82f6;
|
||||
--radius: 4px;
|
||||
|
||||
display: ${this.isFullscreen ? 'none' : 'block'};
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
width: 200px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
padding: 5px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.menu {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #888;
|
||||
margin: 0;
|
||||
margin-top: 0.5rem;
|
||||
background: rgba(59, 130, 246, 0.03);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px; /* Preferred icon size */
|
||||
font-size: 16px;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
@@ -49,51 +95,125 @@ export class WccSidebar extends DeesElement {
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 48;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.selectOption {
|
||||
user-select: none;
|
||||
position: relative;
|
||||
line-height: 24px;
|
||||
padding: 5px;
|
||||
transition: all 0.2s;
|
||||
margin: 0.125rem 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
transition: all 0.15s ease;
|
||||
display: grid;
|
||||
grid-template-columns: 28px auto;
|
||||
grid-template-columns: 20px 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.selectOption.folder {
|
||||
grid-template-columns: 16px 20px 1fr;
|
||||
}
|
||||
|
||||
.selectOption .expand-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.selectOption.expanded .expand-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.selectOption:hover {
|
||||
padding: 5px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.selectOption:hover .material-symbols-outlined {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.selectOption.selected {
|
||||
background: #455A64;;
|
||||
}
|
||||
|
||||
.selectOption.selected:hover {
|
||||
color: #ffffff;
|
||||
background: #455A64;
|
||||
}
|
||||
|
||||
.selectOption .material-symbols-outlined {
|
||||
color: #666;
|
||||
display: block;
|
||||
transition: all 0.2s;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.selectOption.selected .material-symbols-outlined {
|
||||
color: #000;
|
||||
opacity: 1;
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
|
||||
.selectOption.selected:hover {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.selectOption .text {
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
||||
.demo-children {
|
||||
margin-left: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-child {
|
||||
user-select: none;
|
||||
position: relative;
|
||||
margin: 0.125rem 0.5rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
transition: all 0.15s ease;
|
||||
display: grid;
|
||||
grid-template-columns: 16px 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
color: #777;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.demo-child:hover {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.demo-child.selected {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.demo-child .material-symbols-outlined {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
<div class="menu">
|
||||
<h3>Pages</h3>
|
||||
@@ -106,7 +226,7 @@ export class WccSidebar extends DeesElement {
|
||||
class="selectOption ${this.selectedItem === item ? 'selected' : null}"
|
||||
@click=${async () => {
|
||||
const domtools = await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem('page', pageName, item);
|
||||
this.selectItem('page', pageName, item, 0);
|
||||
}}
|
||||
>
|
||||
<i class="material-symbols-outlined">insert_drive_file</i>
|
||||
@@ -119,31 +239,83 @@ export class WccSidebar extends DeesElement {
|
||||
${(() => {
|
||||
const elements = Object.keys(this.dashboardRef.elements);
|
||||
return elements.map(elementName => {
|
||||
const item = this.dashboardRef.elements[elementName];
|
||||
return html`
|
||||
<div
|
||||
class="selectOption ${this.selectedItem === item ? 'selected' : null}"
|
||||
@click=${async () => {
|
||||
const domtools = await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem('element', elementName, item);
|
||||
}}
|
||||
>
|
||||
<i class="material-symbols-outlined">featured_video</i>
|
||||
<div class="text">${elementName}</div>
|
||||
</div>
|
||||
`;
|
||||
const item = this.dashboardRef.elements[elementName] as any;
|
||||
const demoCount = item.demo ? getDemoCount(item.demo) : 0;
|
||||
const isMultiDemo = item.demo && hasMultipleDemos(item.demo);
|
||||
const isExpanded = this.expandedElements.has(elementName);
|
||||
const isSelected = this.selectedItem === item;
|
||||
|
||||
if (isMultiDemo) {
|
||||
// Multi-demo element - render as expandable folder
|
||||
return html`
|
||||
<div
|
||||
class="selectOption folder ${isExpanded ? 'expanded' : ''} ${isSelected ? 'selected' : ''}"
|
||||
@click=${() => this.toggleExpanded(elementName)}
|
||||
>
|
||||
<i class="material-symbols-outlined expand-icon">chevron_right</i>
|
||||
<i class="material-symbols-outlined">folder</i>
|
||||
<div class="text">${elementName}</div>
|
||||
</div>
|
||||
${isExpanded ? html`
|
||||
<div class="demo-children">
|
||||
${Array.from({ length: demoCount }, (_, i) => {
|
||||
const demoIndex = i;
|
||||
const isThisDemoSelected = isSelected && this.dashboardRef.selectedDemoIndex === demoIndex;
|
||||
return html`
|
||||
<div
|
||||
class="demo-child ${isThisDemoSelected ? 'selected' : ''}"
|
||||
@click=${async () => {
|
||||
await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem('element', elementName, item, demoIndex);
|
||||
}}
|
||||
>
|
||||
<i class="material-symbols-outlined">play_circle</i>
|
||||
<div class="text">demo${demoIndex + 1}</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : null}
|
||||
`;
|
||||
} else {
|
||||
// Single demo element - render as normal
|
||||
return html`
|
||||
<div
|
||||
class="selectOption ${isSelected ? 'selected' : null}"
|
||||
@click=${async () => {
|
||||
await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem('element', elementName, item, 0);
|
||||
}}
|
||||
>
|
||||
<i class="material-symbols-outlined">featured_video</i>
|
||||
<div class="text">${elementName}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public selectItem(typeArg: TElementType, itemNameArg: string, itemArg: (() => TemplateResult) | DeesElement) {
|
||||
private toggleExpanded(elementName: string) {
|
||||
const newSet = new Set(this.expandedElements);
|
||||
if (newSet.has(elementName)) {
|
||||
newSet.delete(elementName);
|
||||
} else {
|
||||
newSet.add(elementName);
|
||||
}
|
||||
this.expandedElements = newSet;
|
||||
}
|
||||
|
||||
public selectItem(typeArg: TElementType, itemNameArg: string, itemArg: TTemplateFactory | DeesElement, demoIndex: number = 0) {
|
||||
console.log('selected item');
|
||||
console.log(itemNameArg);
|
||||
console.log(itemArg);
|
||||
console.log('demo index:', demoIndex);
|
||||
this.selectedItem = itemArg;
|
||||
this.selectedType = typeArg;
|
||||
this.dashboardRef.selectedDemoIndex = demoIndex;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('selectedType', {
|
||||
detail: typeArg
|
||||
@@ -159,7 +331,11 @@ export class WccSidebar extends DeesElement {
|
||||
detail: itemArg
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
this.dashboardRef.buildUrl();
|
||||
|
||||
// Force re-render to update demo child selection indicator
|
||||
// (needed when switching between demos of the same element)
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
40
ts_web/elements/wcctools.helpers.ts
Normal file
40
ts_web/elements/wcctools.helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export type TTemplateFactory = () => TemplateResult | Promise<TemplateResult>;
|
||||
|
||||
// Demo can be a single function or an array of functions
|
||||
export type TDemoDefinition = TTemplateFactory | TTemplateFactory[];
|
||||
|
||||
export const resolveTemplateFactory = async (
|
||||
factoryArg: TTemplateFactory
|
||||
): Promise<TemplateResult> => {
|
||||
return await Promise.resolve(factoryArg());
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the number of demos for an element
|
||||
*/
|
||||
export const getDemoCount = (demo: TDemoDefinition): number => {
|
||||
if (Array.isArray(demo)) {
|
||||
return demo.length;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific demo by index (0-based internally, displayed as 1-based)
|
||||
*/
|
||||
export const getDemoAtIndex = (demo: TDemoDefinition, index: number): TTemplateFactory | null => {
|
||||
if (Array.isArray(demo)) {
|
||||
return demo[index] ?? null;
|
||||
}
|
||||
// Single demo - only index 0 is valid
|
||||
return index === 0 ? demo : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an element has multiple demos
|
||||
*/
|
||||
export const hasMultipleDemos = (demo: TDemoDefinition): boolean => {
|
||||
return Array.isArray(demo) && demo.length > 1;
|
||||
};
|
||||
@@ -1,7 +1,16 @@
|
||||
import { WccDashboard } from './elements/wcc-dashboard.js';
|
||||
import { LitElement, type TemplateResult } from 'lit';
|
||||
import { LitElement } from 'lit';
|
||||
import type { TTemplateFactory } from './elements/wcctools.helpers.js';
|
||||
|
||||
const setupWccTools = (elementsArg?: { [key: string]: LitElement }, pagesArg?: { [key: string]: () => TemplateResult }) => {
|
||||
// Export recording components and service
|
||||
export { RecorderService, type IRecorderEvents, type IRecordingOptions } from './services/recorder.service.js';
|
||||
export { WccRecordButton } from './elements/wcc-record-button.js';
|
||||
export { WccRecordingPanel } from './elements/wcc-recording-panel.js';
|
||||
|
||||
const setupWccTools = (
|
||||
elementsArg?: { [key: string]: LitElement },
|
||||
pagesArg?: Record<string, TTemplateFactory>
|
||||
) => {
|
||||
let hasRun = false;
|
||||
const runWccToolsSetup = async () => {
|
||||
if (document.readyState === 'complete' && !hasRun) {
|
||||
|
||||
1
ts_web/pages/index.ts
Normal file
1
ts_web/pages/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const page1 = null;
|
||||
123
ts_web/readme.md
Normal file
123
ts_web/readme.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# @design.estate/dees-wcctools
|
||||
|
||||
🛠️ **Web Component Catalogue Tools** — The core dashboard and UI components for building interactive component catalogues
|
||||
|
||||
## Overview
|
||||
|
||||
This is the main module of `@design.estate/dees-wcctools`, providing the complete dashboard experience for developing, testing, and documenting web components.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add -D @design.estate/dees-wcctools
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { setupWccTools } from '@design.estate/dees-wcctools';
|
||||
import { MyButton } from './components/my-button.js';
|
||||
|
||||
setupWccTools({
|
||||
'my-button': MyButton,
|
||||
});
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
### Main Entry Point
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `setupWccTools` | Initialize the component catalogue dashboard |
|
||||
|
||||
### Recording Components
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `RecorderService` | Service class for screen/viewport recording |
|
||||
| `WccRecordButton` | Record button UI component |
|
||||
| `WccRecordingPanel` | Recording options and preview panel |
|
||||
| `IRecorderEvents` | TypeScript interface for recorder callbacks |
|
||||
| `IRecordingOptions` | TypeScript interface for recording options |
|
||||
|
||||
## Internal Components
|
||||
|
||||
The module includes these internal web components:
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `wcc-dashboard` | Main dashboard container with routing |
|
||||
| `wcc-sidebar` | Navigation sidebar with element/page listing |
|
||||
| `wcc-frame` | Iframe viewport with responsive sizing |
|
||||
| `wcc-properties` | Property panel with live editing |
|
||||
| `wcc-record-button` | Recording state indicator button |
|
||||
| `wcc-recording-panel` | Recording workflow UI |
|
||||
|
||||
## RecorderService API
|
||||
|
||||
For programmatic recording control:
|
||||
|
||||
```typescript
|
||||
import { RecorderService, type IRecorderEvents } from '@design.estate/dees-wcctools';
|
||||
|
||||
const events: IRecorderEvents = {
|
||||
onDurationUpdate: (duration) => console.log(`Recording: ${duration}s`),
|
||||
onRecordingComplete: (blob) => saveBlob(blob),
|
||||
onAudioLevelUpdate: (level) => updateMeter(level),
|
||||
onError: (error) => console.error(error),
|
||||
onStreamEnded: () => console.log('User stopped sharing'),
|
||||
};
|
||||
|
||||
const recorder = new RecorderService(events);
|
||||
|
||||
// Load available microphones
|
||||
const mics = await recorder.loadMicrophones(true); // true = request permission
|
||||
|
||||
// Start audio level monitoring
|
||||
await recorder.startAudioMonitoring(mics[0].deviceId);
|
||||
|
||||
// Start recording
|
||||
await recorder.startRecording({
|
||||
mode: 'viewport', // or 'screen'
|
||||
audioDeviceId: mics[0].deviceId,
|
||||
viewportElement: document.querySelector('.viewport'),
|
||||
});
|
||||
|
||||
// Stop recording
|
||||
recorder.stopRecording();
|
||||
|
||||
// Export trimmed video
|
||||
const trimmedBlob = await recorder.exportTrimmedVideo(videoElement, startTime, endTime);
|
||||
|
||||
// Cleanup
|
||||
recorder.dispose();
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ts_web/
|
||||
├── index.ts # Main exports
|
||||
├── elements/
|
||||
│ ├── wcc-dashboard.ts # Root dashboard component
|
||||
│ ├── wcc-sidebar.ts # Navigation sidebar
|
||||
│ ├── wcc-frame.ts # Responsive iframe viewport
|
||||
│ ├── wcc-properties.ts # Property editing panel
|
||||
│ ├── wcc-record-button.ts # Recording button
|
||||
│ ├── wcc-recording-panel.ts # Recording options/preview
|
||||
│ └── wcctools.helpers.ts # Shared utilities
|
||||
├── services/
|
||||
│ └── recorder.service.ts # MediaRecorder abstraction
|
||||
└── pages/
|
||||
└── index.ts # Built-in pages
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 🎨 Interactive component preview
|
||||
- 🔧 Real-time property editing with type detection
|
||||
- 🌓 Theme switching (light/dark)
|
||||
- 📱 Responsive viewport testing
|
||||
- 🎬 Screen recording with trimming
|
||||
- 🔗 URL-based deep linking
|
||||
393
ts_web/services/recorder.service.ts
Normal file
393
ts_web/services/recorder.service.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* RecorderService - Handles all MediaRecorder, audio monitoring, and video export logic
|
||||
*/
|
||||
|
||||
export interface IRecorderEvents {
|
||||
onDurationUpdate?: (duration: number) => void;
|
||||
onRecordingComplete?: (blob: Blob) => void;
|
||||
onAudioLevelUpdate?: (level: number) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onStreamEnded?: () => void;
|
||||
}
|
||||
|
||||
export interface IRecordingOptions {
|
||||
mode: 'viewport' | 'screen';
|
||||
audioDeviceId?: string;
|
||||
viewportElement?: HTMLElement;
|
||||
}
|
||||
|
||||
export class RecorderService {
|
||||
// Recording state
|
||||
private mediaRecorder: MediaRecorder | null = null;
|
||||
private recordedChunks: Blob[] = [];
|
||||
private durationInterval: number | null = null;
|
||||
private _duration: number = 0;
|
||||
private _recordedBlob: Blob | null = null;
|
||||
private _isRecording: boolean = false;
|
||||
|
||||
// Audio monitoring state
|
||||
private audioContext: AudioContext | null = null;
|
||||
private audioAnalyser: AnalyserNode | null = null;
|
||||
private audioMonitoringInterval: number | null = null;
|
||||
private monitoringStream: MediaStream | null = null;
|
||||
|
||||
// Current recording stream
|
||||
private currentStream: MediaStream | null = null;
|
||||
|
||||
// Event callbacks
|
||||
private events: IRecorderEvents = {};
|
||||
|
||||
constructor(events?: IRecorderEvents) {
|
||||
if (events) {
|
||||
this.events = events;
|
||||
}
|
||||
}
|
||||
|
||||
// Public getters
|
||||
get isRecording(): boolean {
|
||||
return this._isRecording;
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
get recordedBlob(): Blob | null {
|
||||
return this._recordedBlob;
|
||||
}
|
||||
|
||||
// Update event callbacks
|
||||
setEvents(events: IRecorderEvents): void {
|
||||
this.events = { ...this.events, ...events };
|
||||
}
|
||||
|
||||
// ==================== Microphone Management ====================
|
||||
|
||||
async loadMicrophones(requestPermission: boolean = false): Promise<MediaDeviceInfo[]> {
|
||||
try {
|
||||
if (requestPermission) {
|
||||
// Request permission by getting a temporary stream
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.filter(d => d.kind === 'audioinput');
|
||||
} catch (error) {
|
||||
console.error('Error loading microphones:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async startAudioMonitoring(deviceId: string): Promise<void> {
|
||||
this.stopAudioMonitoring();
|
||||
|
||||
if (!deviceId) return;
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { deviceId: { exact: deviceId } }
|
||||
});
|
||||
|
||||
this.monitoringStream = stream;
|
||||
this.audioContext = new AudioContext();
|
||||
const source = this.audioContext.createMediaStreamSource(stream);
|
||||
this.audioAnalyser = this.audioContext.createAnalyser();
|
||||
this.audioAnalyser.fftSize = 256;
|
||||
source.connect(this.audioAnalyser);
|
||||
|
||||
const dataArray = new Uint8Array(this.audioAnalyser.frequencyBinCount);
|
||||
|
||||
this.audioMonitoringInterval = window.setInterval(() => {
|
||||
if (this.audioAnalyser) {
|
||||
this.audioAnalyser.getByteFrequencyData(dataArray);
|
||||
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
|
||||
const level = Math.min(100, (average / 128) * 100);
|
||||
this.events.onAudioLevelUpdate?.(level);
|
||||
}
|
||||
}, 50);
|
||||
} catch (error) {
|
||||
console.error('Error starting audio monitoring:', error);
|
||||
this.events.onAudioLevelUpdate?.(0);
|
||||
}
|
||||
}
|
||||
|
||||
stopAudioMonitoring(): void {
|
||||
if (this.audioMonitoringInterval) {
|
||||
clearInterval(this.audioMonitoringInterval);
|
||||
this.audioMonitoringInterval = null;
|
||||
}
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
if (this.monitoringStream) {
|
||||
this.monitoringStream.getTracks().forEach(track => track.stop());
|
||||
this.monitoringStream = null;
|
||||
}
|
||||
this.audioAnalyser = null;
|
||||
}
|
||||
|
||||
// ==================== Recording Control ====================
|
||||
|
||||
async startRecording(options: IRecordingOptions): Promise<void> {
|
||||
try {
|
||||
// Stop audio monitoring before recording
|
||||
this.stopAudioMonitoring();
|
||||
|
||||
// Get video stream based on mode
|
||||
const displayMediaOptions: DisplayMediaStreamOptions = {
|
||||
video: {
|
||||
displaySurface: options.mode === 'viewport' ? 'browser' : 'monitor'
|
||||
} as MediaTrackConstraints,
|
||||
audio: false
|
||||
};
|
||||
|
||||
// Add preferCurrentTab hint for viewport mode
|
||||
if (options.mode === 'viewport') {
|
||||
(displayMediaOptions as any).preferCurrentTab = true;
|
||||
}
|
||||
|
||||
const videoStream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
|
||||
|
||||
// If viewport mode, try to crop to viewport element using Element Capture API
|
||||
if (options.mode === 'viewport' && options.viewportElement) {
|
||||
try {
|
||||
if ('CropTarget' in window) {
|
||||
const cropTarget = await (window as any).CropTarget.fromElement(options.viewportElement);
|
||||
const [videoTrack] = videoStream.getVideoTracks();
|
||||
await (videoTrack as any).cropTo(cropTarget);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Element Capture not supported, recording full tab:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine video with audio if enabled
|
||||
let combinedStream = videoStream;
|
||||
if (options.audioDeviceId) {
|
||||
try {
|
||||
const audioStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { deviceId: { exact: options.audioDeviceId } }
|
||||
});
|
||||
combinedStream = new MediaStream([
|
||||
...videoStream.getVideoTracks(),
|
||||
...audioStream.getAudioTracks()
|
||||
]);
|
||||
} catch (audioError) {
|
||||
console.warn('Could not add audio:', audioError);
|
||||
}
|
||||
}
|
||||
|
||||
// Store stream for cleanup
|
||||
this.currentStream = combinedStream;
|
||||
|
||||
// Create MediaRecorder
|
||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||
? 'video/webm;codecs=vp9'
|
||||
: 'video/webm';
|
||||
|
||||
this.mediaRecorder = new MediaRecorder(combinedStream, { mimeType });
|
||||
this.recordedChunks = [];
|
||||
|
||||
this.mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
this.recordedChunks.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = () => this.handleRecordingComplete();
|
||||
|
||||
// Handle stream ending (user clicks "Stop sharing")
|
||||
videoStream.getVideoTracks()[0].onended = () => {
|
||||
if (this._isRecording) {
|
||||
this.stopRecording();
|
||||
this.events.onStreamEnded?.();
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(1000); // Capture in 1-second chunks
|
||||
|
||||
// Start duration timer
|
||||
this._duration = 0;
|
||||
this.durationInterval = window.setInterval(() => {
|
||||
this._duration++;
|
||||
this.events.onDurationUpdate?.(this._duration);
|
||||
}, 1000);
|
||||
|
||||
this._isRecording = true;
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
this._isRecording = false;
|
||||
this.events.onError?.(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
stopRecording(): void {
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
|
||||
if (this.durationInterval) {
|
||||
clearInterval(this.durationInterval);
|
||||
this.durationInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRecordingComplete(): Promise<void> {
|
||||
// Create blob from recorded chunks
|
||||
const blob = new Blob(this.recordedChunks, { type: 'video/webm' });
|
||||
|
||||
this._recordedBlob = blob;
|
||||
|
||||
// Stop all tracks
|
||||
if (this.currentStream) {
|
||||
this.currentStream.getTracks().forEach(track => track.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
|
||||
this._isRecording = false;
|
||||
this.events.onRecordingComplete?.(this._recordedBlob);
|
||||
}
|
||||
|
||||
// ==================== Trim & Export ====================
|
||||
|
||||
async exportTrimmedVideo(
|
||||
videoElement: HTMLVideoElement,
|
||||
trimStart: number,
|
||||
trimEnd: number
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create a canvas for capturing frames
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoElement.videoWidth || 1280;
|
||||
canvas.height = videoElement.videoHeight || 720;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Could not get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create canvas stream for video
|
||||
const canvasStream = canvas.captureStream(30);
|
||||
|
||||
// Try to capture audio from video element
|
||||
let combinedStream: MediaStream;
|
||||
|
||||
try {
|
||||
// Create audio context to capture video's audio
|
||||
const audioCtx = new AudioContext();
|
||||
const source = audioCtx.createMediaElementSource(videoElement);
|
||||
const destination = audioCtx.createMediaStreamDestination();
|
||||
source.connect(destination);
|
||||
source.connect(audioCtx.destination); // Also play through speakers
|
||||
|
||||
// Combine video (from canvas) and audio (from video element)
|
||||
combinedStream = new MediaStream([
|
||||
...canvasStream.getVideoTracks(),
|
||||
...destination.stream.getAudioTracks()
|
||||
]);
|
||||
|
||||
// Store audioCtx for cleanup
|
||||
const cleanup = () => {
|
||||
audioCtx.close();
|
||||
};
|
||||
|
||||
this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, cleanup, resolve, reject);
|
||||
} catch (audioError) {
|
||||
console.warn('Could not capture audio, recording video only:', audioError);
|
||||
combinedStream = canvasStream;
|
||||
this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, () => {}, resolve, reject);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private recordTrimmedStream(
|
||||
video: HTMLVideoElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stream: MediaStream,
|
||||
trimStart: number,
|
||||
trimEnd: number,
|
||||
cleanup: () => void,
|
||||
resolve: (blob: Blob) => void,
|
||||
reject: (error: Error) => void
|
||||
): void {
|
||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||
? 'video/webm;codecs=vp9'
|
||||
: 'video/webm';
|
||||
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
const chunks: Blob[] = [];
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunks.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
cleanup();
|
||||
resolve(new Blob(chunks, { type: 'video/webm' }));
|
||||
};
|
||||
|
||||
recorder.onerror = (e) => {
|
||||
cleanup();
|
||||
reject(new Error('Recording error: ' + e));
|
||||
};
|
||||
|
||||
// Seek to trim start
|
||||
video.currentTime = trimStart;
|
||||
|
||||
video.onseeked = () => {
|
||||
// Start recording
|
||||
recorder.start(100);
|
||||
|
||||
// Start playing
|
||||
video.play();
|
||||
|
||||
// Draw frames to canvas
|
||||
const drawFrame = () => {
|
||||
if (video.currentTime >= trimEnd || video.paused || video.ended) {
|
||||
video.pause();
|
||||
video.onseeked = null;
|
||||
|
||||
// Give a small delay before stopping to ensure last frame is captured
|
||||
setTimeout(() => {
|
||||
if (recorder.state === 'recording') {
|
||||
recorder.stop();
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
requestAnimationFrame(drawFrame);
|
||||
};
|
||||
|
||||
drawFrame();
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Cleanup ====================
|
||||
|
||||
reset(): void {
|
||||
this._recordedBlob = null;
|
||||
this.recordedChunks = [];
|
||||
this._duration = 0;
|
||||
this._isRecording = false;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stopRecording();
|
||||
this.stopAudioMonitoring();
|
||||
this.reset();
|
||||
|
||||
if (this.currentStream) {
|
||||
this.currentStream.getTracks().forEach(track => track.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
|
||||
Reference in New Issue
Block a user