Compare commits

..

26 Commits

Author SHA1 Message Date
639431824f v3.9.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-12 23:34:04 +00:00
fec1dffd37 feat(docs): document MP4 export support and enhanced recording capabilities 2026-04-12 23:34:04 +00:00
73a975e9e9 v3.8.5
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-12 23:21:45 +00:00
d178d6cb73 fix(recording): improve recording capture quality and align preview button loading state 2026-04-12 23:21:45 +00:00
3eeb9dc46f feat(recording): add output format options and MP4 conversion support
- Introduced `outputFormat` state in `WccRecordingPanel` for selecting between MP4 and WebM formats.
- Updated `RecorderService` to handle MP4 conversion using mediabunny.
- Added type stubs for `MediaStreamAudioTrack` and `MediaStreamVideoTrack` to ensure type safety.
- Updated documentation to reflect changes in output format handling.
2026-04-12 23:17:21 +00:00
d9330a5fa1 v3.8.4
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-12 17:17:40 +00:00
443618d1ac fix(repo): no changes to commit 2026-04-12 17:17:40 +00:00
ac087b9f3f v3.8.3
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-12 17:17:04 +00:00
977d8ab5e0 fix(sidebar): include component tag names in sidebar search filtering 2026-04-12 17:17:04 +00:00
02e1f536d5 v3.8.2
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-12 10:23:02 +00:00
a7f5341baa fix(sidebar): restore search input focus after clearing the sidebar search 2026-04-12 10:23:02 +00:00
3499652622 v3.8.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-12 10:19:03 +00:00
ee8b5dc3ff fix(build): migrate smart config and update build tooling for latest tsbundle and TypeScript defaults 2026-04-12 10:19:03 +00:00
0e816379a5 v3.8.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-27 09:03:10 +00:00
aa2c065918 feat(sidebar): rename demoGroup to demoGroups, add multi-group support, search by group name, and context menu group navigation 2026-01-27 09:03:10 +00:00
a778ad6855 v3.7.1
Some checks failed
Default (tags) / security (push) Failing after 13s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 17:07:41 +00:00
24a1f064ba fix(sidebar): increase scrolled sidebar header box-shadow intensity and size to improve visual separation 2026-01-04 17:07:41 +00:00
203a53a45d v3.7.0
Some checks failed
Default (tags) / security (push) Failing after 13s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 17:02:15 +00:00
349d4ba320 feat(wcc-sidebar): add header shadow and scrolled state for sidebar menu to show elevation when content is scrolled 2026-01-04 17:02:15 +00:00
399ef3d508 v3.6.2
Some checks failed
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 16:59:18 +00:00
e0f176b221 v3.6.1
Some checks failed
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 16:51:52 +00:00
e625fe9ba6 fix(wcc-sidebar): sort sidebar items alphabetically and unify grouped and ungrouped items for consistent ordering 2026-01-04 16:51:52 +00:00
fe62278d74 v3.6.0
Some checks failed
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 16:41:24 +00:00
3ee8afcdae feat(sidebar): restructure sidebar layout, add search clear button, and improve scrolling behavior 2026-01-04 16:41:24 +00:00
ab517b6ba8 v3.5.3
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 15:59:51 +00:00
2e4cbd911c fix(deps): bump dependency versions: @design.estate/dees-domtools to ^2.3.7, @design.estate/dees-element to ^2.1.5, lit to ^3.3.2; update devDependencies @api.global/typedserver to ^8.1.0 and @git.zone/tstest to ^3.1.4 2026-01-04 15:59:51 +00:00
26 changed files with 3886 additions and 2653 deletions

View File

@@ -35,5 +35,18 @@
},
"@ship.zone/szci": {
"npmGlobalTools": []
},
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./html/index.ts",
"to": "./dist_bundle/bundle.js",
"outputMode": "bundle",
"bundler": "esbuild"
}
]
},
"@git.zone/tswatch": {
"preset": "element"
}
}

View File

