Compare commits

...

40 Commits

Author SHA1 Message Date
d48cd063c4 v2.0.1
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-11 19:26:59 +00:00
bb04895be8 fix(@git.zone/tswatch): Bump @git.zone/tswatch devDependency to ^2.3.12 2025-12-11 19:26:59 +00:00
54b34b6faa v2.0.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-11 19:02:02 +00:00
12c85fa4cb BREAKING CHANGE(recorder): Remove FFmpeg-based MP4 conversion; simplify recorder/export to WebM and improve recorder/editor robustness 2025-12-11 19:02:02 +00:00
d90df9717b update 2025-12-11 18:03:46 +00:00
d4b161437b update 2025-12-11 16:58:04 +00:00
ad033c8104 update 2025-12-11 15:49:04 +00:00
d7d6d650bc v1.3.0
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-11 12:16:48 +00:00
53c5d839ca feat(recording-panel): Add demo wrapper utilities, improve recording trim behavior, and harden property panel element detection; update documentation 2025-12-11 12:16:48 +00:00
6cbfd714eb update 2025-12-11 12:06:18 +00:00
7c8c194fd8 update 2025-12-11 11:45:02 +00:00
278000bb36 update 2025-12-11 11:23:02 +00:00
52ffe81352 update 2025-12-11 11:14:37 +00:00
91194f6388 v1.2.1
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-16 15:49:36 +00:00
904bb92057 fix(dependencies): Bump dependencies and developer tooling versions 2025-11-16 15:49:36 +00:00
4c23739d9a 1.2.0
Some checks failed
Default (tags) / security (push) Failing after 23s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-19 13:24:46 +00:00
dd048d42a8 feat(wcc-properties): Add advanced property editors, recursive element detection, demo wrapper, UI refresh and test fixtures 2025-09-19 13:24:46 +00:00
ca28dbd9db feat: add async demo support and enhance template resolution
- Introduced async demo functionality in the README, allowing for asynchronous data preparation before rendering components.
- Updated WccDashboard, WccProperties, and WccSidebar to support Promise-based template factories.
- Implemented resolveTemplateFactory to handle both synchronous and asynchronous template results.
- Added tests for resolveTemplateFactory to ensure correct behavior for both sync and async templates.
- Updated pnpm workspace configuration.
2025-09-19 13:02:16 +00:00
7148b12066 1.1.1
Some checks failed
Default (tags) / security (push) Failing after 25s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-07 08:48:35 +00:00
309d708830 add fullscreen mode 2025-07-07 08:48:09 +00:00
923bedc4fc 1.1.0
Some checks failed
Default (tags) / security (push) Failing after 25s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-27 20:52:27 +00:00
e8b771bde4 feat(wcctools): Enhance component tools with an advanced property editor, improved element detection and modernized UI styling for a more responsive dashboard experience. 2025-06-27 20:52:27 +00:00
7a248993bc update 2025-06-27 20:51:31 +00:00
03f215e0f1 update 2025-06-27 20:50:32 +00:00
216cb0288d update 2025-06-27 20:41:24 +00:00
65acda3de1 update 2025-06-27 20:40:06 +00:00
88ff74bb86 update styling 2025-06-27 20:28:47 +00:00
98a5b1b5a3 update 2025-06-27 20:26:42 +00:00
bbf738d4e2 update 2025-06-27 20:22:28 +00:00
4f8ca7061a update 2025-06-27 20:03:54 +00:00
d26d99dbff 1.0.101
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 23:11:58 +00:00
c1d8e347de fix(wcc-dashboard): Improve scroll listener management and add new test pages 2025-06-26 23:11:58 +00:00
b6c41caf44 1.0.100
Some checks failed
Default (tags) / security (push) Failing after 24s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 22:52:54 +00:00
b858b3b9e2 fix(wcc-dashboard): Prevent duplicate application of scroll positions in dashboard to avoid interfering with user scrolling 2025-06-26 22:52:54 +00:00
4ed37086ae 1.0.99
Some checks failed
Default (tags) / security (push) Failing after 20s
Default (tags) / test (push) Failing after 20s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 20:18:20 +00:00
b4c0de47b9 fix(dashboard): Fix scroll state preservation in dashboard by tracking frame and sidebar scroll positions and updating the URL accordingly. 2025-06-26 20:18:20 +00:00
e11f0df950 1.0.98
Some checks failed
Default (tags) / security (push) Failing after 40s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-16 17:57:37 +00:00
c64b106569 fix(properties-panel): improve element detection timing and value handling in properties panel 2025-06-16 17:56:14 +00:00
3d1948b93e 1.0.97
Some checks failed
Default (tags) / security (push) Failing after 41s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-16 16:03:28 +00:00
21b7158a35 fix(properties-panel): enhance element detection and error handling for nested structures 2025-06-16 15:56:12 +00:00
36 changed files with 8872 additions and 2460 deletions

