Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73a975e9e9 | |||
| d178d6cb73 | |||
| 3eeb9dc46f | |||
| d9330a5fa1 | |||
| 443618d1ac | |||
| ac087b9f3f | |||
| 977d8ab5e0 | |||
| 02e1f536d5 | |||
| a7f5341baa | |||
| 3499652622 | |||
| ee8b5dc3ff | |||
| 0e816379a5 | |||
| aa2c065918 | |||
| a778ad6855 | |||
| 24a1f064ba | |||
| 203a53a45d | |||
| 349d4ba320 | |||
| 399ef3d508 | |||
| e0f176b221 | |||
| e625fe9ba6 | |||
| fe62278d74 | |||
| 3ee8afcdae | |||
| ab517b6ba8 | |||
| 2e4cbd911c | |||
| 6e14ebde03 | |||
| 28d1227d30 | |||
| 8c60d3bea3 | |||
| 9ed614994f | |||
| 61b79aa4dc | |||
| 1134cba575 | |||
| 29c0df489e | |||
| 53df62a9fd |
BIN
.playwright-mcp/sidebar-check-2.png
Normal file
BIN
.playwright-mcp/sidebar-check-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
.playwright-mcp/sidebar-check.png
Normal file
BIN
.playwright-mcp/sidebar-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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": {
|
||||
|
||||
124
changelog.md
124
changelog.md
@@ -1,5 +1,129 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Add isHidden state to wcc-sidebar and wcc-properties and switch display bindings to use isHidden instead of directly using isNative
|
||||
- Introduce a 300ms delayed hide when entering native mode so UI hides after frame animation completes; show immediately when exiting native mode
|
||||
- Replace hardcoded hex border values in wcc-frame with rgba and set native border to a transparent 0px to prevent abrupt visual jumps
|
||||
|
||||
## 2026-01-04 - 3.5.1 - fix(sidebar)
|
||||
disable frame CSS transition while user is resizing the sidebar to prevent janky animations
|
||||
|
||||
- Added isResizing boolean property to wcc-frame to toggle transitions during resize
|
||||
- Set frame.isResizing = true at resize start and false on mouseup to re-enable transitions
|
||||
- Updated CSS to skip transition while isResizing is true
|
||||
- Files changed: ts_web/elements/wcc-frame.ts, ts_web/elements/wcc-sidebar.ts
|
||||
|
||||
## 2026-01-04 - 3.5.0 - feat(wcctools)
|
||||
add context menu and pinning support, persist pinned state in URL, and add grouped demo test elements
|
||||
|
||||
- Add wcc-contextmenu custom element with a static show() API, proper positioning, visibility transitions, outside-click and Escape handling, and menu item actions.
|
||||
- Introduce pinnedItems (Set<string>) on wcc-dashboard and wcc-sidebar; pass pinnedItems to the sidebar, handle pinnedChanged events, and persist pinned item keys in the URL query param 'pinned'. Changes include defensive updates to avoid unnecessary update loops.
|
||||
- Enhance wcc-sidebar to render pinned state: new styles for pinned items and pinned sections, contextmenu integration for element items, adjusted layout (grid-template-columns) and improved element/demo rendering logic.
|
||||
- Add grouped demo test components and exports to demo the demoGroup feature: test-button-primary, test-button-secondary, test-button-danger, test-input-text, and test-input-checkbox.
|
||||
- Misc: adjust dashboard URL state serialization/deserialization to include pinned items and ensure scroll/search state handling remains stable.
|
||||
|
||||
## 2025-12-30 - 3.4.0 - feat(sidebar)
|
||||
add searchable sidebar with URL-backed query state and highlighted matches
|
||||
|
||||
|
||||
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.4.0",
|
||||
"version": "3.8.5",
|
||||
"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
5773
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
|
||||
|
||||
21
readme.md
21
readme.md
@@ -293,6 +293,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:
|
||||
@@ -462,7 +481,7 @@ my-component-library/
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -4,3 +4,10 @@ export * from './test-complextypes.js';
|
||||
export * from './test-withwrapper.js';
|
||||
export * from './test-edgecases.js';
|
||||
export * from './test-nested.js';
|
||||
|
||||
// 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';
|
||||
export * from './test-input-text.js';
|
||||
export * from './test-input-checkbox.js';
|
||||
|
||||
45
test/elements/test-button-danger.ts
Normal file
45
test/elements/test-button-danger.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('test-button-danger')
|
||||
export class TestButtonDanger extends DeesElement {
|
||||
// Same group as other buttons
|
||||
public static demoGroups = 'Buttons';
|
||||
|
||||
public static demo = () => html`
|
||||
<test-button-danger>Delete</test-button-danger>
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
accessor label: string = 'Delete';
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
button {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`<button><slot>${this.label}</slot></button>`;
|
||||
}
|
||||
}
|
||||
45
test/elements/test-button-primary.ts
Normal file
45
test/elements/test-button-primary.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('test-button-primary')
|
||||
export class TestButtonPrimary extends DeesElement {
|
||||
// This groups the element with other "Buttons" in the sidebar
|
||||
public static demoGroups = 'Buttons';
|
||||
|
||||
public static demo = () => html`
|
||||
<test-button-primary>Click Me</test-button-primary>
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
accessor label: string = 'Button';
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`<button><slot>${this.label}</slot></button>`;
|
||||
}
|
||||
}
|
||||
45
test/elements/test-button-secondary.ts
Normal file
45
test/elements/test-button-secondary.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('test-button-secondary')
|
||||
export class TestButtonSecondary extends DeesElement {
|
||||
// Same group as test-button-primary - they'll appear together
|
||||
public static demoGroups = 'Buttons';
|
||||
|
||||
public static demo = () => html`
|
||||
<test-button-secondary>Secondary Action</test-button-secondary>
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
accessor label: string = 'Button';
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
button {
|
||||
background: transparent;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`<button><slot>${this.label}</slot></button>`;
|
||||
}
|
||||
}
|
||||
68
test/elements/test-input-checkbox.ts
Normal file
68
test/elements/test-input-checkbox.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('test-input-checkbox')
|
||||
export class TestInputCheckbox extends DeesElement {
|
||||
// Same group as test-input-text
|
||||
public static demoGroups = ['Inputs', 'A Second Group'];
|
||||
|
||||
public static demo = () => html`
|
||||
<test-input-checkbox label="Accept terms and conditions"></test-input-checkbox>
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
accessor label: string = 'Checkbox';
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor checked: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.checkbox.checked {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.checkbox.checked::after {
|
||||
content: '✓';
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
.label {
|
||||
color: #e5e5e5;
|
||||
font-size: 14px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div
|
||||
class="checkbox ${this.checked ? 'checked' : ''}"
|
||||
@click=${() => this.checked = !this.checked}
|
||||
></div>
|
||||
<span class="label">${this.label}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
59
test/elements/test-input-text.ts
Normal file
59
test/elements/test-input-text.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('test-input-text')
|
||||
export class TestInputText extends DeesElement {
|
||||
// Different group - "Inputs"
|
||||
public static demoGroups = 'Inputs';
|
||||
|
||||
public static demo = () => html`
|
||||
<test-input-text placeholder="Enter text..."></test-input-text>
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
accessor placeholder: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor value: string = '';
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
input {
|
||||
background: #1a1a1a;
|
||||
color: #e5e5e5;
|
||||
border: 1px solid #333;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
min-width: 200px;
|
||||
}
|
||||
input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<input
|
||||
type="text"
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.value}
|
||||
@input=${(e: Event) => this.value = (e.target as HTMLInputElement).value}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-wcctools',
|
||||
version: '3.4.0',
|
||||
version: '3.8.5',
|
||||
description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.'
|
||||
}
|
||||
|
||||
211
ts_web/elements/wcc-contextmenu.ts
Normal file
211
ts_web/elements/wcc-contextmenu.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, state, css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export interface IContextMenuItem {
|
||||
name: string;
|
||||
iconName?: string;
|
||||
action: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@customElement('wcc-contextmenu')
|
||||
export class WccContextmenu extends DeesElement {
|
||||
// Static method to show context menu at position
|
||||
public static async show(
|
||||
event: MouseEvent,
|
||||
menuItems: IContextMenuItem[]
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Remove any existing context menu
|
||||
const existing = document.querySelector('wcc-contextmenu');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
const menu = new WccContextmenu();
|
||||
menu.menuItems = menuItems;
|
||||
menu.x = event.clientX;
|
||||
menu.y = event.clientY;
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Wait for render then adjust position if needed
|
||||
await menu.updateComplete;
|
||||
menu.adjustPosition();
|
||||
}
|
||||
|
||||
@property({ type: Array })
|
||||
accessor menuItems: IContextMenuItem[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
accessor x: number = 0;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor y: number = 0;
|
||||
|
||||
@state()
|
||||
accessor visible: boolean = false;
|
||||
|
||||
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
|
||||
private boundHandleKeydown = this.handleKeydown.bind(this);
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-5px);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:host(.visible) {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.menu {
|
||||
min-width: 160px;
|
||||
background: #0f0f0f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu-item.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.menu-item .icon {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu-item:hover .icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menu-item .label {
|
||||
flex: 1;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="menu">
|
||||
${this.menuItems.map(item => html`
|
||||
<div
|
||||
class="menu-item ${item.disabled ? 'disabled' : ''}"
|
||||
@click=${() => this.handleItemClick(item)}
|
||||
>
|
||||
${item.iconName ? html`<span class="icon">${item.iconName}</span>` : null}
|
||||
<span class="label">${item.name}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
// Delay adding listeners to avoid immediate close
|
||||
requestAnimationFrame(() => {
|
||||
document.addEventListener('click', this.boundHandleOutsideClick);
|
||||
document.addEventListener('contextmenu', this.boundHandleOutsideClick);
|
||||
document.addEventListener('keydown', this.boundHandleKeydown);
|
||||
this.classList.add('visible');
|
||||
});
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
document.removeEventListener('click', this.boundHandleOutsideClick);
|
||||
document.removeEventListener('contextmenu', this.boundHandleOutsideClick);
|
||||
document.removeEventListener('keydown', this.boundHandleKeydown);
|
||||
}
|
||||
|
||||
private adjustPosition() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
let x = this.x;
|
||||
let y = this.y;
|
||||
|
||||
// Adjust if menu goes off right edge
|
||||
if (x + rect.width > windowWidth - 10) {
|
||||
x = windowWidth - rect.width - 10;
|
||||
}
|
||||
|
||||
// Adjust if menu goes off bottom edge
|
||||
if (y + rect.height > windowHeight - 10) {
|
||||
y = windowHeight - rect.height - 10;
|
||||
}
|
||||
|
||||
// Ensure not off left or top
|
||||
if (x < 10) x = 10;
|
||||
if (y < 10) y = 10;
|
||||
|
||||
this.style.left = `${x}px`;
|
||||
this.style.top = `${y}px`;
|
||||
}
|
||||
|
||||
private handleOutsideClick(e: Event) {
|
||||
const path = e.composedPath();
|
||||
if (!path.includes(this)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleItemClick(item: IContextMenuItem) {
|
||||
if (item.disabled) return;
|
||||
await item.action();
|
||||
this.close();
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.classList.remove('visible');
|
||||
setTimeout(() => this.remove(), 150);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -62,13 +64,21 @@ export class WccDashboard extends DeesElement {
|
||||
@property()
|
||||
accessor searchQuery: string = '';
|
||||
|
||||
// Pinned items as Set of "sectionName::itemName"
|
||||
@property({ attribute: false })
|
||||
accessor pinnedItems: Set<string> = new Set();
|
||||
|
||||
// Sidebar width (resizable)
|
||||
@property({ type: Number })
|
||||
accessor sidebarWidth: number = 200;
|
||||
|
||||
// Derived from selectedViewport - no need for separate property
|
||||
public get isNative(): boolean {
|
||||
return this.selectedViewport === 'native';
|
||||
}
|
||||
|
||||
@property()
|
||||
accessor warning: string = null;
|
||||
accessor warning: string | null = null;
|
||||
|
||||
private frameScrollY: number = 0;
|
||||
private sidebarScrollY: number = 0;
|
||||
@@ -122,6 +132,8 @@ export class WccDashboard extends DeesElement {
|
||||
.dashboardRef=${this}
|
||||
.selectedItem=${this.selectedItem}
|
||||
.searchQuery=${this.searchQuery}
|
||||
.pinnedItems=${this.pinnedItems}
|
||||
.sidebarWidth=${this.sidebarWidth}
|
||||
.isNative=${this.isNative}
|
||||
@selectedType=${(eventArg) => {
|
||||
this.selectedType = eventArg.detail;
|
||||
@@ -136,6 +148,19 @@ export class WccDashboard extends DeesElement {
|
||||
this.searchQuery = eventArg.detail;
|
||||
this.updateUrlWithScrollState();
|
||||
}}
|
||||
@pinnedChanged=${(eventArg: CustomEvent) => {
|
||||
this.pinnedItems = eventArg.detail;
|
||||
this.updateUrlWithScrollState();
|
||||
}}
|
||||
@widthChanged=${async (eventArg: CustomEvent) => {
|
||||
this.sidebarWidth = eventArg.detail;
|
||||
this.updateUrlWithScrollState();
|
||||
const frame = await this.wccFrame;
|
||||
if (frame) {
|
||||
frame.sidebarWidth = eventArg.detail;
|
||||
frame.requestUpdate();
|
||||
}
|
||||
}}
|
||||
></wcc-sidebar>
|
||||
<wcc-properties
|
||||
.dashboardRef=${this}
|
||||
@@ -144,6 +169,7 @@ export class WccDashboard extends DeesElement {
|
||||
.selectedViewport=${this.selectedViewport}
|
||||
.selectedTheme=${this.selectedTheme}
|
||||
.isNative=${this.isNative}
|
||||
.sidebarWidth=${this.sidebarWidth}
|
||||
@selectedViewport=${(eventArg) => {
|
||||
this.selectedViewport = eventArg.detail;
|
||||
this.scheduleUpdate();
|
||||
@@ -162,7 +188,7 @@ export class WccDashboard extends DeesElement {
|
||||
this.toggleNative();
|
||||
}}
|
||||
></wcc-properties>
|
||||
<wcc-frame id="wccFrame" viewport=${this.selectedViewport} .isNative=${this.isNative}>
|
||||
<wcc-frame id="wccFrame" viewport=${this.selectedViewport} .isNative=${this.isNative} .sidebarWidth=${this.sidebarWidth}>
|
||||
</wcc-frame>
|
||||
`;
|
||||
}
|
||||
@@ -237,6 +263,8 @@ export class WccDashboard extends DeesElement {
|
||||
const search = routeInfo.queryParams.search;
|
||||
const frameScrollY = routeInfo.queryParams.frameScrollY;
|
||||
const sidebarScrollY = routeInfo.queryParams.sidebarScrollY;
|
||||
const pinned = routeInfo.queryParams.pinned;
|
||||
const sidebarWidth = routeInfo.queryParams.sidebarWidth;
|
||||
|
||||
if (search) {
|
||||
this.searchQuery = search;
|
||||
@@ -249,13 +277,36 @@ export class WccDashboard extends DeesElement {
|
||||
if (sidebarScrollY) {
|
||||
this.sidebarScrollY = parseInt(sidebarScrollY);
|
||||
}
|
||||
if (pinned) {
|
||||
const newPinned = new Set(pinned.split(',').filter(Boolean));
|
||||
// Only update if actually different to avoid update loops
|
||||
if (this.pinnedItems.size !== newPinned.size ||
|
||||
![...newPinned].every(k => this.pinnedItems.has(k))) {
|
||||
this.pinnedItems = newPinned;
|
||||
}
|
||||
} else if (this.pinnedItems.size > 0) {
|
||||
this.pinnedItems = new Set();
|
||||
}
|
||||
if (sidebarWidth) {
|
||||
this.sidebarWidth = parseInt(sidebarWidth, 10);
|
||||
}
|
||||
|
||||
// Apply scroll positions after a short delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
// Apply scroll positions and update frame after a short delay to ensure DOM is ready
|
||||
setTimeout(async () => {
|
||||
this.applyScrollPositions();
|
||||
// Ensure frame gets the sidebarWidth
|
||||
const frame = await this.wccFrame;
|
||||
if (frame) {
|
||||
frame.sidebarWidth = this.sidebarWidth;
|
||||
frame.requestUpdate();
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
// Only clear if not already empty to avoid update loops
|
||||
if (this.pinnedItems.size > 0) {
|
||||
this.pinnedItems = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
const domtoolsInstance = await plugins.deesDomtools.elementBasic.setup();
|
||||
@@ -301,6 +352,8 @@ export class WccDashboard extends DeesElement {
|
||||
const search = routeInfo.queryParams.search;
|
||||
const frameScrollY = routeInfo.queryParams.frameScrollY;
|
||||
const sidebarScrollY = routeInfo.queryParams.sidebarScrollY;
|
||||
const pinned = routeInfo.queryParams.pinned;
|
||||
const sidebarWidth = routeInfo.queryParams.sidebarWidth;
|
||||
|
||||
if (search) {
|
||||
this.searchQuery = search;
|
||||
@@ -313,6 +366,19 @@ export class WccDashboard extends DeesElement {
|
||||
if (sidebarScrollY) {
|
||||
this.sidebarScrollY = parseInt(sidebarScrollY);
|
||||
}
|
||||
if (pinned) {
|
||||
const newPinned = new Set(pinned.split(',').filter(Boolean));
|
||||
// Only update if actually different to avoid update loops
|
||||
if (this.pinnedItems.size !== newPinned.size ||
|
||||
![...newPinned].every(k => this.pinnedItems.has(k))) {
|
||||
this.pinnedItems = newPinned;
|
||||
}
|
||||
} else if (this.pinnedItems.size > 0) {
|
||||
this.pinnedItems = new Set();
|
||||
}
|
||||
if (sidebarWidth) {
|
||||
this.sidebarWidth = parseInt(sidebarWidth, 10);
|
||||
}
|
||||
|
||||
// Apply scroll positions after a short delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
@@ -320,6 +386,10 @@ export class WccDashboard extends DeesElement {
|
||||
}, 100);
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
// Only clear if not already empty to avoid update loops
|
||||
if (this.pinnedItems.size > 0) {
|
||||
this.pinnedItems = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
const domtoolsInstance = await plugins.deesDomtools.elementBasic.setup();
|
||||
@@ -402,6 +472,12 @@ export class WccDashboard extends DeesElement {
|
||||
if (this.sidebarScrollY > 0) {
|
||||
queryParams.set('sidebarScrollY', this.sidebarScrollY.toString());
|
||||
}
|
||||
if (this.pinnedItems.size > 0) {
|
||||
queryParams.set('pinned', Array.from(this.pinnedItems).join(','));
|
||||
}
|
||||
if (this.sidebarWidth !== 200) {
|
||||
queryParams.set('sidebarWidth', this.sidebarWidth.toString());
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const fullUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
@@ -417,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', () => {
|
||||
@@ -431,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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +541,12 @@ export class WccDashboard extends DeesElement {
|
||||
if (this.sidebarScrollY > 0) {
|
||||
queryParams.set('sidebarScrollY', this.sidebarScrollY.toString());
|
||||
}
|
||||
if (this.pinnedItems.size > 0) {
|
||||
queryParams.set('pinned', Array.from(this.pinnedItems).join(','));
|
||||
}
|
||||
if (this.sidebarWidth !== 200) {
|
||||
queryParams.set('sidebarWidth', this.sidebarWidth.toString());
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const fullUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
@@ -475,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;
|
||||
|
||||
@@ -19,13 +19,18 @@ export class WccFrame extends DeesElement {
|
||||
@property({ type: Boolean })
|
||||
accessor isNative: boolean = false;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor sidebarWidth: number = 200;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor isResizing: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
border: 10px solid #ffaeaf;
|
||||
border: 10px solid rgba(255, 174, 175, 1);
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#333', '#000')};
|
||||
left: 200px;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
overflow-y: auto;
|
||||
@@ -47,17 +52,17 @@ export class WccFrame extends DeesElement {
|
||||
<style>
|
||||
:host {
|
||||
${this.isNative ? `
|
||||
border: none !important;
|
||||
border: 0px solid rgba(255, 174, 175, 0) !important;
|
||||
left: 0px !important;
|
||||
right: 0px !important;
|
||||
top: 0px !important;
|
||||
bottom: 0px !important;
|
||||
` : `
|
||||
bottom: ${this.advancedEditorOpen ? '400px' : '100px'};
|
||||
border: 10px solid #ffaeaf;
|
||||
left: 200px;
|
||||
border: 10px solid rgba(255, 174, 175, 1);
|
||||
left: ${this.sidebarWidth}px;
|
||||
`}
|
||||
transition: all 0.3s ease;
|
||||
transition: ${this.isResizing ? 'none' : 'all 0.3s ease'};
|
||||
${this.isNative ? 'padding: 0px;' : (() => {
|
||||
switch (this.viewport) {
|
||||
case 'desktop':
|
||||
@@ -67,19 +72,19 @@ export class WccFrame extends DeesElement {
|
||||
case 'tablet':
|
||||
return `
|
||||
padding: 0px ${
|
||||
(document.body.clientWidth - 200 - domtools.breakpoints.tablet) / 2
|
||||
(document.body.clientWidth - this.sidebarWidth - domtools.breakpoints.tablet) / 2
|
||||
}px;
|
||||
`;
|
||||
case 'phablet':
|
||||
return `
|
||||
padding: 0px ${
|
||||
(document.body.clientWidth - 200 - domtools.breakpoints.phablet) / 2
|
||||
(document.body.clientWidth - this.sidebarWidth - domtools.breakpoints.phablet) / 2
|
||||
}px;
|
||||
`;
|
||||
case 'phone':
|
||||
return `
|
||||
padding: 0px ${
|
||||
(document.body.clientWidth - 200 - domtools.breakpoints.phone) / 2
|
||||
(document.body.clientWidth - this.sidebarWidth - domtools.breakpoints.phone) / 2
|
||||
}px;
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ export class WccProperties extends DeesElement {
|
||||
@property()
|
||||
accessor isNative: boolean = false;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor sidebarWidth: number = 200;
|
||||
|
||||
@state()
|
||||
accessor propertyContent: TemplateResult[] = [];
|
||||
|
||||
@@ -60,6 +63,10 @@ export class WccProperties extends DeesElement {
|
||||
@state()
|
||||
accessor recordingDuration: number = 0;
|
||||
|
||||
// Delayed hide for native mode transition
|
||||
@state()
|
||||
accessor isHidden: boolean = false;
|
||||
|
||||
public editorHeight: number = 300;
|
||||
|
||||
public render(): TemplateResult {
|
||||
@@ -89,14 +96,14 @@ export class WccProperties extends DeesElement {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
left: 200px;
|
||||
left: ${this.sidebarWidth}px;
|
||||
height: ${this.editingProperties.length > 0 ? 100 + this.editorHeight : 100}px;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
display: ${this.isNative ? 'none' : 'block'};
|
||||
display: ${this.isHidden ? 'none' : 'block'};
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
@@ -928,6 +935,19 @@ export class WccProperties extends DeesElement {
|
||||
protected updated(changedProperties: Map<string, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
// Handle delayed hide for native mode transition
|
||||
if (changedProperties.has('isNative')) {
|
||||
if (this.isNative) {
|
||||
// Delay hiding until frame animation completes
|
||||
setTimeout(() => {
|
||||
this.isHidden = true;
|
||||
}, 300);
|
||||
} else {
|
||||
// Show immediately when exiting native mode
|
||||
this.isHidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only recreate properties when selectedItem changes
|
||||
if (changedProperties.has('selectedItem')) {
|
||||
this.createProperties().catch(error => {
|
||||
|
||||
@@ -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,6 +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, type IContextMenuItem } from './wcc-contextmenu.js';
|
||||
|
||||
@customElement('wcc-sidebar')
|
||||
export class WccSidebar extends DeesElement {
|
||||
@@ -31,8 +32,35 @@ export class WccSidebar extends DeesElement {
|
||||
@property()
|
||||
accessor searchQuery: string = '';
|
||||
|
||||
// Pinned items as Set of "sectionName::itemName"
|
||||
@property({ attribute: false })
|
||||
accessor pinnedItems: Set<string> = new Set();
|
||||
|
||||
// Sidebar width (resizable)
|
||||
@property({ type: Number })
|
||||
accessor sidebarWidth: number = 200;
|
||||
|
||||
// Track if currently resizing
|
||||
@state()
|
||||
accessor isResizing: boolean = false;
|
||||
|
||||
// Delayed hide for native mode transition
|
||||
@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" />
|
||||
@@ -54,23 +82,40 @@ export class WccSidebar extends DeesElement {
|
||||
--ring: #3b82f6;
|
||||
--radius: 4px;
|
||||
|
||||
display: ${this.isNative ? '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;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
width: 200px;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -159,7 +204,11 @@ export class WccSidebar extends DeesElement {
|
||||
}
|
||||
|
||||
.selectOption.folder {
|
||||
grid-template-columns: 16px 20px 1fr;
|
||||
grid-template-columns: 16px 1fr;
|
||||
}
|
||||
|
||||
.selectOption.folder .text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.selectOption .expand-icon {
|
||||
@@ -260,6 +309,7 @@ export class WccSidebar extends DeesElement {
|
||||
.search-container {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@@ -268,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;
|
||||
@@ -284,23 +334,156 @@ 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;
|
||||
}
|
||||
|
||||
/* Pinned item highlight in original section */
|
||||
.selectOption.pinned {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.selectOption.pinned:hover {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.selectOption.pinned.selected {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
/* Pinned section styling */
|
||||
.section-header.pinned-section {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.section-header.pinned-section:hover {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.section-header.pinned-section .section-icon {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Section tag pill for pinned items */
|
||||
.section-tag {
|
||||
font-size: 0.5rem;
|
||||
color: #888;
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 9999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Group container */
|
||||
.item-group {
|
||||
margin: 0.375rem 0.375rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
}
|
||||
|
||||
.item-group-legend {
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #555;
|
||||
padding: 0.125rem 0.625rem 0.25rem;
|
||||
display: block;
|
||||
cursor: context-menu;
|
||||
}
|
||||
|
||||
.item-group .selectOption {
|
||||
margin-left: 0.25rem;
|
||||
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;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.resize-handle.active {
|
||||
background: var(--primary);
|
||||
}
|
||||
</style>
|
||||
<div class="search-container">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
.value=${this.searchQuery}
|
||||
@input=${this.handleSearchInput}
|
||||
/>
|
||||
<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">
|
||||
<div class="menu" @scroll=${this.handleMenuScroll}>
|
||||
${this.renderSections()}
|
||||
</div>
|
||||
<div
|
||||
class="resize-handle ${this.isResizing ? 'active' : ''}"
|
||||
@mousedown=${this.startResize}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -308,7 +491,7 @@ export class WccSidebar extends DeesElement {
|
||||
* Initialize collapsed sections from section config
|
||||
*/
|
||||
private initCollapsedSections() {
|
||||
if (this.sectionsInitialized) return;
|
||||
if (this.sectionsInitialized || !this.dashboardRef?.sections) return;
|
||||
|
||||
const collapsed = new Set<string>();
|
||||
for (const section of this.dashboardRef.sections) {
|
||||
@@ -320,16 +503,164 @@ export class WccSidebar extends DeesElement {
|
||||
this.sectionsInitialized = true;
|
||||
}
|
||||
|
||||
// ============ Pinning helpers ============
|
||||
|
||||
private getPinKey(sectionName: string, itemName: string): string {
|
||||
return `${sectionName}::${itemName}`;
|
||||
}
|
||||
|
||||
private isPinned(sectionName: string, itemName: string): boolean {
|
||||
return this.pinnedItems.has(this.getPinKey(sectionName, itemName));
|
||||
}
|
||||
|
||||
private togglePin(sectionName: string, itemName: string) {
|
||||
const key = this.getPinKey(sectionName, itemName);
|
||||
const newPinned = new Set(this.pinnedItems);
|
||||
if (newPinned.has(key)) {
|
||||
newPinned.delete(key);
|
||||
} else {
|
||||
newPinned.add(key);
|
||||
}
|
||||
this.pinnedItems = newPinned;
|
||||
this.dispatchEvent(new CustomEvent('pinnedChanged', { detail: newPinned }));
|
||||
}
|
||||
|
||||
private showContextMenu(e: MouseEvent, sectionName: string, itemName: string) {
|
||||
const isPinned = this.isPinned(sectionName, itemName);
|
||||
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 }));
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the PINNED section (only if there are pinned items)
|
||||
*/
|
||||
private renderPinnedSection() {
|
||||
if (!this.dashboardRef?.sections || this.pinnedItems.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCollapsed = this.collapsedSections.has('__pinned__');
|
||||
|
||||
// Collect pinned items with their original section info
|
||||
// Pinned items are NOT filtered by search - they always remain visible
|
||||
const pinnedEntries: Array<{ sectionName: string; itemName: string; item: any; section: IWccSection }> = [];
|
||||
|
||||
for (const key of this.pinnedItems) {
|
||||
const [sectionName, itemName] = key.split('::');
|
||||
const section = this.dashboardRef.sections.find(s => s.name === sectionName);
|
||||
if (section) {
|
||||
const entries = getSectionItems(section);
|
||||
const found = entries.find(([name]) => name === itemName);
|
||||
if (found) {
|
||||
pinnedEntries.push({ sectionName, itemName, item: found[1], section });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pinnedEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="section-header pinned-section ${isCollapsed ? 'collapsed' : ''}"
|
||||
@click=${() => this.toggleSectionCollapsed('__pinned__')}
|
||||
>
|
||||
<i class="material-symbols-outlined expand-icon">expand_more</i>
|
||||
<i class="material-symbols-outlined section-icon">push_pin</i>
|
||||
<span>Pinned</span>
|
||||
</div>
|
||||
<div class="section-content ${isCollapsed ? 'collapsed' : ''}">
|
||||
${pinnedEntries.map(({ sectionName, itemName, item, section }) => {
|
||||
const isSelected = this.selectedItem === item;
|
||||
const type = section.type === 'elements' ? 'element' : 'page';
|
||||
const icon = section.type === 'elements'
|
||||
? (this.getElementGroups(item).length > 1 ? 'library_books' : 'featured_video')
|
||||
: 'insert_drive_file';
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="selectOption ${isSelected ? 'selected' : ''}"
|
||||
@click=${async () => {
|
||||
await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem(type, itemName, item, 0, section);
|
||||
}}
|
||||
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, sectionName, itemName)}
|
||||
>
|
||||
<i class="material-symbols-outlined">${icon}</i>
|
||||
<div class="text">${this.highlightMatch(itemName)}</div>
|
||||
<span class="section-tag">${sectionName}</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all sections
|
||||
*/
|
||||
private renderSections() {
|
||||
if (!this.dashboardRef?.sections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.initCollapsedSections();
|
||||
|
||||
return this.dashboardRef.sections.map((section, index) => {
|
||||
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) {
|
||||
@@ -361,17 +692,27 @@ 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]) => {
|
||||
const isPinned = this.isPinned(section.name, pageName);
|
||||
return html`
|
||||
<div
|
||||
class="selectOption ${this.selectedItem === item ? 'selected' : ''}"
|
||||
class="selectOption ${this.selectedItem === item ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
|
||||
@click=${async () => {
|
||||
await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem('page', pageName, item, 0, section);
|
||||
}}
|
||||
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, pageName)}
|
||||
>
|
||||
<i class="material-symbols-outlined">insert_drive_file</i>
|
||||
<div class="text">${this.highlightMatch(pageName)}</div>
|
||||
@@ -379,58 +720,58 @@ export class WccSidebar extends DeesElement {
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
// type === 'elements'
|
||||
return filteredEntries.map(([elementName, item]) => {
|
||||
const anonItem = item as any;
|
||||
const demoCount = anonItem.demo ? getDemoCount(anonItem.demo) : 0;
|
||||
const isMultiDemo = anonItem.demo && hasMultipleDemos(anonItem.demo);
|
||||
const isExpanded = this.expandedElements.has(elementName);
|
||||
const isSelected = this.selectedItem === item;
|
||||
// type === 'elements' - group by demoGroups (supports string | string[])
|
||||
const groupedItems = new Map<string | null, Array<[string, any]>>();
|
||||
|
||||
if (isMultiDemo) {
|
||||
// Multi-demo element - render as expandable folder
|
||||
return html`
|
||||
<div
|
||||
class="selectOption folder ${isExpanded ? 'expanded' : ''} ${isSelected ? 'selected' : ''}"
|
||||
@click=${() => this.toggleExpanded(elementName)}
|
||||
>
|
||||
<i class="material-symbols-outlined expand-icon">chevron_right</i>
|
||||
<i class="material-symbols-outlined">folder</i>
|
||||
<div class="text">${this.highlightMatch(elementName)}</div>
|
||||
</div>
|
||||
${isExpanded ? html`
|
||||
<div class="demo-children">
|
||||
${Array.from({ length: demoCount }, (_, i) => {
|
||||
const demoIndex = i;
|
||||
const isThisDemoSelected = isSelected && this.dashboardRef.selectedDemoIndex === demoIndex;
|
||||
return html`
|
||||
<div
|
||||
class="demo-child ${isThisDemoSelected ? 'selected' : ''}"
|
||||
@click=${async () => {
|
||||
await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem('element', elementName, item, demoIndex, section);
|
||||
}}
|
||||
>
|
||||
<i class="material-symbols-outlined">play_circle</i>
|
||||
<div class="text">demo${demoIndex + 1}</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : null}
|
||||
`;
|
||||
for (const entry of filteredEntries) {
|
||||
const [, item] = entry;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
|
||||
const renderItems: RenderItem[] = [];
|
||||
|
||||
// Add ungrouped items
|
||||
const ungrouped = groupedItems.get(null) || [];
|
||||
for (const entry of ungrouped) {
|
||||
renderItems.push({ type: 'element', entry, sortKey: entry[0].toLowerCase() });
|
||||
}
|
||||
|
||||
// Add groups (sorted by group name)
|
||||
for (const [groupName, items] of groupedItems) {
|
||||
if (groupName === null) continue;
|
||||
renderItems.push({ type: 'group', groupName, items, sortKey: groupName.toLowerCase() });
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Single demo element
|
||||
return html`
|
||||
<div
|
||||
class="selectOption ${isSelected ? 'selected' : ''}"
|
||||
@click=${async () => {
|
||||
await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem('element', elementName, item, 0, section);
|
||||
}}
|
||||
>
|
||||
<i class="material-symbols-outlined">featured_video</i>
|
||||
<div class="text">${this.highlightMatch(elementName)}</div>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
@@ -438,6 +779,69 @@ export class WccSidebar extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single element item (used by renderSectionItems)
|
||||
*/
|
||||
private renderElementItem(entry: [string, any], section: IWccSection): TemplateResult {
|
||||
const [elementName, item] = entry;
|
||||
const anonItem = item as any;
|
||||
const demoCount = anonItem.demo ? getDemoCount(anonItem.demo) : 0;
|
||||
const isMultiDemo = anonItem.demo && hasMultipleDemos(anonItem.demo);
|
||||
const isExpanded = this.expandedElements.has(elementName);
|
||||
const isSelected = this.selectedItem === item;
|
||||
const isPinned = this.isPinned(section.name, elementName);
|
||||
|
||||
if (isMultiDemo) {
|
||||
// Multi-demo element - render as expandable folder
|
||||
return html`
|
||||
<div
|
||||
class="selectOption folder ${isExpanded ? 'expanded' : ''} ${isSelected ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
|
||||
@click=${() => this.toggleExpanded(elementName)}
|
||||
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, elementName)}
|
||||
>
|
||||
<i class="material-symbols-outlined expand-icon">chevron_right</i>
|
||||
<div class="text">${this.highlightMatch(elementName)}</div>
|
||||
</div>
|
||||
${isExpanded ? html`
|
||||
<div class="demo-children">
|
||||
${Array.from({ length: demoCount }, (_, i) => {
|
||||
const demoIndex = i;
|
||||
const isThisDemoSelected = isSelected && this.dashboardRef.selectedDemoIndex === demoIndex;
|
||||
return html`
|
||||
<div
|
||||
class="demo-child ${isThisDemoSelected ? 'selected' : ''}"
|
||||
@click=${async () => {
|
||||
await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem('element', elementName, item, demoIndex, section);
|
||||
}}
|
||||
>
|
||||
<i class="material-symbols-outlined">play_circle</i>
|
||||
<div class="text">demo${demoIndex + 1}</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : null}
|
||||
`;
|
||||
} else {
|
||||
// Single demo element
|
||||
const icon = this.getElementGroups(item).length > 1 ? 'library_books' : 'featured_video';
|
||||
return html`
|
||||
<div
|
||||
class="selectOption ${isSelected ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
|
||||
@click=${async () => {
|
||||
await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
this.selectItem('element', elementName, item, 0, section);
|
||||
}}
|
||||
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, elementName)}
|
||||
>
|
||||
<i class="material-symbols-outlined">${icon}</i>
|
||||
<div class="text">${this.highlightMatch(elementName)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
private toggleSectionCollapsed(sectionName: string) {
|
||||
const newSet = new Set(this.collapsedSections);
|
||||
if (newSet.has(sectionName)) {
|
||||
@@ -464,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();
|
||||
@@ -484,8 +931,21 @@ export class WccSidebar extends DeesElement {
|
||||
protected updated(changedProperties: Map<string, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
// Handle delayed hide for native mode transition
|
||||
if (changedProperties.has('isNative')) {
|
||||
if (this.isNative) {
|
||||
// Delay hiding until frame animation completes
|
||||
setTimeout(() => {
|
||||
this.isHidden = true;
|
||||
}, 300);
|
||||
} else {
|
||||
// Show immediately when exiting native mode
|
||||
this.isHidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-expand folder when a multi-demo element is selected
|
||||
if (changedProperties.has('selectedItem') && this.selectedItem) {
|
||||
if (changedProperties.has('selectedItem') && this.selectedItem && this.dashboardRef?.sections) {
|
||||
// Find the element in any section
|
||||
for (const section of this.dashboardRef.sections) {
|
||||
if (section.type !== 'elements') continue;
|
||||
@@ -508,6 +968,51 @@ export class WccSidebar extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Resize functionality ============
|
||||
|
||||
private startResize = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
this.isResizing = true;
|
||||
const startX = e.clientX;
|
||||
const startWidth = this.sidebarWidth;
|
||||
|
||||
// Cache references once at start
|
||||
const frame = this.dashboardRef?.shadowRoot?.querySelector('wcc-frame') as any;
|
||||
const properties = this.dashboardRef?.shadowRoot?.querySelector('wcc-properties') as any;
|
||||
|
||||
// Disable frame transition during resize
|
||||
if (frame) {
|
||||
frame.isResizing = true;
|
||||
}
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const newWidth = Math.min(400, Math.max(150, startWidth + (e.clientX - startX)));
|
||||
this.sidebarWidth = newWidth;
|
||||
// Update frame and properties directly
|
||||
if (frame) {
|
||||
frame.sidebarWidth = newWidth;
|
||||
}
|
||||
if (properties) {
|
||||
properties.sidebarWidth = newWidth;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
this.isResizing = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
// Re-enable frame transition
|
||||
if (frame) {
|
||||
frame.isResizing = false;
|
||||
}
|
||||
// Dispatch event on release for URL persistence
|
||||
this.dispatchEvent(new CustomEvent('widthChanged', { detail: this.sidebarWidth }));
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
public selectItem(
|
||||
typeArg: TElementType,
|
||||
itemNameArg: string,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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