@@ -1,7 +1,7 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"fileMatch": ["/.smartconfig.json"],
"schema": {
"type": "object",
"properties": {

View File

@@ -1,5 +1,112 @@
# Changelog
## 2026-04-12 - 3.9.0 - feat(docs)
document MP4 export support and enhanced recording capabilities
- Update recording documentation to cover MP4/WebM export options, 60fps capture, and conversion via RecorderService
- Add API and type references for output format selection and MP4 conversion in the TypeScript web docs
- Clarify related UI capabilities such as recording panel format selection and sidebar search support
## 2026-04-12 - 3.8.5 - fix(recording)
improve recording capture quality and align preview button loading state
- request 60fps screen capture and increase recorder bitrate to 8 Mbps for smoother, higher-quality videos
- update preview button layout to use inline flex alignment so spinner and label stay properly aligned
## 2026-04-12 - 3.8.4 - fix(repo)
no changes to commit
## 2026-04-12 - 3.8.3 - fix(sidebar)
include component tag names in sidebar search filtering
- Updates sidebar section and entry filtering to match search queries against each item's custom element tag name via the `is` field.
- Keeps existing name and demo group matching behavior while making search results easier to find by tag.
## 2026-04-12 - 3.8.2 - fix(sidebar)
restore search input focus after clearing the sidebar search
- Updates the sidebar clearSearch behavior to focus the .search-input element after resetting the query and dispatching searchChanged.
- Improves search usability by letting users continue typing immediately after clearing the current search.
## 2026-04-12 - 3.8.1 - fix(build)
migrate smart config and update build tooling for latest tsbundle and TypeScript defaults
- rename npmextra.json to .smartconfig.json and add tsbundle bundle configuration
- update build script to use the new tsbundle invocation without the removed element subcommand
- adjust tsconfig and dashboard property defaults for newer TypeScript compiler behavior
- refresh package dependencies and include the new config file in published package contents
## 2026-01-27 - 3.8.0 - feat(sidebar)
rename demoGroup to demoGroups, add multi-group support, search by group name, and context menu group navigation
- Static property demoGroup renamed to demoGroups; accepts string | string[] for multi-group membership
- Elements with multiple demoGroups appear in each group's sidebar section simultaneously and show the library_books icon instead of featured_video
- Sidebar search now matches group names in addition to element names; groups are sorted alphabetically by group name
- Context menu for elements includes a "Show in Group:" section with navigable group entries that scroll to and briefly highlight the target group; group headers also have a context menu entry to filter by that group
- Added data-group attribute on .item-group for DOM querying and visual classes for group highlight and filter match
- Updated test elements to use demoGroups and updated docs/changelog/readme.hints to document the new behavior
- Bumped several devDependencies (@api.global/typedserver, @git.zone/tsbuild, @git.zone/tsbundle, @git.zone/tstest, @git.zone/tswatch, @types/node) and adjusted npm script `watch` to use tswatch
## 2026-01-27 - 3.8.0 - feat(sidebar)
rename demoGroup to demoGroups, add multi-group support, search by group name, and context menu group navigation
- Rename static property `demoGroup` to `demoGroups` on element classes; accepts `string | string[]` for multi-group membership
- Elements with an array of `demoGroups` appear in each group's sidebar section simultaneously
- Search now matches group names in addition to element names (e.g. searching "Buttons" shows all elements in the Buttons group)
- Groups are sorted alphabetically by group name instead of by first element name
- Elements belonging to multiple groups display a `library_books` icon instead of `featured_video`
- Right-click context menu on elements with groups shows "Show in Group:" section with navigable group entries
- Clicking a group name in the context menu scrolls to and briefly highlights that group in the sidebar
- Updated test elements (test-button-primary, test-button-secondary, test-button-danger, test-input-text, test-input-checkbox) to use `demoGroups`
## 2026-01-04 - 3.7.1 - fix(sidebar)
increase scrolled sidebar header box-shadow intensity and size to improve visual separation
- Changed .sidebar-header.scrolled box-shadow from `0 4px 12px -2px rgba(0, 0, 0, 0.4)` to `0 8px 24px -2px rgba(0, 0, 0, 1)`
- File modified: ts_web/elements/wcc-sidebar.ts — stronger, larger, and fully opaque shadow for better contrast when scrolled
## 2026-01-04 - 3.7.0 - feat(wcc-sidebar)
add header shadow and scrolled state for sidebar menu to show elevation when content is scrolled
- Introduce isMenuScrolled state to track whether the menu has been scrolled
- Add handleMenuScroll handler and bind it to the menu scroll event
- Apply a 'scrolled' class to .sidebar-header to add box-shadow and border-bottom color with transitions
- Update template to conditionally add scrolled class and attach scroll listener
## 2026-01-04 - 3.6.2 - fix(wcc-sidebar)
use sidebar's internal .menu element for scroll management and expose scrollableContainer getter
- Add public scrollableContainer getter to wcc-sidebar that returns the .menu element for external scroll control
- Update wcc-dashboard to query wcc-sidebar as WccSidebar and attach scroll listeners to sidebar.scrollableContainer instead of the host element
- Restore sidebar scroll position by setting scrollTop on the scrollableContainer when applying saved positions
- TypeScript casting added to avoid nullable/implicit any issues when querying the sidebar element
## 2026-01-04 - 3.6.1 - fix(wcc-sidebar)
sort sidebar items alphabetically and unify grouped and ungrouped items for consistent ordering
- Unifies ungrouped elements and groups into a single render list using a RenderItem type with a sortKey.
- Sorts all top-level items alphabetically by element name (case-insensitive via toLowerCase) so sidebar order is deterministic.
- Groups are ordered by the first element's name; individual items inside a group preserve their original order.
- Replaces previous separate rendering paths with a single sorted render pass that returns TemplateResult array.
## 2026-01-04 - 3.6.0 - feat(sidebar)
restructure sidebar layout, add search clear button, and improve scrolling behavior
- Change sidebar root to a flex column layout and add a .sidebar-header to separate header content from the scrollable menu
- Move pinned section into the header and make .menu flex: 1 with min-height: 0 so the menu becomes the scrollable area
- Replace overflow-y on the root with overflow:hidden to avoid double scrolling and constrain scrolling to .menu
- Add a clear button for the search input (.search-clear) with positioning, hover styles, and a clearSearch() method to reset the query and emit searchChanged
- Adjust search input padding and make .search-container position: relative to correctly position the clear button
## 2026-01-04 - 3.5.3 - fix(deps)
bump dependency versions: @design.estate/dees-domtools to ^2.3.7, @design.estate/dees-element to ^2.1.5, lit to ^3.3.2; update devDependencies @api.global/typedserver to ^8.1.0 and @git.zone/tstest to ^3.1.4
- Updated runtime dependencies: @design.estate/dees-domtools ^2.3.7, @design.estate/dees-element ^2.1.5, lit ^3.3.2.
- Updated devDependencies: @api.global/typedserver ^8.1.0 (major bump), @git.zone/tstest ^3.1.4.
- @api.global/typedserver major bump affects development tooling only (devDependency), not runtime API.
- Current package version is 3.5.2; recommend a patch release to 3.5.3.
## 2026-01-04 - 3.5.2 - fix(elements)
delay hiding sidebar and properties panels during native-mode transition and use transparent rgba border for frame to avoid layout jumps

View File

@@ -1,4 +1,4 @@
Copyright (c) 2020 Lossless GmbH (hello@lossless.com)
Copyright (c) 2020 Task Venture Capital GmbH (hello@task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-wcctools",
"version": "3.5.2",
"version": "3.9.0",
"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": {
@@ -10,27 +10,28 @@
"type": "module",
"scripts": {
"test": "(npm run build)",
"build": "(tsbuild tsfolders --allowimplicitany && tsbundle element)",
"watch": "tswatch element",
"build": "(tsbuild tsfolders --allowimplicitany && tsbundle)",
"watch": "tswatch",
"buildDocs": "tsdoc"
},
"author": "Lossless GmbH",
"license": "UNLICENSED",
"dependencies": {
"@design.estate/dees-domtools": "^2.3.6",
"@design.estate/dees-element": "^2.1.3",
"@design.estate/dees-domtools": "^2.5.4",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/smartdelay": "^3.0.5",
"lit": "^3.3.1"
"lit": "^3.3.2",
"mediabunny": "^1.40.1"
},
"devDependencies": {
"@api.global/typedserver": "^7.11.1",
"@git.zone/tsbuild": "^4.0.2",
"@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.3",
"@git.zone/tswatch": "^2.3.13",
"@push.rocks/projectinfo": "^5.0.2",
"@types/node": "^25.0.3"
"@api.global/typedserver": "^8.4.6",
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
"@push.rocks/projectinfo": "^5.1.0",
"@types/node": "^25.6.0"
},
"files": [
"ts/**/*",
@@ -41,7 +42,7 @@
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
".smartconfig.json",
"readme.md"
],
"browserslist": [
@@ -59,5 +60,11 @@
"element testing",
"page development"
],
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
"pnpm": {
"overrides": {
"@types/dom-webcodecs": "./ts_web/types/dom-webcodecs-stub",
"@types/dom-mediacapture-transform": "./ts_web/types/dom-mediacapture-stub"
}
}
}

5773
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,26 @@
# Project Hints and Findings
## Mediabunny / @types/dom-webcodecs Override (2026-04-12)
The `mediabunny` package depends on `@types/dom-webcodecs` and `@types/dom-mediacapture-transform`, which conflict with TypeScript 6's built-in WebCodecs types in `lib.dom.d.ts`. We override both via `pnpm.overrides` in `package.json`, pointing them to local stubs in `ts_web/types/`:
- `dom-webcodecs-stub/` — empty, since TS6 provides these types natively
- `dom-mediacapture-stub/` — provides `MediaStreamVideoTrack` and `MediaStreamAudioTrack` interfaces (not yet in `lib.dom.d.ts`)
If mediabunny drops these `@types` dependencies in a future version, the overrides can be removed.
## TypeScript 6.0 & Build Tooling (2026-04-12)
### TypeScript 6.0 Strict Defaults
TypeScript 6.0.2 (shipped with tsbuild 4.4.0) changes `strict` to `true` by default. The project explicitly sets `"strict": false` in tsconfig.json to preserve pre-TS6 behavior. Also added `"types": ["node"]` since TS6 changed `@types/*` auto-discovery.
### Config Migration: npmextra.json → .smartconfig.json
Build tools (tsbundle 2.10.0, tswatch 3.3.2) now use `@push.rocks/smartconfig` which reads `.smartconfig.json` (with leading dot). The old `npmextra.json` was renamed.
### tsbundle Configuration
The `tsbundle element` subcommand no longer exists. Instead, bundle configuration lives in `.smartconfig.json` under `"@git.zone/tsbundle"` with a `bundles` array. Entry point is `./html/index.ts``./dist_bundle/bundle.js`.
---
## Section-based Configuration API (2025-12-27)
### Overview
@@ -62,6 +83,33 @@ Section names are URL-encoded. Legacy routes (`element`/`page` as section name)
---
## Element Demo Groups (2026-01-27)
### Overview
Elements can declare `demoGroups` (renamed from `demoGroup`) as a static property to appear grouped in the sidebar. Supports `string | string[]` — elements with an array appear in multiple groups simultaneously.
### Usage
```typescript
// Single group
public static demoGroups = 'Buttons';
// Multiple groups — element appears in both
public static demoGroups = ['Buttons', 'Form Controls'];
```
### Features
- Search matches group names (searching "Buttons" shows all elements in that group)
- Groups sorted alphabetically by group name
- Multi-group elements show `library_books` icon instead of `featured_video`
- Context menu shows "Show in Group:" with clickable group entries that scroll to and highlight the group
- `data-group` attribute on `.item-group` containers for DOM querying
### Files Changed
- `ts_web/elements/wcc-sidebar.ts` — grouping logic, search filter, sort key, icon, context menu, scrollToGroup
- `test/elements/test-button-*.ts`, `test/elements/test-input-*.ts` — renamed `demoGroup``demoGroups`
---
## UI Redesign with Shadcn-like Styles (2025-06-27)
### Changes Made

View File

@@ -10,7 +10,7 @@
- 🔧 **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
- 🎬 **Screen Recording** — Record component demos with audio, trimming, and MP4/WebM export
- 🧪 **Advanced Demo Tools** — Post-render hooks for interactive testing
- 📂 **Section-based Organization** — Group components into custom sections with filtering and sorting
- 🚀 **Zero-config Setup** — TypeScript and Lit support out of the box
@@ -235,15 +235,17 @@ public static styles = [
### 🎬 Screen Recording
Record component demos directly from the catalogue:
Record component demos directly from the catalogue with full export control:
- **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
- **Video Trimming** — Trim start/end before export with a visual timeline
- **60fps Capture** — Smooth, high-bitrate recording at up to 60 frames per second
- **MP4 Export** — Universal H.264/AAC format via [mediabunny](https://mediabunny.dev) WebCodecs conversion (plays everywhere: WhatsApp, iMessage, Slack, etc.)
- **WebM Export** — Native VP9 output for maximum quality
Click the red record button in the bottom toolbar to start.
Click the red record button in the bottom toolbar, choose your format (MP4 or WebM), and start recording.
### 🧪 Demo Tools
@@ -293,6 +295,25 @@ export class MyButton extends DeesElement {
Each demo appears as a numbered item in an expandable folder in the sidebar.
### 🗂️ Demo Groups
Organize elements into groups within a section for better discoverability:
```typescript
@customElement('my-input')
export class MyInput extends DeesElement {
// Single group
public static demoGroups = 'Form Controls';
// Or multiple groups — element appears in each
public static demoGroups = ['Form Controls', 'Inputs'];
public static demo = () => html`<my-input></my-input>`;
}
```
Groups appear as collapsible headers in the sidebar, sorted alphabetically. Searching matches group names too — searching "Form Controls" shows all elements in that group.
### ⏳ Async Demos
Return a `Promise` from `demo` for async setup:
@@ -419,7 +440,7 @@ The wrapper provides full DOM API access:
For custom recording integrations:
```typescript
import { RecorderService } from '@design.estate/dees-wcctools';
import { RecorderService, type TOutputFormat } from '@design.estate/dees-wcctools';
const recorder = new RecorderService({
onDurationUpdate: (duration) => console.log(`${duration}s`),
@@ -427,9 +448,13 @@ const recorder = new RecorderService({
onAudioLevelUpdate: (level) => console.log(`Audio: ${level}%`),
});
// Record (always captures as WebM internally)
await recorder.startRecording({ mode: 'viewport' });
// ... later
recorder.stopRecording();
// Convert to MP4 for universal playback (H.264 + AAC via WebCodecs)
const mp4Blob = await recorder.convertToMp4(recorder.recordedBlob);
```
## Project Structure
@@ -474,7 +499,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.

View File

@@ -5,7 +5,7 @@ export * from './test-withwrapper.js';
export * from './test-edgecases.js';
export * from './test-nested.js';
// Grouped elements to demo the demoGroup feature
// Grouped elements to demo the demoGroups feature
export * from './test-button-primary.js';
export * from './test-button-secondary.js';
export * from './test-button-danger.js';

View File

@@ -9,7 +9,7 @@ import {
@customElement('test-button-danger')
export class TestButtonDanger extends DeesElement {
// Same group as other buttons
public static demoGroup = 'Buttons';
public static demoGroups = 'Buttons';
public static demo = () => html`
<test-button-danger>Delete</test-button-danger>

View File

@@ -9,7 +9,7 @@ import {
@customElement('test-button-primary')
export class TestButtonPrimary extends DeesElement {
// This groups the element with other "Buttons" in the sidebar
public static demoGroup = 'Buttons';
public static demoGroups = 'Buttons';
public static demo = () => html`
<test-button-primary>Click Me</test-button-primary>

View File

@@ -9,7 +9,7 @@ import {
@customElement('test-button-secondary')
export class TestButtonSecondary extends DeesElement {
// Same group as test-button-primary - they'll appear together
public static demoGroup = 'Buttons';
public static demoGroups = 'Buttons';
public static demo = () => html`
<test-button-secondary>Secondary Action</test-button-secondary>

View File

@@ -9,7 +9,7 @@ import {
@customElement('test-input-checkbox')
export class TestInputCheckbox extends DeesElement {
// Same group as test-input-text
public static demoGroup = 'Inputs';
public static demoGroups = ['Inputs', 'A Second Group'];
public static demo = () => html`
<test-input-checkbox label="Accept terms and conditions"></test-input-checkbox>

View File

@@ -9,7 +9,7 @@ import {
@customElement('test-input-text')
export class TestInputText extends DeesElement {
// Different group - "Inputs"
public static demoGroup = 'Inputs';
public static demoGroups = 'Inputs';
public static demo = () => html`
<test-input-text placeholder="Enter text..."></test-input-text>

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-wcctools',
version: '3.5.2',
version: '3.9.0',
description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.'
}

View File

@@ -12,6 +12,7 @@ import './wcc-properties.js';
import { type TTheme } from './wcc-properties.js';
import { breakpoints } from '@design.estate/dees-domtools';
import { WccFrame } from './wcc-frame.js';
import { WccSidebar } from './wcc-sidebar.js';
/**
* Get filtered and sorted items from a section
@@ -21,7 +22,8 @@ export const getSectionItems = (section: IWccSection): Array<[string, any]> => {
// Apply filter if provided
if (section.filter) {
entries = entries.filter(([name, item]) => section.filter(name, item));
const filterFn = section.filter;
entries = entries.filter(([name, item]) => filterFn(name, item));
}
// Apply sort if provided
@@ -42,13 +44,13 @@ export class WccDashboard extends DeesElement {
accessor selectedSection: IWccSection | null = null;
@property()
accessor selectedType: TElementType;
accessor selectedType: TElementType = 'element';
@property()
accessor selectedItemName: string;
accessor selectedItemName: string = '';
@property()
accessor selectedItem: TTemplateFactory | DeesElement;
accessor selectedItem: TTemplateFactory | DeesElement | null = null;
@property({ type: Number })
accessor selectedDemoIndex: number = 0;
@@ -76,7 +78,7 @@ export class WccDashboard extends DeesElement {
}
@property()
accessor warning: string = null;
accessor warning: string | null = null;
private frameScrollY: number = 0;
private sidebarScrollY: number = 0;
@@ -491,10 +493,10 @@ export class WccDashboard extends DeesElement {
if (this.scrollListenersAttached) {
return;
}
const wccFrame = await this.wccFrame;
const wccSidebar = this.shadowRoot.querySelector('wcc-sidebar');
const wccSidebar = this.shadowRoot.querySelector('wcc-sidebar') as WccSidebar | null;
if (wccFrame) {
// The frame element itself is the scrollable container
wccFrame.addEventListener('scroll', () => {
@@ -505,11 +507,14 @@ export class WccDashboard extends DeesElement {
}
if (wccSidebar) {
// The sidebar element itself is the scrollable container
wccSidebar.addEventListener('scroll', () => {
this.sidebarScrollY = wccSidebar.scrollTop;
this.debouncedScrollUpdate();
});
// Use the sidebar's scrollable container (.menu element)
const scrollContainer = wccSidebar.scrollableContainer;
if (scrollContainer) {
scrollContainer.addEventListener('scroll', () => {
this.sidebarScrollY = scrollContainer.scrollTop;
this.debouncedScrollUpdate();
});
}
}
}
@@ -555,18 +560,21 @@ export class WccDashboard extends DeesElement {
if (this.scrollPositionsApplied) {
return;
}
const wccFrame = await this.wccFrame;
const wccSidebar = this.shadowRoot.querySelector('wcc-sidebar');
const wccSidebar = this.shadowRoot.querySelector('wcc-sidebar') as WccSidebar | null;
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;
// Use the sidebar's scrollable container (.menu element)
const scrollContainer = wccSidebar.scrollableContainer;
if (scrollContainer) {
scrollContainer.scrollTop = this.sidebarScrollY;
}
}
this.scrollPositionsApplied = true;

View File

@@ -1,5 +1,5 @@
import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element';
import { RecorderService } from '../services/recorder.service.js';
import { RecorderService, type TOutputFormat } from '../services/recorder.service.js';
import type { WccDashboard } from './wcc-dashboard.js';
@customElement('wcc-recording-panel')
@@ -16,6 +16,9 @@ export class WccRecordingPanel extends DeesElement {
@state()
accessor recordingMode: 'viewport' | 'screen' = 'viewport';
@state()
accessor outputFormat: TOutputFormat = 'mp4';
@state()
accessor audioEnabled: boolean = false;
@@ -380,6 +383,9 @@ export class WccRecordingPanel extends DeesElement {
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.preview-btn.secondary {
@@ -546,7 +552,7 @@ export class WccRecordingPanel extends DeesElement {
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
flex-shrink: 0;
}
@keyframes spin {
@@ -591,6 +597,24 @@ export class WccRecordingPanel extends DeesElement {
</div>
</div>
<div class="recording-option-group">
<div class="recording-option-label">Format</div>
<div class="recording-mode-buttons">
<button
class="recording-mode-btn ${this.outputFormat === 'mp4' ? 'selected' : ''}"
@click=${() => this.outputFormat = 'mp4'}
>
MP4 (H.264)
</button>
<button
class="recording-mode-btn ${this.outputFormat === 'webm' ? 'selected' : ''}"
@click=${() => this.outputFormat = 'webm'}
>
WebM (VP9)
</button>
</div>
</div>
<div class="recording-option-group">
<div class="recording-option-label">Audio</div>
<div class="audio-toggle">
@@ -716,7 +740,9 @@ export class WccRecordingPanel extends DeesElement {
?disabled=${this.isExporting}
@click=${() => this.downloadRecording()}
>
${this.isExporting ? html`<span class="export-spinner"></span>Exporting...` : 'Download WebM'}
${this.isExporting
? html`<span class="export-spinner"></span>${this.outputFormat === 'mp4' ? 'Converting to MP4...' : 'Exporting...'}`
: `Download ${this.outputFormat === 'mp4' ? 'MP4' : 'WebM'}`}
</button>
</div>
</div>
@@ -764,7 +790,7 @@ export class WccRecordingPanel extends DeesElement {
await this.recorderService.startRecording({
mode: this.recordingMode,
audioDeviceId: this.audioEnabled ? this.selectedMicrophoneId : undefined,
viewportElement
viewportElement,
});
this.panelState = 'recording';
@@ -817,7 +843,7 @@ export class WccRecordingPanel extends DeesElement {
try {
let blobToDownload: Blob;
// Handle trimming if needed
// Handle trimming if needed — always produces WebM
const needsTrim = this.trimStart > 0.1 || this.trimEnd < this.videoDuration - 0.1;
if (needsTrim) {
@@ -831,9 +857,15 @@ export class WccRecordingPanel extends DeesElement {
blobToDownload = recordedBlob;
}
// Convert WebM → MP4 if MP4 format selected
if (this.outputFormat === 'mp4') {
blobToDownload = await this.recorderService.convertToMp4(blobToDownload);
}
// Trigger download
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `wcctools-recording-${timestamp}.webm`;
const ext = this.outputFormat === 'mp4' ? 'mp4' : 'webm';
const filename = `wcctools-recording-${timestamp}.${ext}`;
const url = URL.createObjectURL(blobToDownload);
const a = document.createElement('a');

View File

@@ -4,7 +4,7 @@ import { WccDashboard, getSectionItems } from './wcc-dashboard.js';
import type { TTemplateFactory } from './wcctools.helpers.js';
import { getDemoCount, hasMultipleDemos } from './wcctools.helpers.js';
import type { IWccSection, TElementType } from '../wcctools.interfaces.js';
import { WccContextmenu } from './wcc-contextmenu.js';
import { WccContextmenu, type IContextMenuItem } from './wcc-contextmenu.js';
@customElement('wcc-sidebar')
export class WccSidebar extends DeesElement {
@@ -48,8 +48,19 @@ export class WccSidebar extends DeesElement {
@state()
accessor isHidden: boolean = false;
// Track if menu is scrolled for header shadow
@state()
accessor isMenuScrolled: boolean = false;
private sectionsInitialized = false;
/**
* Returns the scrollable container element (.menu) for external scroll management
*/
public get scrollableContainer(): HTMLElement | null {
return this.shadowRoot?.querySelector('.menu') as HTMLElement | null;
}
public render(): TemplateResult {
return html`
<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" />
@@ -71,7 +82,8 @@ export class WccSidebar extends DeesElement {
--ring: #3b82f6;
--radius: 4px;
display: ${this.isHidden ? 'none' : 'block'};
display: ${this.isHidden ? 'none' : 'flex'};
flex-direction: column;
border-right: 1px solid rgba(255, 255, 255, 0.08);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
font-size: 14px;
@@ -81,13 +93,29 @@ export class WccSidebar extends DeesElement {
width: ${this.sidebarWidth}px;
top: 0px;
bottom: 0px;
overflow-y: auto;
overflow-x: hidden;
overflow: hidden;
background: var(--background);
color: var(--foreground);
}
.sidebar-header {
flex-shrink: 0;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
border-bottom: 1px solid transparent;
position: relative;
z-index: 1;
}
.sidebar-header.scrolled {
box-shadow: 0 8px 24px -2px rgba(0, 0, 0, 1);
border-bottom-color: var(--border);
}
.menu {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0.5rem 0;
}
@@ -281,6 +309,7 @@ export class WccSidebar extends DeesElement {
.search-container {
padding: 0.5rem;
border-bottom: 1px solid var(--border);
position: relative;
}
.search-input {
@@ -289,7 +318,7 @@ export class WccSidebar extends DeesElement {
background: var(--input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
padding: 0.5rem 1.75rem 0.5rem 0.75rem;
color: var(--foreground);
font-size: 0.75rem;
font-family: inherit;
@@ -305,6 +334,33 @@ export class WccSidebar extends DeesElement {
color: var(--muted-foreground);
}
.search-clear {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--muted-foreground);
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
transition: color 0.15s ease, background 0.15s ease;
}
.search-clear:hover {
color: var(--foreground);
background: rgba(255, 255, 255, 0.1);
}
.search-clear .material-symbols-outlined {
font-size: 14px;
opacity: 1;
}
.highlight {
background: rgba(59, 130, 246, 0.3);
border-radius: 2px;
@@ -366,6 +422,7 @@ export class WccSidebar extends DeesElement {
color: #555;
padding: 0.125rem 0.625rem 0.25rem;
display: block;
cursor: context-menu;
}
.item-group .selectOption {
@@ -373,6 +430,15 @@ export class WccSidebar extends DeesElement {
margin-right: 0.25rem;
}
.item-group.group-highlight {
background: rgba(59, 130, 246, 0.15);
transition: background 0.3s ease;
}
.item-group.group-filter-match {
border-color: rgba(245, 158, 11, 0.5);
}
/* Resize handle */
.resize-handle {
position: absolute;
@@ -394,17 +460,24 @@ export class WccSidebar extends DeesElement {
background: var(--primary);
}
</style>
<div class="search-container">
<input
type="text"
class="search-input"
placeholder="Search..."
.value=${this.searchQuery}
@input=${this.handleSearchInput}
/>
</div>
<div class="menu">
<div class="sidebar-header ${this.isMenuScrolled ? 'scrolled' : ''}">
<div class="search-container">
<input
type="text"
class="search-input"
placeholder="Search..."
.value=${this.searchQuery}
@input=${this.handleSearchInput}
/>
${this.searchQuery ? html`
<button class="search-clear" @click=${this.clearSearch}>
<i class="material-symbols-outlined">close</i>
</button>
` : null}
</div>
${this.renderPinnedSection()}
</div>
<div class="menu" @scroll=${this.handleMenuScroll}>
${this.renderSections()}
</div>
<div
@@ -454,12 +527,49 @@ export class WccSidebar extends DeesElement {
private showContextMenu(e: MouseEvent, sectionName: string, itemName: string) {
const isPinned = this.isPinned(sectionName, itemName);
WccContextmenu.show(e, [
const section = this.dashboardRef?.sections?.find(s => s.name === sectionName);
const sectionEntries = section ? getSectionItems(section) : [];
const foundEntry = sectionEntries.find(([name]) => name === itemName);
const item = foundEntry?.[1];
const groups = item ? this.getElementGroups(item) : [];
const menuItems: IContextMenuItem[] = [
{
name: isPinned ? 'Unpin' : 'Pin',
iconName: isPinned ? 'push_pin' : 'push_pin',
action: () => this.togglePin(sectionName, itemName),
},
];
if (groups.length > 0) {
menuItems.push({
name: 'Show in Group:',
iconName: 'folder',
action: () => {},
disabled: true,
});
for (const groupName of groups) {
menuItems.push({
name: groupName,
iconName: 'label',
action: () => this.scrollToGroup(sectionName, groupName),
});
}
}
WccContextmenu.show(e, menuItems);
}
private showGroupContextMenu(e: MouseEvent, groupName: string) {
WccContextmenu.show(e, [
{
name: `Show "${groupName}"`,
iconName: 'filter_alt',
action: () => {
this.searchQuery = groupName;
this.dispatchEvent(new CustomEvent('searchChanged', { detail: this.searchQuery }));
},
},
]);
}
@@ -506,7 +616,9 @@ export class WccSidebar extends DeesElement {
${pinnedEntries.map(({ sectionName, itemName, item, section }) => {
const isSelected = this.selectedItem === item;
const type = section.type === 'elements' ? 'element' : 'page';
const icon = section.type === 'elements' ? 'featured_video' : 'insert_drive_file';
const icon = section.type === 'elements'
? (this.getElementGroups(item).length > 1 ? 'library_books' : 'featured_video')
: 'insert_drive_file';
return html`
<div
@@ -540,7 +652,15 @@ export class WccSidebar extends DeesElement {
return this.dashboardRef.sections.map((section) => {
// Check if section has any matching items
const entries = getSectionItems(section);
const filteredEntries = entries.filter(([name]) => this.matchesSearch(name));
const filteredEntries = entries.filter(([name, item]) => {
if (this.matchesSearch(name)) return true;
const tagName = (item as any).is;
if (tagName && this.matchesSearch(tagName)) return true;
const rawGroups = (item as any).demoGroups;
if (!rawGroups) return false;
const groups: string[] = Array.isArray(rawGroups) ? rawGroups : [rawGroups];
return groups.some(g => this.matchesSearch(g));
});
// Hide section if no items match the search
if (filteredEntries.length === 0 && this.searchQuery) {
@@ -572,7 +692,15 @@ export class WccSidebar extends DeesElement {
private renderSectionItems(section: IWccSection) {
const entries = getSectionItems(section);
// Filter entries by search query
const filteredEntries = entries.filter(([name]) => this.matchesSearch(name));
const filteredEntries = entries.filter(([name, item]) => {
if (this.matchesSearch(name)) return true;
const tagName = (item as any).is;
if (tagName && this.matchesSearch(tagName)) return true;
const rawGroups = (item as any).demoGroups;
if (!rawGroups) return false;
const groups: string[] = Array.isArray(rawGroups) ? rawGroups : [rawGroups];
return groups.some(g => this.matchesSearch(g));
});
if (section.type === 'pages') {
return filteredEntries.map(([pageName, item]) => {
@@ -592,39 +720,62 @@ export class WccSidebar extends DeesElement {
`;
});
} else {
// type === 'elements' - group by demoGroup
// type === 'elements' - group by demoGroups (supports string | string[])
const groupedItems = new Map<string | null, Array<[string, any]>>();
for (const entry of filteredEntries) {
const [, item] = entry;
const group = (item as any).demoGroup || null;
if (!groupedItems.has(group)) {
groupedItems.set(group, []);
const rawGroups = (item as any).demoGroups;
const groups: Array<string | null> = rawGroups
? (Array.isArray(rawGroups) ? rawGroups : [rawGroups])
: [null];
for (const group of groups) {
if (!groupedItems.has(group)) {
groupedItems.set(group, []);
}
groupedItems.get(group)!.push(entry);
}
groupedItems.get(group)!.push(entry);
}
const result: TemplateResult[] = [];
// Build a unified list of render items (ungrouped elements and groups)
// Each item has a sortKey (element name or first element name of group)
type RenderItem =
| { type: 'element'; entry: [string, any]; sortKey: string }
| { type: 'group'; groupName: string; items: Array<[string, any]>; sortKey: string };
// Render ungrouped items first
const renderItems: RenderItem[] = [];
// Add ungrouped items
const ungrouped = groupedItems.get(null) || [];
for (const entry of ungrouped) {
result.push(this.renderElementItem(entry, section));
renderItems.push({ type: 'element', entry, sortKey: entry[0].toLowerCase() });
}
// Render grouped items
// Add groups (sorted by group name)
for (const [groupName, items] of groupedItems) {
if (groupName === null) continue;
result.push(html`
<div class="item-group">
<span class="item-group-legend">${groupName}</span>
${items.map((entry) => this.renderElementItem(entry, section))}
</div>
`);
renderItems.push({ type: 'group', groupName, items, sortKey: groupName.toLowerCase() });
}
return result;
// Sort all items alphabetically by sortKey
renderItems.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
// Render in sorted order
return renderItems.map((item) => {
if (item.type === 'element') {
return this.renderElementItem(item.entry, section);
} else {
return html`
<div class="item-group ${this.isGroupFilterMatch(item.groupName) ? 'group-filter-match' : ''}" data-group="${item.groupName}">
<span
class="item-group-legend"
@contextmenu=${(e: MouseEvent) => this.showGroupContextMenu(e, item.groupName)}
>${item.groupName}</span>
${item.items.map((entry) => this.renderElementItem(entry, section))}
</div>
`;
}
});
}
}
@@ -674,6 +825,7 @@ export class WccSidebar extends DeesElement {
`;
} else {
// Single demo element
const icon = this.getElementGroups(item).length > 1 ? 'library_books' : 'featured_video';
return html`
<div
class="selectOption ${isSelected ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
@@ -683,7 +835,7 @@ export class WccSidebar extends DeesElement {
}}
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, elementName)}
>
<i class="material-symbols-outlined">featured_video</i>
<i class="material-symbols-outlined">${icon}</i>
<div class="text">${this.highlightMatch(elementName)}</div>
</div>
`;
@@ -716,11 +868,54 @@ export class WccSidebar extends DeesElement {
this.dispatchEvent(new CustomEvent('searchChanged', { detail: this.searchQuery }));
}
private clearSearch() {
this.searchQuery = '';
this.dispatchEvent(new CustomEvent('searchChanged', { detail: this.searchQuery }));
const input = this.shadowRoot.querySelector('.search-input') as HTMLInputElement;
if (input) input.focus();
}
private handleMenuScroll(e: Event) {
const target = e.target as HTMLElement;
this.isMenuScrolled = target.scrollTop > 0;
}
private matchesSearch(name: string): boolean {
if (!this.searchQuery) return true;
return name.toLowerCase().includes(this.searchQuery.toLowerCase());
}
private isGroupFilterMatch(groupName: string): boolean {
return !!this.searchQuery && groupName.toLowerCase() === this.searchQuery.toLowerCase();
}
private getElementGroups(item: any): string[] {
const raw = item?.demoGroups;
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
private scrollToGroup(sectionName: string, groupName: string) {
// Ensure the section is not collapsed
this.collapsedSections.delete(sectionName);
// Clear any active search so all groups are visible
this.searchQuery = '';
this.requestUpdate();
// After render, scroll to the group element
this.updateComplete.then(() => {
const groupEl = this.shadowRoot?.querySelector(
`.item-group[data-group="${groupName}"]`
);
if (groupEl) {
groupEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Brief highlight flash
groupEl.classList.add('group-highlight');
setTimeout(() => groupEl.classList.remove('group-highlight'), 1500);
}
});
}
private highlightMatch(text: string): TemplateResult {
if (!this.searchQuery) return html`${text}`;
const lowerText = text.toLowerCase();

View File

@@ -4,7 +4,7 @@ import type { TTemplateFactory } from './elements/wcctools.helpers.js';
import type { IWccConfig, IWccSection } from './wcctools.interfaces.js';
// Export recording components and service
export { RecorderService, type IRecorderEvents, type IRecordingOptions } from './services/recorder.service.js';
export { RecorderService, type IRecorderEvents, type IRecordingOptions, type TOutputFormat } from './services/recorder.service.js';
export { WccRecordButton } from './elements/wcc-record-button.js';
export { WccRecordingPanel } from './elements/wcc-recording-panel.js';

View File

@@ -71,11 +71,12 @@ setupWccTools({
| Export | Description |
|--------|-------------|
| `RecorderService` | Service class for screen/viewport recording |
| `RecorderService` | Service class for screen/viewport recording and MP4 conversion |
| `WccRecordButton` | Record button UI component |
| `WccRecordingPanel` | Recording options and preview panel |
| `IRecorderEvents` | TypeScript interface for recorder callbacks |
| `IRecordingOptions` | TypeScript interface for recording options |
| `TOutputFormat` | Type for output format selection (`'webm' \| 'mp4'`) |
## Section Configuration
@@ -96,15 +97,15 @@ The module includes these internal web components:
| Component | Description |
|-----------|-------------|
| `wcc-dashboard` | Main dashboard container with routing |
| `wcc-sidebar` | Navigation sidebar with collapsible sections |
| `wcc-sidebar` | Navigation sidebar with collapsible sections and search |
| `wcc-frame` | Responsive viewport with size controls |
| `wcc-properties` | Property panel with live editing |
| `wcc-record-button` | Recording state indicator button |
| `wcc-recording-panel` | Recording workflow UI |
| `wcc-recording-panel` | Recording workflow UI with format selection |
## RecorderService API
For programmatic recording control:
For programmatic recording and MP4 conversion:
```typescript
import { RecorderService, type IRecorderEvents } from '@design.estate/dees-wcctools';
@@ -125,7 +126,7 @@ const mics = await recorder.loadMicrophones(true);
// Start audio level monitoring
await recorder.startAudioMonitoring(mics[0].deviceId);
// Start recording
// Start recording (always captures as WebM internally at up to 60fps)
await recorder.startRecording({
mode: 'viewport',
audioDeviceId: mics[0].deviceId,
@@ -135,7 +136,10 @@ await recorder.startRecording({
// Stop recording
recorder.stopRecording();
// Export trimmed video
// Convert to MP4 for universal playback (H.264 + AAC via WebCodecs)
const mp4Blob = await recorder.convertToMp4(recorder.recordedBlob);
// Or export trimmed video
const trimmedBlob = await recorder.exportTrimmedVideo(videoElement, startTime, endTime);
// Cleanup
@@ -148,6 +152,9 @@ recorder.dispose();
ts_web/
├── index.ts # Main exports
├── wcctools.interfaces.ts # Type definitions
├── types/
│ ├── dom-webcodecs-stub/ # TS6 compatibility shim
│ └── dom-mediacapture-stub/ # MediaCapture Transform types
├── elements/
│ ├── wcc-dashboard.ts # Root dashboard component
│ ├── wcc-sidebar.ts # Navigation sidebar
@@ -157,7 +164,7 @@ ts_web/
│ ├── wcc-recording-panel.ts # Recording options/preview
│ └── wcctools.helpers.ts # Shared utilities
├── services/
│ └── recorder.service.ts # MediaRecorder abstraction
│ └── recorder.service.ts # MediaRecorder + mediabunny MP4 conversion
└── pages/
└── index.ts # Built-in pages
```
@@ -165,9 +172,9 @@ ts_web/
## Features
- 🎨 Interactive component preview
- 📂 Section-based sidebar with filtering & sorting
- 📂 Section-based sidebar with filtering, sorting & search (by name, tag, or group)
- 🔧 Real-time property editing with type detection
- 🌓 Theme switching (light/dark)
- 📱 Responsive viewport testing
- 🎬 Screen recording with trimming
- 🎬 Screen recording with MP4/WebM export, trimming, and audio
- 🔗 URL-based deep linking

View File

@@ -1,7 +1,11 @@
/**
* RecorderService - Handles all MediaRecorder, audio monitoring, and video export logic
* RecorderService - Handles all MediaRecorder, audio monitoring, and video export logic.
* Recording always uses MediaRecorder → WebM (the reliable browser path).
* MP4 output is produced by converting WebM → MP4 via mediabunny at export time.
*/
export type TOutputFormat = 'webm' | 'mp4';
export interface IRecorderEvents {
onDurationUpdate?: (duration: number) => void;
onRecordingComplete?: (blob: Blob) => void;
@@ -14,6 +18,7 @@ export interface IRecordingOptions {
mode: 'viewport' | 'screen';
audioDeviceId?: string;
viewportElement?: HTMLElement;
outputFormat?: TOutputFormat;
}
export class RecorderService {
@@ -24,6 +29,7 @@ export class RecorderService {
private _duration: number = 0;
private _recordedBlob: Blob | null = null;
private _isRecording: boolean = false;
private _outputFormat: TOutputFormat = 'webm';
// Audio monitoring state
private audioContext: AudioContext | null = null;
@@ -56,6 +62,10 @@ export class RecorderService {
return this._recordedBlob;
}
get outputFormat(): TOutputFormat {
return this._outputFormat;
}
// Update event callbacks
setEvents(events: IRecorderEvents): void {
this.events = { ...this.events, ...events };
@@ -132,13 +142,16 @@ export class RecorderService {
async startRecording(options: IRecordingOptions): Promise<void> {
try {
this._outputFormat = options.outputFormat || 'webm';
// Stop audio monitoring before recording
this.stopAudioMonitoring();
// Get video stream based on mode
const displayMediaOptions: DisplayMediaStreamOptions = {
video: {
displaySurface: options.mode === 'viewport' ? 'browser' : 'monitor'
displaySurface: options.mode === 'viewport' ? 'browser' : 'monitor',
frameRate: { ideal: 60 },
} as MediaTrackConstraints,
audio: false
};
@@ -182,12 +195,23 @@ export class RecorderService {
// Store stream for cleanup
this.currentStream = combinedStream;
// Create MediaRecorder
// Handle stream ending (user clicks "Stop sharing")
videoStream.getVideoTracks()[0].onended = () => {
if (this._isRecording) {
this.stopRecording();
this.events.onStreamEnded?.();
}
};
// Always record as WebM — conversion to MP4 happens at export time
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? 'video/webm;codecs=vp9'
: 'video/webm';
this.mediaRecorder = new MediaRecorder(combinedStream, { mimeType });
this.mediaRecorder = new MediaRecorder(combinedStream, {
mimeType,
videoBitsPerSecond: 8_000_000, // 8 Mbps for smooth, high-quality capture
});
this.recordedChunks = [];
this.mediaRecorder.ondataavailable = (e) => {
@@ -198,14 +222,6 @@ export class RecorderService {
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
@@ -236,9 +252,7 @@ export class RecorderService {
}
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
@@ -251,7 +265,51 @@ export class RecorderService {
this.events.onRecordingComplete?.(this._recordedBlob);
}
// ==================== Trim & Export ====================
// ==================== Conversion & Export ====================
/**
* Converts a WebM blob to MP4 using mediabunny's Conversion API.
* Uses WebCodecs for hardware-accelerated H.264 encoding.
*/
async convertToMp4(webmBlob: Blob): Promise<Blob> {
const {
Input, Output, Conversion, BlobSource, BufferTarget, Mp4OutputFormat, WEBM, QUALITY_HIGH,
} = await import('mediabunny');
const input = new Input({
source: new BlobSource(webmBlob),
formats: [WEBM],
});
const target = new BufferTarget();
const output = new Output({
format: new Mp4OutputFormat({ fastStart: 'in-memory' }),
target,
});
const conversion = await Conversion.init({
input,
output,
// Force transcoding from VP9 → H.264 and Opus → AAC
video: {
codec: 'avc',
bitrate: QUALITY_HIGH,
fit: 'contain',
},
audio: {
codec: 'aac',
bitrate: QUALITY_HIGH,
},
});
await conversion.execute();
const buffer = target.buffer;
if (!buffer || buffer.byteLength === 0) {
throw new Error('MP4 conversion produced empty output');
}
return new Blob([buffer], { type: 'video/mp4' });
}
async exportTrimmedVideo(
videoElement: HTMLVideoElement,

View File

@@ -0,0 +1,12 @@
// Minimal type stubs for MediaCapture Transform API types not yet in lib.dom.d.ts.
// These specialize MediaStreamTrack for audio/video so mediabunny's API is type-safe.
interface MediaStreamAudioTrack extends MediaStreamTrack {
readonly kind: 'audio';
clone(): MediaStreamAudioTrack;
}
interface MediaStreamVideoTrack extends MediaStreamTrack {
readonly kind: 'video';
clone(): MediaStreamVideoTrack;
}

View File

@@ -0,0 +1,6 @@
{
"name": "@types/dom-mediacapture-transform",
"version": "0.0.0",
"description": "Minimal stub providing MediaStreamVideoTrack/MediaStreamAudioTrack for TS 6",
"types": "index.d.ts"
}

View File

@@ -0,0 +1,2 @@
// Empty stub: TypeScript 6 includes WebCodecs types in lib.dom.d.ts natively.
// This prevents @types/dom-webcodecs from conflicting with the built-in types.

View File

@@ -0,0 +1,6 @@
{
"name": "@types/dom-webcodecs",
"version": "0.0.0",
"description": "Empty stub — TypeScript 6 provides WebCodecs types natively in lib.dom.d.ts",
"types": "index.d.ts"
}

View File

@@ -4,7 +4,9 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true
"verbatimModuleSyntax": true,
"strict": false,
"types": ["node"]
},
"exclude": [
"dist_*/**/*.d.ts"