1
.gitignore vendored
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

104
changelog.md Normal file
View 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.891.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.)

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-wcctools",
"version": "1.0.96",
"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": {
@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

279
readme.md
View File

@@ -1,23 +1,31 @@
# @design.estate/dees-wcctools
Web Component Development Tools - A powerful framework for building, testing, and documenting web components
🛠️ **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 with live preview
- 🔧 Real-time property editing
- 🌓 Theme switching (light/dark modes)
- 📱 Responsive viewport testing
- 🧪 Advanced demo tools for component testing
- 🚀 Zero-config setup with TypeScript and Lit support
- 🎨 **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
# Using npm
npm install @design.estate/dees-wcctools --save-dev
# Using pnpm (recommended)
pnpm add -D @design.estate/dees-wcctools
# Using npm
npm install @design.estate/dees-wcctools --save-dev
```
## Quick Start
@@ -35,10 +43,10 @@ export class MyButton extends DeesElement {
`;
@property({ type: String })
public label: string = 'Button';
accessor label: string = 'Button';
@property({ type: String })
public variant: 'primary' | 'secondary' = 'primary';
accessor variant: 'primary' | 'secondary' = 'primary';
public static styles = [
css`
@@ -85,8 +93,8 @@ import { setupWccTools } from '@design.estate/dees-wcctools';
import { html } from 'lit';
// Import your components
import './components/my-button.js';
import './components/my-card.js';
import { MyButton } from './components/my-button.js';
import { MyCard } from './components/my-card.js';
// Define elements for the catalogue
const elements = {
@@ -132,21 +140,28 @@ setupWccTools(elements, pages);
## Features
### 🎯 Live Property Editing
The properties panel automatically detects and allows editing of:
- **String** properties with text inputs
- **Number** properties with number inputs
- **Boolean** properties with checkboxes
- **Enum** properties with select dropdowns
- **Object** and **Array** properties (read-only display)
| 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)
- **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
@@ -159,7 +174,8 @@ public render() {
}
```
Or use CSS custom properties:
Or use CSS custom properties with the theme manager:
```typescript
import { cssManager } from '@design.estate/dees-element';
@@ -173,47 +189,63 @@ public static styles = [
];
```
### 🧪 Advanced Demo Tools
### 🎬 Screen Recording
The demo tools provide enhanced testing capabilities:
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 * as demoTools from '@design.estate/dees-wcctools/demotools';
import '@design.estate/dees-wcctools/demotools';
@customElement('my-component')
export class MyComponent extends DeesElement {
public static demo = () => html`
<dees-demowrapper .runAfterRender=${async (wrapper) => {
// Use querySelector to find specific elements
const myComponent = wrapper.querySelector('my-component') as MyComponent;
console.log('Component found:', myComponent);
// Access all children via wrapper.children
console.log('Total children:', wrapper.children.length);
// Use querySelectorAll for multiple elements
const allDivs = wrapper.querySelectorAll('div');
console.log('Found divs:', allDivs.length);
// Find elements using standard DOM APIs
const myComponent = wrapper.querySelector('my-component');
// Simulate user interactions
myComponent.value = 'Test value';
await myComponent.updateComplete;
// Work with all children
Array.from(wrapper.children).forEach((child, index) => {
console.log(`Child ${index}:`, child.tagName);
// Work with multiple elements
wrapper.querySelectorAll('.item').forEach((el, i) => {
console.log(`Item ${i}:`, el.textContent);
});
}}>
<my-component></my-component>
<div>Additional content</div>
<div class="item">Item 1</div>
<div class="item">Item 2</div>
</dees-demowrapper>
`;
}
```
### 🎭 Container Queries Support
### ⏳ Async Demos
Components can respond to their container size:
Return a `Promise` from `demo` for async setup. The dashboard waits for resolution:
```typescript
public static demo = async () => {
const data = await fetchSomeData();
return html`<my-component .data=${data}></my-component>`;
};
```
### 🎭 Container Queries
Components can respond to their container size using the `wccToolsViewport` container:
```typescript
public static styles = [
@@ -223,7 +255,7 @@ public static styles = [
flex-direction: row;
}
}
@container wccToolsViewport (max-width: 767px) {
:host {
flex-direction: column;
@@ -236,61 +268,94 @@ public static styles = [
## Component Guidelines
### Required for Catalogue Display
1. Components must expose a static `demo` property returning a Lit template
2. Use `@property()` decorators for properties you want to be editable
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
<best-practice-component
.complexProp=${{ key: 'value' }}
simpleAttribute="test"
></best-practice-component>
`;
// ✅ Typed properties with defaults
// ✅ Typed properties with defaults (TC39 decorators)
@property({ type: String })
public title: string = 'Default Title';
accessor title: string = 'Default Title';
// ✅ Complex property without attribute
@property({ attribute: false })
public complexProp: { key: string } = { key: 'default' };
accessor complexProp: { key: string } = { key: 'default' };
// ✅ Enum with proper typing
@property({ type: String })
public variant: 'small' | 'medium' | 'large' = 'medium';
accessor variant: 'small' | 'medium' | 'large' = 'medium';
}
```
## URL Routing
The catalogue uses URL routing for deep linking:
```
/wcctools-route/:type/:name/:viewport/:theme
Example:
Examples:
/wcctools-route/element/my-button/desktop/dark
/wcctools-route/page/home/tablet/bright
```
## Development Workflow
## API Reference
### Build and Watch
```json
{
"scripts": {
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element",
"watch": "tswatch element",
"serve": "http-server ./dist"
}
}
### `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
## Project Structure
```
my-components/
├── src/
@@ -303,90 +368,30 @@ my-components/
└── package.json
```
## Advanced Features
### Custom Property Handlers
For complex property types, implement custom logic in your demo:
```typescript
public static demo = () => html`
<dees-demowrapper .runAfterRender=${(wrapper) => {
// Use querySelector to target specific elements
const component = wrapper.querySelector('my-component');
if (component) {
component.addEventListener('property-change', (e) => {
console.log('Property changed:', e.detail);
});
}
// Or handle all elements of a type
wrapper.querySelectorAll('my-component').forEach(el => {
el.addEventListener('click', () => console.log('Clicked!'));
});
}}>
<my-component></my-component>
</dees-demowrapper>
`;
```
### Responsive Testing Helpers
```typescript
import * as domtools from '@design.estate/dees-domtools';
public static styles = [
// Media query helpers
domtools.breakpoints.cssForPhone(css`
:host { font-size: 14px; }
`),
domtools.breakpoints.cssForTablet(css`
:host { font-size: 16px; }
`),
domtools.breakpoints.cssForDesktop(css`
:host { font-size: 18px; }
`)
];
```
## API Reference
### setupWccTools(elements, pages?)
Initialize the WCC Tools dashboard.
- `elements`: Object mapping element names to element classes
- `pages`: Optional object mapping page names to template functions
### DeesDemoWrapper
Component for wrapping demos with post-render logic.
- `runAfterRender`: Function called after the wrapped elements render
- Receives the wrapper element itself, providing full DOM API access
- Use `wrapper.querySelector()` and `wrapper.querySelectorAll()` for element selection
- Access children via `wrapper.children` property
- Supports async operations
## Browser Support
- Chrome/Edge (latest)
- Firefox (latest)
- Safari (latest)
- Mobile browsers with Web Components 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.
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.

View File

@@ -61,4 +61,47 @@ The properties panel has timing issues detecting rendered elements because:
- 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
- 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

View File

@@ -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';

View 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>
`;
}
}

View File

@@ -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();

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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
View File

@@ -0,0 +1,2 @@
export * from './page1.js';
export * from './pageLongScroll.js';

3
test/pages/page1.ts Normal file
View File

@@ -0,0 +1,3 @@
import { html } from '@design.estate/dees-element';
export const page1 = () => html` <test-demoelement></test-demoelement> `;

View 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>
`;

View 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();

View File

@@ -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: (wrapperElement: DeesDemoWrapper) => void | Promise<void>;
accessor runAfterRender: (wrapperElement: DeesDemoWrapper) => void | Promise<void>;
public static styles = [
css`

147
ts_demotools/readme.md Normal file
View 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.

View File

@@ -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.'
}

View File

@@ -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;
}
}

View File

@@ -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

View 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');
}
}
}
}

View 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);
}
}
}

View File

@@ -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();
}
}

View 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;
};

View File

@@ -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
View File

@@ -0,0 +1 @@
export const page1 = null;

123
ts_web/readme.md Normal file
View 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

View 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;
}
}
}

View File

@@ -1,7 +1,5 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",