Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 639431824f | |||
| fec1dffd37 | |||
| 73a975e9e9 | |||
| d178d6cb73 | |||
| 3eeb9dc46f | |||
| d9330a5fa1 | |||
| 443618d1ac | |||
| ac087b9f3f | |||
| 977d8ab5e0 | |||
| 02e1f536d5 | |||
| a7f5341baa | |||
| 3499652622 | |||
| ee8b5dc3ff | |||
| 0e816379a5 | |||
| aa2c065918 | |||
| a778ad6855 | |||
| 24a1f064ba | |||
| 203a53a45d | |||
| 349d4ba320 | |||
| 399ef3d508 | |||
| e0f176b221 | |||
| e625fe9ba6 | |||
| fe62278d74 | |||
| 3ee8afcdae |
@@ -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"
|
||||
}
|
||||
}
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
99
changelog.md
99
changelog.md
@@ -1,5 +1,104 @@
|
||||
# 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
|
||||
|
||||
|
||||
2
license
2
license
@@ -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
|
||||
|
||||
39
package.json
39
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-wcctools",
|
||||
"version": "3.5.3",
|
||||
"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.7",
|
||||
"@design.estate/dees-element": "^2.1.5",
|
||||
"@design.estate/dees-domtools": "^2.5.4",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"lit": "^3.3.2"
|
||||
"lit": "^3.3.2",
|
||||
"mediabunny": "^1.40.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@api.global/typedserver": "^8.1.0",
|
||||
"@git.zone/tsbuild": "^4.0.2",
|
||||
"@git.zone/tsbundle": "^2.6.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.4",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6288
pnpm-lock.yaml
generated
6288
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
39
readme.md
39
readme.md
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-wcctools',
|
||||
version: '3.5.3',
|
||||
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.'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
ts_web/types/dom-mediacapture-stub/index.d.ts
vendored
Normal file
12
ts_web/types/dom-mediacapture-stub/index.d.ts
vendored
Normal 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;
|
||||
}
|
||||
6
ts_web/types/dom-mediacapture-stub/package.json
Normal file
6
ts_web/types/dom-mediacapture-stub/package.json
Normal 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"
|
||||
}
|
||||
2
ts_web/types/dom-webcodecs-stub/index.d.ts
vendored
Normal file
2
ts_web/types/dom-webcodecs-stub/index.d.ts
vendored
Normal 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.
|
||||
6
ts_web/types/dom-webcodecs-stub/package.json
Normal file
6
ts_web/types/dom-webcodecs-stub/package.json
Normal 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"
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"strict": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
|
||||
Reference in New Issue
Block a user