Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0f176b221 | |||
| e625fe9ba6 | |||
| fe62278d74 | |||
| 3ee8afcdae | |||
| ab517b6ba8 | |||
| 2e4cbd911c | |||
| 6e14ebde03 | |||
| 28d1227d30 | |||
| 8c60d3bea3 | |||
| 9ed614994f | |||
| 61b79aa4dc | |||
| 1134cba575 | |||
| 29c0df489e | |||
| 53df62a9fd | |||
| 4fe17f5afd | |||
| 63dd6a27b3 | |||
| 287cc4d1c3 | |||
| 14e63738b7 | |||
| dd151bdad8 | |||
| bd409745e6 | |||
| eb7f482b75 | |||
| d16d482120 | |||
| 5ba4e55011 | |||
| 0068c0749d | |||
| 36dd6b5064 | |||
| ddecfcdb4c | |||
| 8f0f8606a1 | |||
| 7dca519d9a | |||
| d48cd063c4 | |||
| bb04895be8 | |||
| 54b34b6faa | |||
| 12c85fa4cb | |||
| d90df9717b | |||
| d4b161437b | |||
| ad033c8104 | |||
| d7d6d650bc | |||
| 53c5d839ca | |||
| 6cbfd714eb | |||
| 7c8c194fd8 | |||
| 278000bb36 | |||
| 52ffe81352 | |||
| 91194f6388 | |||
| 904bb92057 | |||
| 4c23739d9a | |||
| dd048d42a8 | |||
| ca28dbd9db | |||
| 7148b12066 | |||
| 309d708830 | |||
| 923bedc4fc | |||
| e8b771bde4 | |||
| 7a248993bc | |||
| 03f215e0f1 | |||
| 216cb0288d | |||
| 65acda3de1 | |||
| 88ff74bb86 | |||
| 98a5b1b5a3 | |||
| bbf738d4e2 | |||
| 4f8ca7061a |
BIN
.playwright-mcp/recording-panel.png
Normal file
BIN
.playwright-mcp/recording-panel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
.playwright-mcp/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 |
BIN
.playwright-mcp/wcctools-dashboard.png
Normal file
BIN
.playwright-mcp/wcctools-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
.playwright-mcp/wcctools-with-element.png
Normal file
BIN
.playwright-mcp/wcctools-with-element.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
167
changelog.md
167
changelog.md
@@ -1,5 +1,172 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Add search input to wcc-sidebar and expose a searchQuery property
|
||||
- Filter sidebar sections and items client-side based on the search query and hide sections with no matches
|
||||
- Highlight matching substrings in sidebar item labels
|
||||
- Emit a 'searchChanged' event from the sidebar and handle it in wcc-dashboard to keep dashboard.searchQuery in sync
|
||||
- Persist the search query in the route query parameter 'search' when building URLs and restore/clear it on navigation
|
||||
- Preserve existing scroll-state handling while adding search state to URL updates
|
||||
|
||||
## 2025-12-28 - 3.3.0 - feat(wcctools)
|
||||
Add section-based configuration API for setupWccTools, new Views, and section-aware routing/sidebar
|
||||
|
||||
- Introduce IWccSection and IWccConfig types and migrate setupWccTools to accept a sections config while preserving legacy (elements, pages) format
|
||||
- WccDashboard and WccSidebar updated to support sections, filtering, sorting, collapsed sections, and section-aware URL routing (uses sectionName in routes with legacy fallbacks)
|
||||
- Add Views: view-dashboard, view-settings, view-empty-state plus test/views index exports and demo variations
|
||||
- Add helpers: getSectionItems, convertLegacyToConfig and isWccConfig; update build URL and routing logic to be section-aware
|
||||
- Update docs and README/readme.hints with sections API, examples, migration notes and UI/UX updates
|
||||
|
||||
## 2025-12-22 - 3.2.0 - feat(wcc-sidebar)
|
||||
auto-expand sidebar folder when selecting an element with multiple demos
|
||||
|
||||
- Add updated() lifecycle to auto-expand folder when selectedItem changes
|
||||
- Find element name by matching selectedItem against dashboardRef.elements
|
||||
- Only auto-expand when the element has multiple demos (checks item.demo and hasMultipleDemos)
|
||||
- Immutably update expandedElements set to trigger re-render and avoid duplicate additions
|
||||
|
||||
## 2025-12-21 - 3.1.2 - fix(wcc-properties)
|
||||
Use LitElement.updated to recreate properties only when selectedItem changes and handle errors; remove custom scheduleUpdate implementation
|
||||
|
||||
- Replaced public async scheduleUpdate() with protected updated(changedProperties) lifecycle method
|
||||
- Call super.updated(...) and only recreate properties when selectedItem changed to avoid unnecessary work
|
||||
- Preserve error handling and clear propertyContent on failure
|
||||
- Removed explicit super.scheduleUpdate() call to rely on LitElement's update lifecycle
|
||||
|
||||
## 2025-12-21 - 3.1.1 - fix(wcc-properties)
|
||||
Improve wcc-properties CSS to prevent grid overflow, properly size and center icon glyphs, and adjust right-side offset
|
||||
|
||||
- Use minmax(0, 1fr) for grid-template-columns (selectorButtons1/2/4/5) to avoid flexbox overflow and ensure consistent column sizing
|
||||
- Add min-width/min-height and inline-flex centering to .material-symbols-outlined to stabilize icon sizing and vertical/horizontal alignment
|
||||
- Increase right calc offset from 520px to 600px to accommodate wider content/controls
|
||||
- Changes applied in ts_web/elements/wcc-properties.ts
|
||||
|
||||
## 2025-12-19 - 3.1.0 - feat(wcc-properties)
|
||||
add Share selector with inline Record button and adjust properties panel grid layout
|
||||
|
||||
- Increase rightmost column width from 70px to 100px in properties grid
|
||||
- Introduce .shareSelector and .selectorButtons1 CSS classes and markup
|
||||
- Replace <wcc-record-button> with inline Record button that toggles icon based on isRecording
|
||||
- Add Share panel heading and integrate record control into selector area
|
||||
|
||||
## 2025-12-19 - 3.0.0 - BREAKING CHANGE(ts_web)
|
||||
Replace fullscreen boolean with native viewport mode across components, add native viewport selector and toggle, and update dev deps and npmextra config
|
||||
|
||||
- Introduce isNative (derived from selectedViewport === 'native') replacing isFullscreen on WccDashboard, WccFrame, WccProperties and WccSidebar (property and styling changes).
|
||||
- WccDashboard: remove isFullscreen property, add isNative getter, implement toggleNative to switch selectedViewport between 'native' and 'desktop', update ESC handler to exit native mode and call buildUrl().
|
||||
- WccProperties: add Native button to viewport selector, adjust grid columns and selector layout, replace fullscreen toggle with toggleNative (dispatches 'toggleNative' event).
|
||||
- WccFrame: rename isFullscreen -> isNative and update style/markup branching to use isNative.
|
||||
- WccSidebar: visibility now driven by isNative instead of isFullscreen.
|
||||
- API/event changes: 'toggleFullscreen' event renamed to 'toggleNative' — this is an incompatible change for consumers relying on the old event/property.
|
||||
- package.json: bump devDependencies @git.zone/tsbuild -> ^4.0.2, @git.zone/tsrun -> ^2.0.1, @git.zone/tswatch -> ^2.3.13, @types/node -> ^25.0.3.
|
||||
- npmextra.json: rename keys (e.g. "gitzone" -> "@git.zone/cli", "tsdoc" -> "@git.zone/tsdoc"), add @ship.zone/szci entry and add release registries/accessLevel for @git.zone/cli.
|
||||
|
||||
## 2025-12-11 - 2.0.1 - fix(@git.zone/tswatch)
|
||||
Bump @git.zone/tswatch devDependency to ^2.3.12
|
||||
|
||||
- Updated devDependency @git.zone/tswatch from ^2.3.11 to ^2.3.12 in package.json
|
||||
|
||||
## 2025-12-11 - 2.0.0 - BREAKING CHANGE(recorder)
|
||||
Remove FFmpeg-based MP4 conversion; simplify recorder/export to WebM and improve recorder/editor robustness
|
||||
|
||||
- Removed FFmpegService and all client-side MP4 conversion logic — exports are now WebM-only (MP4 conversion and related UI/controls removed).
|
||||
- ts_web/elements/wcc-recording-panel: dropped outputFormat and conversion states/UI; download flow simplified to always export WebM.
|
||||
- ts_web/index.ts: removed FFmpegService exports and conversion types from public API.
|
||||
- package.json: removed @ffmpeg/* dependencies.
|
||||
- RecorderService: handleRecordingComplete is now async and fixes recorded blob assignment and cleanup timing.
|
||||
- wcc-properties: improved element detection and robustness — recursive search through light/shadow DOM with retry/delay, plus an advanced JSON editor for Object/Array props (supports multiple open editors and frame resize events).
|
||||
- wcc-sidebar: force re-render after selecting demos to ensure child demo selection indicators update correctly.
|
||||
- dees-demowrapper: ensure slotted content is rendered before calling runAfterRender (small timing/stability improvements).
|
||||
- Test update: demo definitions can be arrays (multiple demos) — test-demoelement updated to use multiple demo entries.
|
||||
|
||||
## 2025-12-11 - 1.3.0 - feat(recording-panel)
|
||||
Add demo wrapper utilities, improve recording trim behavior, and harden property panel element detection; update documentation
|
||||
|
||||
- Add dees-demowrapper (ts_demotools) with runAfterRender callback to run post-render demo logic (supports async callbacks).
|
||||
- Improve recording UI and trimming: handle WebM files with Infinity/NaN durations by falling back to tracked recording duration; replace numeric handle positioning with CSS calc strings for responsive trim handles.
|
||||
- Harden property extraction: implement recursive element search (including shadowRoots), add an initial delay and retry loop to wait for demo rendering, and add an advanced JSON editor for Object/Array properties with open/save/cancel and per-editor error reporting.
|
||||
- Add and expand documentation: new ts_web/ and ts_demotools/ READMEs, reorganized main README with clearer feature list, usage examples, and API reference.
|
||||
- Minor exports and module/docs housekeeping (index exports, readme reorder, examples updated to import classes).
|
||||
|
||||
## 2025-11-16 - 1.2.1 - fix(dependencies)
|
||||
Bump dependencies and developer tooling versions
|
||||
|
||||
- Bump @design.estate/dees-domtools from ^2.3.3 to ^2.3.6
|
||||
- Bump @design.estate/dees-element from ^2.1.2 to ^2.1.3
|
||||
- Upgrade @git.zone/tsbuild from ^2.6.8 to ^2.7.1
|
||||
- Upgrade @git.zone/tsrun from ^1.2.44 to ^1.6.2
|
||||
- Upgrade @git.zone/tstest from ^2.3.8 to ^2.7.0
|
||||
|
||||
## 2025-09-19 - 1.2.0 - feat(wcc-properties)
|
||||
Add advanced property editors, recursive element detection, demo wrapper, UI refresh and test fixtures
|
||||
|
||||
- Advanced JSON property editor: multiple side-by-side editors with save/cancel, syntax validation and inline error display; editors affect frame layout (frame bottom increases when editors open).
|
||||
- Improved properties panel element detection: recursive search through nested children and shadow DOM, initial delay and retry mechanism to handle async Lit rendering.
|
||||
- Add dees-demowrapper component in ts_demotools to run post-render callbacks and support async demo setup and DOM access for demos.
|
||||
- UI refresh with shadcn-like styles: CSS variables for theming, redesigned properties panel and sidebar, improved form controls, theme and viewport selectors.
|
||||
- Viewport and frame improvements: responsive padding based on viewport type, theme-aware background rendering, and scroll position tracking with URL/state restoration for frame and sidebar.
|
||||
- Add test fixtures and demo elements/pages under test/ to exercise properties, complex types, nested elements and scroll restoration; include node test for resolveTemplateFactory.
|
||||
- Expose setupWccTools entry point and plugin wiring (wcctools.plugins exports for dees-domtools and smartdelay) for easier integration.
|
||||
|
||||
## 2025-06-27 - 1.1.0 - feat(wcctools)
|
||||
Enhance component tools with an advanced property editor, improved element detection and modernized UI styling for a more responsive dashboard experience.
|
||||
|
||||
- Updated documentation and in-code hints with new shadcn-like design patterns for the dashboard UI.
|
||||
- Introduced an advanced complex properties editor supporting JSON validation and multi-editor handling.
|
||||
- Refined recursive element search in the properties panel to improve asynchronous rendering detection.
|
||||
- Expanded test coverage with scenarios for edge cases, nested elements and wrapper components.
|
||||
|
||||
## 2025-06-26 - 1.0.101 - fix(wcc-dashboard)
|
||||
Improve scroll listener management and add new test pages
|
||||
|
||||
|
||||
@@ -2,9 +2,32 @@
|
||||
import * as deesWccTools from '../ts_web/index.js';
|
||||
import * as deesDomTools from '@design.estate/dees-domtools';
|
||||
|
||||
// elements and pages
|
||||
// elements, views and pages
|
||||
import * as elements from '../test/elements/index.js';
|
||||
import * as views from '../test/views/index.js';
|
||||
import * as pages from '../test/pages/index.js';
|
||||
|
||||
deesWccTools.setupWccTools(elements as any, pages);
|
||||
// Sections-based API with Views
|
||||
deesWccTools.setupWccTools({
|
||||
sections: [
|
||||
{
|
||||
name: 'Pages',
|
||||
type: 'pages',
|
||||
items: pages,
|
||||
},
|
||||
{
|
||||
name: 'Views',
|
||||
type: 'elements',
|
||||
items: views,
|
||||
icon: 'web',
|
||||
},
|
||||
{
|
||||
name: 'Elements',
|
||||
type: 'elements',
|
||||
items: elements,
|
||||
sort: ([a], [b]) => a.localeCompare(b),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
deesDomTools.elementBasic.setup();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"gitzone": {
|
||||
"@git.zone/cli": {
|
||||
"projectType": "wcc",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -21,13 +21,19 @@
|
||||
"element testing",
|
||||
"page development"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"tsdoc": {
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**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.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
22
package.json
22
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-wcctools",
|
||||
"version": "1.0.101",
|
||||
"version": "3.6.1",
|
||||
"private": false,
|
||||
"description": "A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.",
|
||||
"exports": {
|
||||
@@ -17,18 +17,20 @@
|
||||
"author": "Lossless GmbH",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@design.estate/dees-domtools": "^2.0.57",
|
||||
"@design.estate/dees-element": "^2.0.34",
|
||||
"@design.estate/dees-domtools": "^2.3.7",
|
||||
"@design.estate/dees-element": "^2.1.5",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"lit": "^3.1.3"
|
||||
"lit": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@api.global/typedserver": "^3.0.29",
|
||||
"@git.zone/tsbuild": "^2.1.72",
|
||||
"@git.zone/tsbundle": "^2.0.15",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tswatch": "^2.0.23",
|
||||
"@push.rocks/projectinfo": "^5.0.2"
|
||||
"@api.global/typedserver": "^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"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
|
||||
7563
pnpm-lock.yaml
generated
7563
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
164
readme.hints.md
164
readme.hints.md
@@ -1,5 +1,169 @@
|
||||
# Project Hints and Findings
|
||||
|
||||
## Section-based Configuration API (2025-12-27)
|
||||
|
||||
### Overview
|
||||
Refactored `setupWccTools` to accept a section-based configuration object instead of separate elements/pages arguments. This allows multiple custom sections with filtering, sorting, and collapsible headers.
|
||||
|
||||
### New API
|
||||
```typescript
|
||||
import * as deesWccTools from '@design.estate/dees-wcctools';
|
||||
|
||||
deesWccTools.setupWccTools({
|
||||
sections: [
|
||||
{
|
||||
name: 'Pages',
|
||||
type: 'pages',
|
||||
items: pages,
|
||||
},
|
||||
{
|
||||
name: 'Elements',
|
||||
type: 'elements',
|
||||
items: elements,
|
||||
filter: (name, item) => !name.startsWith('internal-'),
|
||||
sort: ([a], [b]) => a.localeCompare(b),
|
||||
},
|
||||
{
|
||||
name: 'Views',
|
||||
type: 'elements',
|
||||
items: elements,
|
||||
filter: (name, item) => name.startsWith('view-'),
|
||||
icon: 'web',
|
||||
collapsed: true, // Start collapsed
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Section Properties
|
||||
- `name`: Display name for the section header
|
||||
- `type`: `'elements'` (shows demos) or `'pages'` (renders directly)
|
||||
- `items`: Record of items (element classes or page factories)
|
||||
- `filter`: Optional `(name, item) => boolean` to filter items
|
||||
- `sort`: Optional `([name, item], [name, item]) => number` for ordering
|
||||
- `icon`: Optional Material icon name for section header
|
||||
- `collapsed`: Optional boolean to start section collapsed
|
||||
|
||||
### Backwards Compatibility
|
||||
Legacy format is still supported:
|
||||
```typescript
|
||||
deesWccTools.setupWccTools(elements, pages); // Still works
|
||||
```
|
||||
|
||||
### URL Routing
|
||||
Changed from `/wcctools-route/:itemType/:itemName/...` to `/wcctools-route/:sectionName/:itemName/...`
|
||||
Section names are URL-encoded. Legacy routes (`element`/`page` as section name) still work for backwards compatibility.
|
||||
|
||||
### Files Changed
|
||||
- `ts_web/wcctools.interfaces.ts` - New types: `IWccSection`, `IWccConfig`
|
||||
- `ts_web/index.ts` - Updated `setupWccTools` with backwards compat detection
|
||||
- `ts_web/elements/wcc-dashboard.ts` - Uses sections, updated routing
|
||||
- `ts_web/elements/wcc-sidebar.ts` - Dynamic section rendering
|
||||
|
||||
---
|
||||
|
||||
## UI Redesign with Shadcn-like Styles (2025-06-27)
|
||||
|
||||
### Changes Made
|
||||
Updated the WCC Dashboard UI components (properties and sidebar) to use shadcn-like design patterns:
|
||||
|
||||
1. **Color System**: Implemented CSS variables for theming:
|
||||
- `--background`, `--foreground`, `--card`, `--primary`, `--secondary`
|
||||
- `--muted`, `--accent`, `--border`, `--input`, `--ring`
|
||||
- Consistent dark theme with subtle borders and proper contrast
|
||||
- Dynamic theme switching between light and dark modes
|
||||
|
||||
2. **Properties Panel Improvements (Updated)**:
|
||||
- Changed from fixed 3-column grid to flexible flexbox layout
|
||||
- Properties now wrap and use space more efficiently
|
||||
- Added rounded corners (using --radius-md) and better spacing
|
||||
- Property items use flexbox with min-width for responsive layout
|
||||
- Property labels now show as styled headers with type info
|
||||
- Form controls updated with shadcn-style focus states and transitions
|
||||
- Complex properties (Objects/Arrays) show "Edit" button
|
||||
- Advanced JSON editor appears above properties panel when editing complex types
|
||||
- Dynamic height adjustment (50px when editor is open, 120px normally)
|
||||
|
||||
3. **Sidebar Styling**:
|
||||
- Updated with consistent color scheme
|
||||
- Added rounded corners to menu items
|
||||
- Improved hover states with smooth transitions
|
||||
- Better typography with proper font weights
|
||||
|
||||
4. **Advanced Property Editor**:
|
||||
- JSON editor for complex types (Objects and Arrays)
|
||||
- Monaco-style monospace font for code editing
|
||||
- Live updates to element properties
|
||||
- Positioned above the properties panel with smooth transitions
|
||||
|
||||
5. **Theme and Viewport Selectors (New)**:
|
||||
- Redesigned buttons with flexbox layout for better icon/text alignment
|
||||
- Added hover effects with transform and shadow
|
||||
- Smooth transitions on all interactive elements
|
||||
- Selected state uses primary color variables
|
||||
- Icons reduced in size for better balance
|
||||
|
||||
6. **Form Controls (New)**:
|
||||
- Input fields and selects now have:
|
||||
- Rounded corners (--radius-sm)
|
||||
- Consistent padding (0.5rem 0.75rem)
|
||||
- Focus states with ring effect using box-shadow
|
||||
- Smooth transition animations
|
||||
- Checkboxes use accent-color for theming
|
||||
|
||||
### Technical Details
|
||||
- Uses system font stack ('Inter' preferred) for better native appearance
|
||||
- Subtle borders with CSS variables for consistency
|
||||
- Consistent spacing using rem units
|
||||
- Smooth transitions (0.2s ease) for interactive elements
|
||||
- Custom scrollbar styling for better visual integration
|
||||
- Grid layout with 1px gaps creating subtle dividers
|
||||
- Warning display with backdrop blur and rounded corners
|
||||
|
||||
## Advanced Complex Properties Editor (2025-06-27)
|
||||
|
||||
### Overview
|
||||
Implemented an advanced editor for complex properties (Arrays and Objects) that appears between the wcc-properties panel and frame when activated.
|
||||
|
||||
### Features
|
||||
1. **Dynamic Layout**: Frame shrinks by 300px from bottom when editor opens
|
||||
2. **Multiple Editors**: Can edit multiple properties simultaneously side by side
|
||||
3. **JSON Editor**:
|
||||
- Monospace font for code editing
|
||||
- Tab key support for indentation
|
||||
- Syntax validation with error messages
|
||||
- Live preview of changes
|
||||
4. **Smooth Transitions**: Animated opening/closing with 0.3s ease
|
||||
5. **Error Handling**: Invalid JSON shows clear error messages that disappear on typing
|
||||
6. **Close All Button**: Single button to close all open editors at once
|
||||
|
||||
### Technical Implementation (Updated)
|
||||
- **State Management**: Changed from single editor to array of editors with unique IDs
|
||||
- **Editor Structure**: Each editor instance contains:
|
||||
- `id`: Unique identifier (`propertyName-timestamp`)
|
||||
- `name`: Property name
|
||||
- `value`: Original value
|
||||
- `element`: Reference to the element
|
||||
- `editorValue`: Current JSON string
|
||||
- `editorError`: Validation error message
|
||||
- **Event System**: Uses custom 'editorStateChanged' event to communicate with parent dashboard
|
||||
- **Dynamic Styling**: wcc-frame's bottom position changes from 100px to 400px when any editor is open
|
||||
- **Property Types**: Object and Array properties show "Edit Object/Array" button instead of inline controls
|
||||
|
||||
### User Flow
|
||||
1. Click "Edit Object/Array" button on complex property
|
||||
2. Editor slides up between properties panel and frame
|
||||
3. Click additional "Edit" buttons to open more properties side by side
|
||||
4. Each editor can be saved/cancelled independently
|
||||
5. "Close All" button dismisses all editors at once
|
||||
6. Frame automatically resizes back when all editors are closed
|
||||
|
||||
### Layout Details
|
||||
- **Container**: Flexbox with horizontal scrolling when multiple editors overflow
|
||||
- **Editor Width**: Min 300px, max 500px, flexible between
|
||||
- **Scrollbar**: Custom styled thin scrollbar for horizontal overflow
|
||||
- **Header Bar**: Fixed top bar with "Property Editors" title and "Close All" button
|
||||
|
||||
## Properties Panel Element Detection Issue (Fixed)
|
||||
|
||||
### Problem
|
||||
|
||||
482
readme.md
482
readme.md
@@ -1,23 +1,32 @@
|
||||
# @design.estate/dees-wcctools
|
||||
Web Component Development Tools - A powerful framework for building, testing, and documenting web components
|
||||
|
||||
🛠️ **Web Component Development Tools** — A powerful framework for building, testing, documenting, and recording web components
|
||||
|
||||
## Overview
|
||||
|
||||
`@design.estate/dees-wcctools` provides a comprehensive development environment for web components, featuring:
|
||||
- 🎨 Interactive component catalogue with live preview
|
||||
- 🔧 Real-time property editing
|
||||
- 🌓 Theme switching (light/dark modes)
|
||||
- 📱 Responsive viewport testing
|
||||
- 🧪 Advanced demo tools for component testing
|
||||
- 🚀 Zero-config setup with TypeScript and Lit support
|
||||
|
||||
- 🎨 **Interactive Component Catalogue** — Live preview with customizable sidebar sections
|
||||
- 🔧 **Real-time Property Editing** — Modify component props on the fly with auto-detected editors
|
||||
- 🌓 **Theme Switching** — Test light/dark modes instantly
|
||||
- 📱 **Responsive Viewport Testing** — Phone, phablet, tablet, and desktop views
|
||||
- 🎬 **Screen Recording** — Record component demos with audio support and video trimming
|
||||
- 🧪 **Advanced Demo Tools** — Post-render hooks for interactive testing
|
||||
- 📂 **Section-based Organization** — Group components into custom sections with filtering and sorting
|
||||
- 🚀 **Zero-config Setup** — TypeScript and Lit support out of the box
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Using npm
|
||||
npm install @design.estate/dees-wcctools --save-dev
|
||||
|
||||
# Using pnpm (recommended)
|
||||
pnpm add -D @design.estate/dees-wcctools
|
||||
|
||||
# Using npm
|
||||
npm install @design.estate/dees-wcctools --save-dev
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -35,10 +44,10 @@ export class MyButton extends DeesElement {
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
public label: string = 'Button';
|
||||
accessor label: string = 'Button';
|
||||
|
||||
@property({ type: String })
|
||||
public variant: 'primary' | 'secondary' = 'primary';
|
||||
accessor variant: 'primary' | 'secondary' = 'primary';
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
@@ -49,29 +58,22 @@ export class MyButton extends DeesElement {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
button.primary {
|
||||
background: #007bff;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
button.secondary {
|
||||
background: #6c757d;
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<button class="${this.variant}">
|
||||
${this.label}
|
||||
</button>
|
||||
<button class="${this.variant}">${this.label}</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -85,33 +87,32 @@ import { setupWccTools } from '@design.estate/dees-wcctools';
|
||||
import { html } from 'lit';
|
||||
|
||||
// Import your components
|
||||
import './components/my-button.js';
|
||||
import './components/my-card.js';
|
||||
import * as elements from './components/index.js';
|
||||
import * as views from './views/index.js';
|
||||
import * as pages from './pages/index.js';
|
||||
|
||||
// Define elements for the catalogue
|
||||
const elements = {
|
||||
'my-button': MyButton,
|
||||
'my-card': MyCard,
|
||||
};
|
||||
|
||||
// Optionally define pages
|
||||
const pages = {
|
||||
'home': () => html`
|
||||
<div style="padding: 20px;">
|
||||
<h1>Welcome to My Component Library</h1>
|
||||
<p>Browse components using the sidebar.</p>
|
||||
</div>
|
||||
`,
|
||||
'getting-started': () => html`
|
||||
<div style="padding: 20px;">
|
||||
<h2>Getting Started</h2>
|
||||
<p>Installation and usage instructions...</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
// Initialize the catalogue
|
||||
setupWccTools(elements, pages);
|
||||
// Initialize with sections-based configuration
|
||||
setupWccTools({
|
||||
sections: [
|
||||
{
|
||||
name: 'Pages',
|
||||
type: 'pages',
|
||||
items: pages,
|
||||
},
|
||||
{
|
||||
name: 'Views',
|
||||
type: 'elements',
|
||||
items: views,
|
||||
icon: 'web',
|
||||
},
|
||||
{
|
||||
name: 'Elements',
|
||||
type: 'elements',
|
||||
items: elements,
|
||||
sort: ([a], [b]) => a.localeCompare(b),
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Create an HTML Entry Point
|
||||
@@ -129,91 +130,183 @@ setupWccTools(elements, pages);
|
||||
</html>
|
||||
```
|
||||
|
||||
## 📂 Sections Configuration
|
||||
|
||||
The sections-based API gives you full control over how components are organized in the sidebar.
|
||||
|
||||
### Section Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `name` | `string` | Display name for the section header |
|
||||
| `type` | `'elements' \| 'pages'` | How items render (`elements` show demos, `pages` render directly) |
|
||||
| `items` | `Record<string, any>` | Object containing element classes or page factories |
|
||||
| `filter` | `(name, item) => boolean` | Optional filter function to include/exclude items |
|
||||
| `sort` | `([a, itemA], [b, itemB]) => number` | Optional sort function for ordering items |
|
||||
| `icon` | `string` | Optional Material Symbols icon name |
|
||||
| `collapsed` | `boolean` | Start section collapsed (default: `false`) |
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```typescript
|
||||
import { setupWccTools } from '@design.estate/dees-wcctools';
|
||||
import * as allElements from './elements/index.js';
|
||||
import * as pages from './pages/index.js';
|
||||
|
||||
setupWccTools({
|
||||
sections: [
|
||||
{
|
||||
name: 'Pages',
|
||||
type: 'pages',
|
||||
items: pages,
|
||||
},
|
||||
{
|
||||
name: 'Form Controls',
|
||||
type: 'elements',
|
||||
items: allElements,
|
||||
icon: 'edit_note',
|
||||
filter: (name) => name.startsWith('form-') || name.includes('input'),
|
||||
sort: ([a], [b]) => a.localeCompare(b),
|
||||
},
|
||||
{
|
||||
name: 'Layout',
|
||||
type: 'elements',
|
||||
items: allElements,
|
||||
icon: 'dashboard',
|
||||
filter: (name) => name.startsWith('layout-') || name.startsWith('grid-'),
|
||||
},
|
||||
{
|
||||
name: 'Legacy',
|
||||
type: 'elements',
|
||||
items: allElements,
|
||||
filter: (name) => name.startsWith('legacy-'),
|
||||
collapsed: true, // Start collapsed
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Legacy API (Still Supported)
|
||||
|
||||
```typescript
|
||||
// The old format still works for simple use cases
|
||||
setupWccTools(elements, pages);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 🎯 Live Property Editing
|
||||
|
||||
The properties panel automatically detects and allows editing of:
|
||||
- **String** properties with text inputs
|
||||
- **Number** properties with number inputs
|
||||
- **Boolean** properties with checkboxes
|
||||
- **Enum** properties with select dropdowns
|
||||
- **Object** and **Array** properties (read-only display)
|
||||
|
||||
| Property Type | Editor |
|
||||
|--------------|--------|
|
||||
| **String** | Text input |
|
||||
| **Number** | Number input |
|
||||
| **Boolean** | Checkbox |
|
||||
| **Enum** | Select dropdown |
|
||||
| **Object/Array** | JSON editor modal |
|
||||
|
||||
### 📱 Viewport Testing
|
||||
|
||||
Test your components across different screen sizes:
|
||||
- **Phone** (320px width)
|
||||
- **Phablet** (600px width)
|
||||
- **Tablet** (768px width)
|
||||
- **Desktop** (full width)
|
||||
|
||||
- **Phone** — 320px width
|
||||
- **Phablet** — 600px width
|
||||
- **Tablet** — 768px width
|
||||
- **Desktop** — Full width (native)
|
||||
|
||||
### 🌓 Theme Support
|
||||
Components automatically adapt to light/dark themes using the `goBright` property:
|
||||
|
||||
```typescript
|
||||
public render() {
|
||||
return html`
|
||||
<div class="${this.goBright ? 'light-theme' : 'dark-theme'}">
|
||||
<!-- Your component content -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
```
|
||||
Components automatically adapt to light/dark themes. Use CSS custom properties with the theme manager:
|
||||
|
||||
Or use CSS custom properties:
|
||||
```typescript
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
background: ${cssManager.bdTheme('#fff', '#000')};
|
||||
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
|
||||
}
|
||||
`
|
||||
];
|
||||
```
|
||||
|
||||
### 🧪 Advanced Demo Tools
|
||||
### 🎬 Screen Recording
|
||||
|
||||
The demo tools provide enhanced testing capabilities:
|
||||
Record component demos directly from the catalogue:
|
||||
|
||||
- **Viewport Recording** — Record just the component viewport
|
||||
- **Full Screen Recording** — Capture the entire screen
|
||||
- **Audio Support** — Add microphone commentary with live level monitoring
|
||||
- **Video Trimming** — Trim start/end before export with visual timeline
|
||||
- **WebM Export** — High-quality video output
|
||||
|
||||
Click the red record button in the bottom toolbar to start.
|
||||
|
||||
### 🧪 Demo Tools
|
||||
|
||||
The demotools module provides enhanced testing capabilities with `dees-demowrapper`:
|
||||
|
||||
```typescript
|
||||
import * as demoTools from '@design.estate/dees-wcctools/demotools';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
@customElement('my-component')
|
||||
export class MyComponent extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${async (wrapper) => {
|
||||
// Use querySelector to find specific elements
|
||||
const myComponent = wrapper.querySelector('my-component') as MyComponent;
|
||||
console.log('Component found:', myComponent);
|
||||
|
||||
// Access all children via wrapper.children
|
||||
console.log('Total children:', wrapper.children.length);
|
||||
|
||||
// Use querySelectorAll for multiple elements
|
||||
const allDivs = wrapper.querySelectorAll('div');
|
||||
console.log('Found divs:', allDivs.length);
|
||||
|
||||
// Find elements using standard DOM APIs
|
||||
const myComponent = wrapper.querySelector('my-component');
|
||||
|
||||
// Simulate user interactions
|
||||
myComponent.value = 'Test value';
|
||||
await myComponent.updateComplete;
|
||||
|
||||
// Work with all children
|
||||
Array.from(wrapper.children).forEach((child, index) => {
|
||||
console.log(`Child ${index}:`, child.tagName);
|
||||
|
||||
// Work with multiple elements
|
||||
wrapper.querySelectorAll('.item').forEach((el, i) => {
|
||||
console.log(`Item ${i}:`, el.textContent);
|
||||
});
|
||||
}}>
|
||||
<my-component></my-component>
|
||||
<div>Additional content</div>
|
||||
<div class="item">Item 1</div>
|
||||
<div class="item">Item 2</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
### 🎭 Container Queries Support
|
||||
### 🎭 Multiple Demos
|
||||
|
||||
Components can respond to their container size:
|
||||
Components can expose multiple demo variations:
|
||||
|
||||
```typescript
|
||||
@customElement('my-button')
|
||||
export class MyButton extends DeesElement {
|
||||
public static demo = [
|
||||
() => html`<my-button variant="primary">Primary</my-button>`,
|
||||
() => html`<my-button variant="secondary">Secondary</my-button>`,
|
||||
() => html`<my-button variant="danger">Danger</my-button>`,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Each demo appears as a numbered item in an expandable folder in the sidebar.
|
||||
|
||||
### ⏳ Async Demos
|
||||
|
||||
Return a `Promise` from `demo` for async setup:
|
||||
|
||||
```typescript
|
||||
public static demo = async () => {
|
||||
const data = await fetchSomeData();
|
||||
return html`<my-component .data=${data}></my-component>`;
|
||||
};
|
||||
```
|
||||
|
||||
### 🎯 Container Queries
|
||||
|
||||
Components can respond to their container size using the `wccToolsViewport` container:
|
||||
|
||||
```typescript
|
||||
public static styles = [
|
||||
@@ -223,7 +316,7 @@ public static styles = [
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@container wccToolsViewport (max-width: 767px) {
|
||||
:host {
|
||||
flex-direction: column;
|
||||
@@ -236,157 +329,154 @@ public static styles = [
|
||||
## Component Guidelines
|
||||
|
||||
### Required for Catalogue Display
|
||||
1. Components must expose a static `demo` property returning a Lit template
|
||||
2. Use `@property()` decorators for properties you want to be editable
|
||||
|
||||
1. Components must expose a static `demo` property returning a Lit template (or array of templates)
|
||||
2. Use `@property()` decorators with the `accessor` keyword for editable properties
|
||||
3. Export component classes for proper detection
|
||||
|
||||
### Best Practices
|
||||
|
||||
```typescript
|
||||
@customElement('best-practice-component')
|
||||
export class BestPracticeComponent extends DeesElement {
|
||||
// ✅ Static demo property
|
||||
// ✅ Static demo property (single or array)
|
||||
public static demo = () => html`
|
||||
<best-practice-component
|
||||
<best-practice-component
|
||||
.complexProp=${{ key: 'value' }}
|
||||
simpleAttribute="test"
|
||||
></best-practice-component>
|
||||
`;
|
||||
|
||||
// ✅ Typed properties with defaults
|
||||
// ✅ Typed properties with defaults (TC39 decorators)
|
||||
@property({ type: String })
|
||||
public title: string = 'Default Title';
|
||||
accessor title: string = 'Default Title';
|
||||
|
||||
// ✅ Complex property without attribute
|
||||
@property({ attribute: false })
|
||||
public complexProp: { key: string } = { key: 'default' };
|
||||
accessor complexProp: { key: string } = { key: 'default' };
|
||||
|
||||
// ✅ Enum with proper typing
|
||||
@property({ type: String })
|
||||
public variant: 'small' | 'medium' | 'large' = 'medium';
|
||||
accessor variant: 'small' | 'medium' | 'large' = 'medium';
|
||||
}
|
||||
```
|
||||
|
||||
## URL Routing
|
||||
|
||||
The catalogue uses URL routing for deep linking:
|
||||
|
||||
```
|
||||
/wcctools-route/:type/:name/:viewport/:theme
|
||||
/wcctools-route/:sectionName/:itemName/:demoIndex/:viewport/:theme
|
||||
|
||||
Example:
|
||||
/wcctools-route/element/my-button/desktop/dark
|
||||
/wcctools-route/page/home/tablet/bright
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Build and Watch
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element",
|
||||
"watch": "tswatch element",
|
||||
"serve": "http-server ./dist"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
my-components/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── my-button.ts
|
||||
│ │ └── my-card.ts
|
||||
│ └── catalogue.ts
|
||||
├── dist/
|
||||
├── index.html
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Property Handlers
|
||||
For complex property types, implement custom logic in your demo:
|
||||
|
||||
```typescript
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${(wrapper) => {
|
||||
// Use querySelector to target specific elements
|
||||
const component = wrapper.querySelector('my-component');
|
||||
if (component) {
|
||||
component.addEventListener('property-change', (e) => {
|
||||
console.log('Property changed:', e.detail);
|
||||
});
|
||||
}
|
||||
|
||||
// Or handle all elements of a type
|
||||
wrapper.querySelectorAll('my-component').forEach(el => {
|
||||
el.addEventListener('click', () => console.log('Clicked!'));
|
||||
});
|
||||
}}>
|
||||
<my-component></my-component>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
```
|
||||
|
||||
### Responsive Testing Helpers
|
||||
```typescript
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
|
||||
public static styles = [
|
||||
// Media query helpers
|
||||
domtools.breakpoints.cssForPhone(css`
|
||||
:host { font-size: 14px; }
|
||||
`),
|
||||
|
||||
domtools.breakpoints.cssForTablet(css`
|
||||
:host { font-size: 16px; }
|
||||
`),
|
||||
|
||||
domtools.breakpoints.cssForDesktop(css`
|
||||
:host { font-size: 18px; }
|
||||
`)
|
||||
];
|
||||
Examples:
|
||||
/wcctools-route/Elements/my-button/0/desktop/dark
|
||||
/wcctools-route/Views/view-dashboard/0/tablet/bright
|
||||
/wcctools-route/Pages/home/0/desktop/dark
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### setupWccTools(elements, pages?)
|
||||
Initialize the WCC Tools dashboard.
|
||||
### `setupWccTools(config)`
|
||||
|
||||
- `elements`: Object mapping element names to element classes
|
||||
- `pages`: Optional object mapping page names to template functions
|
||||
Initialize the WCC Tools dashboard with sections configuration.
|
||||
|
||||
```typescript
|
||||
interface IWccSection {
|
||||
name: string;
|
||||
type: 'elements' | 'pages';
|
||||
items: Record<string, any>;
|
||||
filter?: (name: string, item: any) => boolean;
|
||||
sort?: (a: [string, any], b: [string, any]) => number;
|
||||
icon?: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
interface IWccConfig {
|
||||
sections: IWccSection[];
|
||||
}
|
||||
|
||||
setupWccTools(config: IWccConfig): void;
|
||||
|
||||
// Legacy (still supported)
|
||||
setupWccTools(elements: Record<string, any>, pages?: Record<string, TTemplateFactory>): void;
|
||||
```
|
||||
|
||||
### `DeesDemoWrapper`
|
||||
|
||||
### DeesDemoWrapper
|
||||
Component for wrapping demos with post-render logic.
|
||||
|
||||
- `runAfterRender`: Function called after the wrapped elements render
|
||||
- Receives the wrapper element itself, providing full DOM API access
|
||||
- Use `wrapper.querySelector()` and `wrapper.querySelectorAll()` for element selection
|
||||
- Access children via `wrapper.children` property
|
||||
- Supports async operations
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `runAfterRender` | `(wrapper) => void \| Promise<void>` | Callback after wrapped elements render |
|
||||
|
||||
The wrapper provides full DOM API access:
|
||||
- `wrapper.querySelector()` — Find single element
|
||||
- `wrapper.querySelectorAll()` — Find multiple elements
|
||||
- `wrapper.children` — Access child elements directly
|
||||
|
||||
### Recording Components (Advanced)
|
||||
|
||||
For custom recording integrations:
|
||||
|
||||
```typescript
|
||||
import { RecorderService } from '@design.estate/dees-wcctools';
|
||||
|
||||
const recorder = new RecorderService({
|
||||
onDurationUpdate: (duration) => console.log(`${duration}s`),
|
||||
onRecordingComplete: (blob) => console.log('Recording done!', blob),
|
||||
onAudioLevelUpdate: (level) => console.log(`Audio: ${level}%`),
|
||||
});
|
||||
|
||||
await recorder.startRecording({ mode: 'viewport' });
|
||||
// ... later
|
||||
recorder.stopRecording();
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
my-component-library/
|
||||
├── src/
|
||||
│ ├── elements/ # UI components
|
||||
│ │ ├── my-button.ts
|
||||
│ │ ├── my-card.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── views/ # Full-page layouts
|
||||
│ │ ├── view-dashboard.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── pages/ # Documentation pages
|
||||
│ │ ├── home.ts
|
||||
│ │ └── index.ts
|
||||
│ └── catalogue.ts # WCC Tools setup
|
||||
├── html/
|
||||
│ └── index.html
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
- Chrome/Edge (latest)
|
||||
- Firefox (latest)
|
||||
- Safari (latest)
|
||||
- Mobile browsers with Web Components support
|
||||
|
||||
- ✅ Chrome/Edge (latest)
|
||||
- ✅ Firefox (latest)
|
||||
- ✅ Safari (latest)
|
||||
- ✅ Mobile browsers with Web Components support
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
@@ -97,4 +97,11 @@ Properties panel was overwriting values set by demo functions
|
||||
2. This prevents browser from firing input events during initialization
|
||||
3. Added proper number parsing for number inputs
|
||||
4. Increased initial wait to 200ms for demo wrappers to complete
|
||||
5. Simplified select element handling to use property binding
|
||||
5. Simplified select element handling to use property binding
|
||||
# Async Demo Support (IN PROGRESS)
|
||||
|
||||
## Tasks
|
||||
- [ ] Allow dashboard-selected items to return Promise-based TemplateResults
|
||||
- [ ] Await async demos/pages before rendering them into the viewport
|
||||
- [ ] Add regression test covering async demo usage
|
||||
- [ ] Document async demo pattern in README and verify with pnpm scripts
|
||||
|
||||
@@ -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 demoGroup 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 demoGroup = '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 demoGroup = '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 demoGroup = '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>`;
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,13 @@ export class TestComplexTypes extends DeesElement {
|
||||
`;
|
||||
|
||||
@property({ type: Array })
|
||||
public stringArray: string[] = ['apple', 'banana', 'cherry'];
|
||||
accessor stringArray: string[] = ['apple', 'banana', 'cherry'];
|
||||
|
||||
@property({ type: Array })
|
||||
public numberArray: number[] = [1, 2, 3, 4, 5];
|
||||
accessor numberArray: number[] = [1, 2, 3, 4, 5];
|
||||
|
||||
@property({ attribute: false })
|
||||
public complexData: IComplexData = {
|
||||
accessor complexData: IComplexData = {
|
||||
name: 'Default Name',
|
||||
age: 0,
|
||||
tags: [],
|
||||
@@ -54,19 +54,19 @@ export class TestComplexTypes extends DeesElement {
|
||||
};
|
||||
|
||||
@property({ type: Object })
|
||||
public simpleObject = {
|
||||
accessor simpleObject = {
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
key3: 123
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
public functionProperty = () => {
|
||||
accessor functionProperty = () => {
|
||||
console.log('This is a function property');
|
||||
};
|
||||
|
||||
@property({ type: Date })
|
||||
public dateProperty = new Date();
|
||||
accessor dateProperty = new Date();
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
|
||||
@@ -18,38 +18,42 @@ enum ETestEnum {
|
||||
|
||||
@customElement('test-demoelement')
|
||||
export class TestDemoelement extends DeesElement {
|
||||
public static demo = () => html`<test-demoelement>This is a slot text</test-demoelement>`;
|
||||
public static demo = [
|
||||
() => html`<test-demoelement>This is demo 1</test-demoelement>`,
|
||||
() => html`<test-demoelement>This is demo 2</test-demoelement>`,
|
||||
() => html`<test-demoelement>This is demo 2</test-demoelement>`,
|
||||
]
|
||||
|
||||
@property()
|
||||
public notTyped = 'hello';
|
||||
accessor notTyped = 'hello';
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public typedAndNotInitizalized: string;
|
||||
accessor typedAndNotInitizalized: string;
|
||||
|
||||
@property()
|
||||
public notTypedAndNotInitizalized: string;
|
||||
accessor notTypedAndNotInitizalized: string;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
public demoBoolean = false;
|
||||
accessor demoBoolean = false;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public demoString = 'default demo string';
|
||||
accessor demoString = 'default demo string';
|
||||
|
||||
@property({
|
||||
type: Number,
|
||||
})
|
||||
public demoNumber = 2;
|
||||
accessor demoNumber = 2;
|
||||
|
||||
@property({
|
||||
type: ETestEnum,
|
||||
})
|
||||
public demoENum: ETestEnum = ETestEnum.first;
|
||||
accessor demoENum: ETestEnum = ETestEnum.first;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -13,60 +13,60 @@ export class TestEdgeCases extends DeesElement {
|
||||
|
||||
// Property with null value
|
||||
@property({ type: String })
|
||||
public nullableString: string | null = null;
|
||||
accessor nullableString: string | null = null;
|
||||
|
||||
// Property with undefined value
|
||||
@property({ type: Number })
|
||||
public undefinedNumber: number | undefined = undefined;
|
||||
accessor undefinedNumber: number | undefined = undefined;
|
||||
|
||||
// Very long string
|
||||
@property({ type: String })
|
||||
public longString: string = 'Lorem ipsum '.repeat(50);
|
||||
accessor longString: string = 'Lorem ipsum '.repeat(50);
|
||||
|
||||
// Property with special characters
|
||||
@property({ type: String })
|
||||
public specialChars: string = '!@#$%^&*()_+-=[]{}|;\':",./<>?`~';
|
||||
accessor specialChars: string = '!@#$%^&*()_+-=[]{}|;\':",./<>?`~';
|
||||
|
||||
// Property that could cause rendering issues
|
||||
@property({ type: String })
|
||||
public htmlString: string = '<script>alert("test")</script><b>Bold text</b>';
|
||||
accessor htmlString: string = '<script>alert("test")</script><b>Bold text</b>';
|
||||
|
||||
// Numeric edge cases
|
||||
@property({ type: Number })
|
||||
public infinityNumber: number = Infinity;
|
||||
accessor infinityNumber: number = Infinity;
|
||||
|
||||
@property({ type: Number })
|
||||
public nanNumber: number = NaN;
|
||||
accessor nanNumber: number = NaN;
|
||||
|
||||
@property({ type: Number })
|
||||
public veryLargeNumber: number = Number.MAX_SAFE_INTEGER;
|
||||
accessor veryLargeNumber: number = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@property({ type: Number })
|
||||
public verySmallNumber: number = Number.MIN_SAFE_INTEGER;
|
||||
accessor verySmallNumber: number = Number.MIN_SAFE_INTEGER;
|
||||
|
||||
@property({ type: Number })
|
||||
public floatNumber: number = 3.14159265359;
|
||||
accessor floatNumber: number = 3.14159265359;
|
||||
|
||||
// Boolean-like values
|
||||
@property({ type: String })
|
||||
public booleanString: string = 'false';
|
||||
accessor booleanString: string = 'false';
|
||||
|
||||
@property({ type: Number })
|
||||
public booleanNumber: number = 0;
|
||||
accessor booleanNumber: number = 0;
|
||||
|
||||
// Empty values
|
||||
@property({ type: String })
|
||||
public emptyString: string = '';
|
||||
accessor emptyString: string = '';
|
||||
|
||||
@property({ type: Array })
|
||||
public emptyArray: any[] = [];
|
||||
accessor emptyArray: any[] = [];
|
||||
|
||||
@property({ type: Object })
|
||||
public emptyObject: {} = {};
|
||||
accessor emptyObject: {} = {};
|
||||
|
||||
// Circular reference (should not break properties panel)
|
||||
@property({ attribute: false })
|
||||
public circularRef: any = (() => {
|
||||
accessor circularRef: any = (() => {
|
||||
const obj: any = { name: 'circular' };
|
||||
obj.self = obj;
|
||||
return obj;
|
||||
|
||||
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 demoGroup = 'Inputs';
|
||||
|
||||
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 demoGroup = '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}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,13 @@ class TestNestedWrapper extends DeesElement {
|
||||
@customElement('test-nested-target')
|
||||
class TestNestedTarget extends DeesElement {
|
||||
@property({ type: String })
|
||||
public message: string = 'I am deeply nested!';
|
||||
accessor message: string = 'I am deeply nested!';
|
||||
|
||||
@property({ type: Number })
|
||||
public depth: number = 0;
|
||||
accessor depth: number = 0;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public found: boolean = false;
|
||||
accessor found: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
@@ -67,7 +67,7 @@ export class TestNested extends DeesElement {
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
public testId: string = 'nested-test';
|
||||
accessor testId: string = 'nested-test';
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
|
||||
@@ -39,13 +39,13 @@ export class TestWithWrapper extends DeesElement {
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
public dynamicValue: string = 'Initial value';
|
||||
accessor dynamicValue: string = 'Initial value';
|
||||
|
||||
@property({ type: Number })
|
||||
public counter: number = 0;
|
||||
accessor counter: number = 0;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public isActive: boolean = false;
|
||||
accessor isActive: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
|
||||
22
test/test.demoresolver.node.ts
Normal file
22
test/test.demoresolver.node.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { resolveTemplateFactory } from '../ts_web/elements/wcctools.helpers.js';
|
||||
import { html } from 'lit';
|
||||
|
||||
const waitFor = (durationMs: number) => new Promise(resolve => setTimeout(resolve, durationMs));
|
||||
|
||||
tap.test('resolveTemplateFactory returns sync TemplateResult', async () => {
|
||||
const template = html`<p>sync demo</p>`;
|
||||
const resolvedTemplate = await resolveTemplateFactory(() => template);
|
||||
expect(resolvedTemplate).toEqual(template);
|
||||
});
|
||||
|
||||
tap.test('resolveTemplateFactory awaits async TemplateResult', async () => {
|
||||
const template = html`<p>async demo</p>`;
|
||||
const resolvedTemplate = await resolveTemplateFactory(async () => {
|
||||
await waitFor(5);
|
||||
return template;
|
||||
});
|
||||
expect(resolvedTemplate).toEqual(template);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
3
test/views/index.ts
Normal file
3
test/views/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './view-dashboard.js';
|
||||
export * from './view-settings.js';
|
||||
export * from './view-empty-state.js';
|
||||
286
test/views/view-dashboard.ts
Normal file
286
test/views/view-dashboard.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { DeesElement, customElement, html, css, property, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
@customElement('view-dashboard')
|
||||
export class ViewDashboard extends DeesElement {
|
||||
public static demo = () => html`<view-dashboard></view-dashboard>`;
|
||||
|
||||
@property()
|
||||
accessor title: string = 'Dashboard';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor notificationCount: number = 3;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
|
||||
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
grid-template-rows: 60px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-column: 1 / -1;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notification-badge::after {
|
||||
content: attr(data-count);
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: ${cssManager.bdTheme('#fff', '#0f0f0f')};
|
||||
border-right: 1px solid ${cssManager.bdTheme('#e5e5e5', '#1a1a1a')};
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
color: ${cssManager.bdTheme('#1a1a1a', '#fff')};
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.15)')};
|
||||
color: #3b82f6;
|
||||
border-right: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.content-header p {
|
||||
color: ${cssManager.bdTheme('#666', '#888')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${cssManager.bdTheme('#666', '#888')};
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-card .change {
|
||||
font-size: 12px;
|
||||
color: #22c55e;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-card .change.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.recent-activity {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.recent-activity h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-content .title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.activity-content .time {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#666')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div class="dashboard">
|
||||
<header class="header">
|
||||
<h1>${this.title}</h1>
|
||||
<div class="header-actions">
|
||||
<div class="notification-badge" data-count="${this.notificationCount}">
|
||||
<span>Notifications</span>
|
||||
</div>
|
||||
<span>User</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="nav-item active">Overview</div>
|
||||
<div class="nav-item">Analytics</div>
|
||||
<div class="nav-item">Projects</div>
|
||||
<div class="nav-item">Team</div>
|
||||
<div class="nav-item">Settings</div>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<div class="content-header">
|
||||
<h2>Overview</h2>
|
||||
<p>Welcome back! Here's what's happening with your projects.</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Revenue</div>
|
||||
<div class="value">$45,231</div>
|
||||
<div class="change">+20.1% from last month</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Active Users</div>
|
||||
<div class="value">2,350</div>
|
||||
<div class="change">+180 new users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Pending Tasks</div>
|
||||
<div class="value">12</div>
|
||||
<div class="change negative">-3 from yesterday</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Completion Rate</div>
|
||||
<div class="value">94.2%</div>
|
||||
<div class="change">+2.4% this week</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-activity">
|
||||
<h3>Recent Activity</h3>
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">+</div>
|
||||
<div class="activity-content">
|
||||
<div class="title">New project created</div>
|
||||
<div class="time">2 minutes ago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">U</div>
|
||||
<div class="activity-content">
|
||||
<div class="title">User settings updated</div>
|
||||
<div class="time">1 hour ago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">D</div>
|
||||
<div class="activity-content">
|
||||
<div class="title">Deployment completed</div>
|
||||
<div class="time">3 hours ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
262
test/views/view-empty-state.ts
Normal file
262
test/views/view-empty-state.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { DeesElement, customElement, html, css, property, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
@customElement('view-empty-state')
|
||||
export class ViewEmptyState extends DeesElement {
|
||||
public static demo = [
|
||||
() => html`<view-empty-state></view-empty-state>`,
|
||||
() => html`<view-empty-state variant="no-results"></view-empty-state>`,
|
||||
() => html`<view-empty-state variant="error"></view-empty-state>`,
|
||||
];
|
||||
|
||||
@property()
|
||||
accessor variant: 'empty' | 'no-results' | 'error' = 'empty';
|
||||
|
||||
@property()
|
||||
accessor title: string = '';
|
||||
|
||||
@property()
|
||||
accessor description: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
|
||||
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 24px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#1a1a1a')};
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon.error {
|
||||
color: #ef4444;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: ${cssManager.bdTheme('#666', '#888')};
|
||||
margin: 0 0 24px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#1a1a1a')};
|
||||
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: ${cssManager.bdTheme('#ddd', '#222')};
|
||||
}
|
||||
|
||||
.illustration {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.illustration svg {
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folder-back {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
background: ${cssManager.bdTheme('#ddd', '#333')};
|
||||
border-radius: 4px 4px 8px 8px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.folder-front {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 50px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#444')};
|
||||
border-radius: 0 4px 8px 8px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.folder-tab {
|
||||
position: absolute;
|
||||
width: 30px;
|
||||
height: 12px;
|
||||
background: ${cssManager.bdTheme('#ddd', '#333')};
|
||||
border-radius: 4px 4px 0 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 4px solid ${cssManager.bdTheme('#ccc', '#444')};
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.search-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: ${cssManager.bdTheme('#ccc', '#444')};
|
||||
border-radius: 2px;
|
||||
bottom: -18px;
|
||||
right: -8px;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 4px solid #ef4444;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin: 0 auto 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-icon::before {
|
||||
content: '!';
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private getContent() {
|
||||
switch (this.variant) {
|
||||
case 'no-results':
|
||||
return {
|
||||
title: this.title || 'No results found',
|
||||
description:
|
||||
this.description ||
|
||||
"We couldn't find what you're looking for. Try adjusting your search or filters.",
|
||||
icon: 'search',
|
||||
primaryAction: 'Clear Filters',
|
||||
secondaryAction: 'Go Back',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
title: this.title || 'Something went wrong',
|
||||
description:
|
||||
this.description ||
|
||||
"We're having trouble loading this page. Please try again or contact support if the problem persists.",
|
||||
icon: 'error',
|
||||
primaryAction: 'Try Again',
|
||||
secondaryAction: 'Contact Support',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: this.title || 'No items yet',
|
||||
description:
|
||||
this.description ||
|
||||
"Get started by creating your first item. It only takes a few seconds.",
|
||||
icon: 'folder',
|
||||
primaryAction: 'Create New',
|
||||
secondaryAction: 'Learn More',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const content = this.getContent();
|
||||
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
${this.renderIcon(content.icon)}
|
||||
<h2>${content.title}</h2>
|
||||
<p>${content.description}</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">${content.primaryAction}</button>
|
||||
<button class="btn btn-secondary">${content.secondaryAction}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'search':
|
||||
return html`<div class="search-icon"></div>`;
|
||||
case 'error':
|
||||
return html`<div class="error-icon"></div>`;
|
||||
default:
|
||||
return html`
|
||||
<div class="folder-icon">
|
||||
<div class="folder-tab"></div>
|
||||
<div class="folder-back"></div>
|
||||
<div class="folder-front"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
436
test/views/view-settings.ts
Normal file
436
test/views/view-settings.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { DeesElement, customElement, html, css, property, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
@customElement('view-settings')
|
||||
export class ViewSettings extends DeesElement {
|
||||
public static demo = [
|
||||
() => html`<view-settings></view-settings>`,
|
||||
() => html`<view-settings activeTab="notifications"></view-settings>`,
|
||||
() => html`<view-settings activeTab="security"></view-settings>`,
|
||||
];
|
||||
|
||||
@property()
|
||||
accessor activeTab: 'profile' | 'notifications' | 'security' = 'profile';
|
||||
|
||||
@property()
|
||||
accessor userName: string = 'John Doe';
|
||||
|
||||
@property()
|
||||
accessor userEmail: string = 'john@example.com';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
|
||||
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
color: ${cssManager.bdTheme('#666', '#888')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#1a1a1a')};
|
||||
color: ${cssManager.bdTheme('#1a1a1a', '#fff')};
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: ${cssManager.bdTheme('#1a1a1a', '#3b82f6')};
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#888')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.toggle-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toggle-label .title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-label .description {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#888')};
|
||||
}
|
||||
|
||||
.toggle {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: ${cssManager.bdTheme('#ddd', '#333')};
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle.active {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle.active::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.security-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#f9f9f9', '#0a0a0a')};
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.security-item .info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.security-item .title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.security-item .status {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#888')};
|
||||
}
|
||||
|
||||
.security-item .status.enabled {
|
||||
color: #22c55e;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private handleTabClick(tab: 'profile' | 'notifications' | 'security') {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div class="settings-layout">
|
||||
<div class="settings-header">
|
||||
<h1>Settings</h1>
|
||||
<p>Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<nav class="settings-nav">
|
||||
<div
|
||||
class="nav-item ${this.activeTab === 'profile' ? 'active' : ''}"
|
||||
@click=${() => this.handleTabClick('profile')}
|
||||
>
|
||||
Profile
|
||||
</div>
|
||||
<div
|
||||
class="nav-item ${this.activeTab === 'notifications' ? 'active' : ''}"
|
||||
@click=${() => this.handleTabClick('notifications')}
|
||||
>
|
||||
Notifications
|
||||
</div>
|
||||
<div
|
||||
class="nav-item ${this.activeTab === 'security' ? 'active' : ''}"
|
||||
@click=${() => this.handleTabClick('security')}
|
||||
>
|
||||
Security
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="settings-panel">
|
||||
${this.activeTab === 'profile' ? this.renderProfile() : null}
|
||||
${this.activeTab === 'notifications' ? this.renderNotifications() : null}
|
||||
${this.activeTab === 'security' ? this.renderSecurity() : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProfile() {
|
||||
return html`
|
||||
<div class="panel-header">
|
||||
<h2>Profile Information</h2>
|
||||
<p>Update your personal details and email address</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>First Name</label>
|
||||
<input type="text" value="John" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Last Name</label>
|
||||
<input type="text" value="Doe" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email Address</label>
|
||||
<input type="email" value="${this.userEmail}" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Bio</label>
|
||||
<input type="text" placeholder="Tell us about yourself..." />
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn btn-primary">Save Changes</button>
|
||||
<button class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNotifications() {
|
||||
return html`
|
||||
<div class="panel-header">
|
||||
<h2>Notification Preferences</h2>
|
||||
<p>Choose what notifications you want to receive</p>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<div class="toggle-label">
|
||||
<span class="title">Email Notifications</span>
|
||||
<span class="description">Receive email updates about your account activity</span>
|
||||
</div>
|
||||
<div class="toggle active"></div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<div class="toggle-label">
|
||||
<span class="title">Push Notifications</span>
|
||||
<span class="description">Receive push notifications on your device</span>
|
||||
</div>
|
||||
<div class="toggle active"></div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<div class="toggle-label">
|
||||
<span class="title">Weekly Digest</span>
|
||||
<span class="description">Get a weekly summary of your activity</span>
|
||||
</div>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<div class="toggle-label">
|
||||
<span class="title">Marketing Emails</span>
|
||||
<span class="description">Receive tips, updates, and promotions</span>
|
||||
</div>
|
||||
<div class="toggle"></div>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn btn-primary">Save Preferences</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSecurity() {
|
||||
return html`
|
||||
<div class="panel-header">
|
||||
<h2>Security Settings</h2>
|
||||
<p>Manage your password and security options</p>
|
||||
</div>
|
||||
|
||||
<div class="security-item">
|
||||
<div class="info">
|
||||
<span class="title">Password</span>
|
||||
<span class="status">Last changed 30 days ago</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary">Change Password</button>
|
||||
</div>
|
||||
|
||||
<div class="security-item">
|
||||
<div class="info">
|
||||
<span class="title">Two-Factor Authentication</span>
|
||||
<span class="status enabled">Enabled</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary">Manage</button>
|
||||
</div>
|
||||
|
||||
<div class="security-item">
|
||||
<div class="info">
|
||||
<span class="title">Active Sessions</span>
|
||||
<span class="status">3 devices</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary">View All</button>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn btn-danger">Delete Account</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { DeesElement, customElement, html, css, property, type TemplateResult }
|
||||
@customElement('dees-demowrapper')
|
||||
export class DeesDemoWrapper extends DeesElement {
|
||||
@property({ attribute: false })
|
||||
public runAfterRender: (wrapperElement: DeesDemoWrapper) => void | Promise<void>;
|
||||
accessor runAfterRender: (wrapperElement: DeesDemoWrapper) => void | Promise<void>;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
|
||||
147
ts_demotools/readme.md
Normal file
147
ts_demotools/readme.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# @design.estate/dees-wcctools/demotools
|
||||
|
||||
🧪 **Demo Wrapper Utilities** — Enhanced testing tools for web component demos
|
||||
|
||||
## Overview
|
||||
|
||||
The demotools module provides `dees-demowrapper`, a utility component for executing post-render logic in component demos. Perfect for simulating user interactions, setting up test data, or validating component state.
|
||||
|
||||
## Installation
|
||||
|
||||
This module is included with `@design.estate/dees-wcctools`:
|
||||
|
||||
```bash
|
||||
pnpm add -D @design.estate/dees-wcctools
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Import the demotools subpath:
|
||||
|
||||
```typescript
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
```
|
||||
|
||||
## DeesDemoWrapper
|
||||
|
||||
A wrapper component that executes a callback after its slotted content renders.
|
||||
|
||||
### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `runAfterRender` | `(wrapper: DeesDemoWrapper) => void \| Promise<void>` | Callback executed after content renders |
|
||||
|
||||
### Example: Basic Usage
|
||||
|
||||
```typescript
|
||||
import { html } from 'lit';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${(wrapper) => {
|
||||
const button = wrapper.querySelector('my-button');
|
||||
console.log('Button found:', button);
|
||||
}}>
|
||||
<my-button>Click Me</my-button>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
```
|
||||
|
||||
### Example: Async Operations
|
||||
|
||||
```typescript
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${async (wrapper) => {
|
||||
const form = wrapper.querySelector('my-form');
|
||||
|
||||
// Wait for component initialization
|
||||
await form.updateComplete;
|
||||
|
||||
// Simulate user input
|
||||
form.values = { name: 'Test User', email: 'test@example.com' };
|
||||
|
||||
// Trigger validation
|
||||
await form.validate();
|
||||
|
||||
console.log('Form state:', form.isValid);
|
||||
}}>
|
||||
<my-form></my-form>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
```
|
||||
|
||||
### Example: Multiple Elements
|
||||
|
||||
```typescript
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${(wrapper) => {
|
||||
// Find all cards
|
||||
const cards = wrapper.querySelectorAll('my-card');
|
||||
console.log(`Found ${cards.length} cards`);
|
||||
|
||||
// Access by index
|
||||
Array.from(wrapper.children).forEach((child, i) => {
|
||||
console.log(`Child ${i}:`, child.tagName);
|
||||
});
|
||||
|
||||
// Add event listeners
|
||||
wrapper.querySelectorAll('button').forEach(btn => {
|
||||
btn.addEventListener('click', () => console.log('Clicked!'));
|
||||
});
|
||||
}}>
|
||||
<my-card title="Card 1"></my-card>
|
||||
<my-card title="Card 2"></my-card>
|
||||
<my-card title="Card 3"></my-card>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
```
|
||||
|
||||
### Example: Component State Manipulation
|
||||
|
||||
```typescript
|
||||
public static demo = () => html`
|
||||
<dees-demowrapper .runAfterRender=${async (wrapper) => {
|
||||
const tabs = wrapper.querySelector('my-tabs');
|
||||
|
||||
// Programmatically switch tabs
|
||||
tabs.activeTab = 'settings';
|
||||
await tabs.updateComplete;
|
||||
|
||||
// Verify content updated
|
||||
const content = tabs.shadowRoot.querySelector('.tab-content');
|
||||
console.log('Active content:', content.textContent);
|
||||
}}>
|
||||
<my-tabs>
|
||||
<div slot="home">Home Content</div>
|
||||
<div slot="settings">Settings Content</div>
|
||||
</my-tabs>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The wrapper renders its slot content immediately
|
||||
2. After a brief delay (50ms) to allow slotted content to initialize
|
||||
3. The `runAfterRender` callback is invoked with the wrapper element
|
||||
4. You have full DOM API access to query and manipulate children
|
||||
|
||||
## Key Features
|
||||
|
||||
- 📦 **Light DOM Access** — Slotted elements remain accessible via standard DOM APIs
|
||||
- ⏱️ **Async Support** — Return a Promise for async operations
|
||||
- 🎯 **Full DOM API** — Use `querySelector`, `querySelectorAll`, `children`, etc.
|
||||
- 🛡️ **Error Handling** — Errors in callbacks are caught and logged
|
||||
|
||||
## CSS Behavior
|
||||
|
||||
The wrapper uses `display: contents` so it doesn't affect layout:
|
||||
|
||||
```css
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
```
|
||||
|
||||
This means the wrapper is "invisible" in the layout — its children render as if they were direct children of the wrapper's parent.
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-wcctools',
|
||||
version: '1.0.101',
|
||||
version: '3.6.1',
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, queryAsync, render, domtools } from '@design.estate/dees-element';
|
||||
import { resolveTemplateFactory, getDemoAtIndex, getDemoCount, hasMultipleDemos } from './wcctools.helpers.js';
|
||||
import type { TTemplateFactory } from './wcctools.helpers.js';
|
||||
import type { IWccConfig, IWccSection, TElementType } from '../wcctools.interfaces.js';
|
||||
|
||||
import * as plugins from '../wcctools.plugins.js';
|
||||
|
||||
@@ -7,58 +10,108 @@ import './wcc-frame.js';
|
||||
import './wcc-sidebar.js';
|
||||
import './wcc-properties.js';
|
||||
import { type TTheme } from './wcc-properties.js';
|
||||
import { type TElementType } from './wcc-sidebar.js';
|
||||
import { breakpoints } from '@design.estate/dees-domtools';
|
||||
import { WccFrame } from './wcc-frame.js';
|
||||
|
||||
/**
|
||||
* Get filtered and sorted items from a section
|
||||
*/
|
||||
export const getSectionItems = (section: IWccSection): Array<[string, any]> => {
|
||||
let entries = Object.entries(section.items);
|
||||
|
||||
// Apply filter if provided
|
||||
if (section.filter) {
|
||||
entries = entries.filter(([name, item]) => section.filter(name, item));
|
||||
}
|
||||
|
||||
// Apply sort if provided
|
||||
if (section.sort) {
|
||||
entries.sort(section.sort);
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
@customElement('wcc-dashboard')
|
||||
export class WccDashboard extends DeesElement {
|
||||
|
||||
@property()
|
||||
public selectedType: TElementType;
|
||||
accessor sections: IWccSection[] = [];
|
||||
|
||||
@property()
|
||||
public selectedItemName: string;
|
||||
accessor selectedSection: IWccSection | null = null;
|
||||
|
||||
@property()
|
||||
public selectedItem: (() => TemplateResult) | DeesElement;
|
||||
accessor selectedType: TElementType;
|
||||
|
||||
@property()
|
||||
public selectedViewport: plugins.deesDomtools.breakpoints.TViewport = 'desktop';
|
||||
accessor selectedItemName: string;
|
||||
|
||||
@property()
|
||||
public selectedTheme: TTheme = 'dark';
|
||||
accessor selectedItem: TTemplateFactory | DeesElement;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor selectedDemoIndex: number = 0;
|
||||
|
||||
@property()
|
||||
public pages: { [key: string]: () => TemplateResult } = {};
|
||||
accessor selectedViewport: plugins.deesDomtools.breakpoints.TViewport = 'desktop';
|
||||
|
||||
@property()
|
||||
public elements: { [key: string]: DeesElement } = {};
|
||||
accessor selectedTheme: TTheme = 'dark';
|
||||
|
||||
@property()
|
||||
public warning: string = null;
|
||||
|
||||
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;
|
||||
|
||||
private frameScrollY: number = 0;
|
||||
private sidebarScrollY: number = 0;
|
||||
private scrollPositionsApplied: boolean = false;
|
||||
|
||||
|
||||
@queryAsync('wcc-frame')
|
||||
public wccFrame: Promise<WccFrame>;
|
||||
accessor wccFrame: Promise<WccFrame>;
|
||||
|
||||
constructor(
|
||||
elementsArg?: { [key: string]: DeesElement },
|
||||
pagesArg?: { [key: string]: () => TemplateResult }
|
||||
) {
|
||||
constructor(config?: IWccConfig) {
|
||||
super();
|
||||
if (elementsArg) {
|
||||
this.elements = elementsArg;
|
||||
console.log('got elements:');
|
||||
console.log(this.elements);
|
||||
if (config && config.sections) {
|
||||
this.sections = config.sections;
|
||||
console.log('got sections:', this.sections.map(s => s.name));
|
||||
}
|
||||
}
|
||||
|
||||
if (pagesArg) {
|
||||
this.pages = pagesArg;
|
||||
/**
|
||||
* Find an item by name across all sections, returns the item and its section
|
||||
*/
|
||||
public findItemByName(name: string): { item: any; section: IWccSection } | null {
|
||||
for (const section of this.sections) {
|
||||
const entries = getSectionItems(section);
|
||||
const found = entries.find(([itemName]) => itemName === name);
|
||||
if (found) {
|
||||
return { item: found[1], section };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a section by name (URL-decoded)
|
||||
*/
|
||||
public findSectionByName(name: string): IWccSection | null {
|
||||
return this.sections.find(s => s.name === name) || null;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
@@ -76,6 +129,10 @@ export class WccDashboard extends DeesElement {
|
||||
<wcc-sidebar
|
||||
.dashboardRef=${this}
|
||||
.selectedItem=${this.selectedItem}
|
||||
.searchQuery=${this.searchQuery}
|
||||
.pinnedItems=${this.pinnedItems}
|
||||
.sidebarWidth=${this.sidebarWidth}
|
||||
.isNative=${this.isNative}
|
||||
@selectedType=${(eventArg) => {
|
||||
this.selectedType = eventArg.detail;
|
||||
}}
|
||||
@@ -85,6 +142,23 @@ export class WccDashboard extends DeesElement {
|
||||
@selectedItem=${(eventArg) => {
|
||||
this.selectedItem = eventArg.detail;
|
||||
}}
|
||||
@searchChanged=${(eventArg: CustomEvent) => {
|
||||
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}
|
||||
@@ -92,6 +166,8 @@ export class WccDashboard extends DeesElement {
|
||||
.selectedItem=${this.selectedItem}
|
||||
.selectedViewport=${this.selectedViewport}
|
||||
.selectedTheme=${this.selectedTheme}
|
||||
.isNative=${this.isNative}
|
||||
.sidebarWidth=${this.sidebarWidth}
|
||||
@selectedViewport=${(eventArg) => {
|
||||
this.selectedViewport = eventArg.detail;
|
||||
this.scheduleUpdate();
|
||||
@@ -99,8 +175,18 @@ export class WccDashboard extends DeesElement {
|
||||
@selectedTheme=${(eventArg) => {
|
||||
this.selectedTheme = eventArg.detail;
|
||||
}}
|
||||
@editorStateChanged=${async (eventArg) => {
|
||||
const frame = await this.wccFrame;
|
||||
if (frame) {
|
||||
frame.advancedEditorOpen = eventArg.detail.isOpen;
|
||||
frame.requestUpdate();
|
||||
}
|
||||
}}
|
||||
@toggleNative=${() => {
|
||||
this.toggleNative();
|
||||
}}
|
||||
></wcc-properties>
|
||||
<wcc-frame id="wccFrame" viewport=${this.selectedViewport}>
|
||||
<wcc-frame id="wccFrame" viewport=${this.selectedViewport} .isNative=${this.isNative} .sidebarWidth=${this.sidebarWidth}>
|
||||
</wcc-frame>
|
||||
`;
|
||||
}
|
||||
@@ -115,45 +201,195 @@ export class WccDashboard extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
public toggleNative() {
|
||||
// Toggle between 'native' and 'desktop' viewports
|
||||
this.selectedViewport = this.selectedViewport === 'native' ? 'desktop' : 'native';
|
||||
this.buildUrl();
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
this.domtools = await plugins.deesDomtools.DomTools.setupDomTools();
|
||||
|
||||
// Add ESC key handler for native mode
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && this.isNative) {
|
||||
this.selectedViewport = 'desktop';
|
||||
this.buildUrl();
|
||||
}
|
||||
});
|
||||
|
||||
// Set up scroll listeners after DOM is ready
|
||||
setTimeout(() => {
|
||||
this.setupScrollListeners();
|
||||
}, 500);
|
||||
|
||||
// New route format with section name
|
||||
this.domtools.router.on(
|
||||
'/wcctools-route/:itemType/:itemName/:viewport/:theme',
|
||||
'/wcctools-route/:sectionName/:itemName/:demoIndex/:viewport/:theme',
|
||||
async (routeInfo) => {
|
||||
this.selectedType = routeInfo.params.itemType as TElementType;
|
||||
const sectionName = decodeURIComponent(routeInfo.params.sectionName);
|
||||
this.selectedSection = this.findSectionByName(sectionName);
|
||||
this.selectedItemName = routeInfo.params.itemName;
|
||||
this.selectedDemoIndex = parseInt(routeInfo.params.demoIndex) || 0;
|
||||
this.selectedViewport = routeInfo.params.viewport as breakpoints.TViewport;
|
||||
this.selectedTheme = routeInfo.params.theme as TTheme;
|
||||
if (routeInfo.params.itemType === 'element') {
|
||||
this.selectedItem = this.elements[routeInfo.params.itemName];
|
||||
} else if (routeInfo.params.itemType === 'page') {
|
||||
this.selectedItem = this.pages[routeInfo.params.itemName];
|
||||
|
||||
if (this.selectedSection) {
|
||||
// Find item within the section
|
||||
const entries = getSectionItems(this.selectedSection);
|
||||
const found = entries.find(([name]) => name === routeInfo.params.itemName);
|
||||
if (found) {
|
||||
this.selectedItem = found[1];
|
||||
this.selectedType = this.selectedSection.type === 'elements' ? 'element' : 'page';
|
||||
}
|
||||
} else {
|
||||
// Fallback: try legacy format (element/page as section name)
|
||||
const legacyType = routeInfo.params.sectionName;
|
||||
if (legacyType === 'element' || legacyType === 'page') {
|
||||
this.selectedType = legacyType as TElementType;
|
||||
// Find item in any matching section
|
||||
const result = this.findItemByName(routeInfo.params.itemName);
|
||||
if (result) {
|
||||
this.selectedItem = result.item;
|
||||
this.selectedSection = result.section;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore scroll positions from query parameters
|
||||
|
||||
// Restore state from query parameters
|
||||
if (routeInfo.queryParams) {
|
||||
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;
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
}
|
||||
if (frameScrollY) {
|
||||
this.frameScrollY = parseInt(frameScrollY);
|
||||
}
|
||||
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 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();
|
||||
this.selectedTheme === 'bright'
|
||||
? domtoolsInstance.themeManager.goBright()
|
||||
: domtoolsInstance.themeManager.goDark();
|
||||
}
|
||||
);
|
||||
|
||||
// Legacy route without demo index (for backwards compatibility)
|
||||
this.domtools.router.on(
|
||||
'/wcctools-route/:sectionName/:itemName/:viewport/:theme',
|
||||
async (routeInfo) => {
|
||||
const sectionName = decodeURIComponent(routeInfo.params.sectionName);
|
||||
this.selectedSection = this.findSectionByName(sectionName);
|
||||
this.selectedItemName = routeInfo.params.itemName;
|
||||
this.selectedDemoIndex = 0;
|
||||
this.selectedViewport = routeInfo.params.viewport as breakpoints.TViewport;
|
||||
this.selectedTheme = routeInfo.params.theme as TTheme;
|
||||
|
||||
if (this.selectedSection) {
|
||||
const entries = getSectionItems(this.selectedSection);
|
||||
const found = entries.find(([name]) => name === routeInfo.params.itemName);
|
||||
if (found) {
|
||||
this.selectedItem = found[1];
|
||||
this.selectedType = this.selectedSection.type === 'elements' ? 'element' : 'page';
|
||||
}
|
||||
} else {
|
||||
// Fallback: try legacy format
|
||||
const legacyType = routeInfo.params.sectionName;
|
||||
if (legacyType === 'element' || legacyType === 'page') {
|
||||
this.selectedType = legacyType as TElementType;
|
||||
const result = this.findItemByName(routeInfo.params.itemName);
|
||||
if (result) {
|
||||
this.selectedItem = result.item;
|
||||
this.selectedSection = result.section;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore state from query parameters
|
||||
if (routeInfo.queryParams) {
|
||||
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;
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
}
|
||||
if (frameScrollY) {
|
||||
this.frameScrollY = parseInt(frameScrollY);
|
||||
}
|
||||
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(() => {
|
||||
this.applyScrollPositions();
|
||||
}, 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();
|
||||
this.selectedTheme === 'bright'
|
||||
? domtoolsInstance.themeManager.goBright()
|
||||
@@ -175,7 +411,9 @@ export class WccDashboard extends DeesElement {
|
||||
if (typeof this.selectedItem === 'function') {
|
||||
console.log('slotting page.');
|
||||
const viewport = await wccFrame.getViewportElement();
|
||||
render(this.selectedItem(), viewport);
|
||||
const pageFactory = this.selectedItem as TTemplateFactory;
|
||||
const pageTemplate = await resolveTemplateFactory(pageFactory);
|
||||
render(pageTemplate, viewport);
|
||||
console.log('rendered page.');
|
||||
} else {
|
||||
console.error('The selected item looks strange:');
|
||||
@@ -188,32 +426,60 @@ export class WccDashboard extends DeesElement {
|
||||
this.setWarning(`component ${anonItem.name} does not expose a demo property.`);
|
||||
return;
|
||||
}
|
||||
if (!(typeof anonItem.demo === 'function')) {
|
||||
|
||||
// Support both single demo (function) and multiple demos (array)
|
||||
const isArray = Array.isArray(anonItem.demo);
|
||||
const isFunction = typeof anonItem.demo === 'function';
|
||||
|
||||
if (!isArray && !isFunction) {
|
||||
this.setWarning(
|
||||
`component ${anonItem.name} has demo property, but it is not of type function`
|
||||
`component ${anonItem.name} has demo property, but it is not a function or array of functions`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the specific demo to render
|
||||
const demoFactory = getDemoAtIndex(anonItem.demo, this.selectedDemoIndex);
|
||||
if (!demoFactory) {
|
||||
this.setWarning(
|
||||
`component ${anonItem.name} does not have a demo at index ${this.selectedDemoIndex + 1}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setWarning(null);
|
||||
const viewport = await wccFrame.getViewportElement();
|
||||
render(anonItem.demo(), viewport);;
|
||||
const demoTemplate = await resolveTemplateFactory(demoFactory);
|
||||
render(demoTemplate, viewport);
|
||||
}
|
||||
}
|
||||
|
||||
public buildUrl() {
|
||||
const baseUrl = `/wcctools-route/${this.selectedType}/${this.selectedItemName}/${this.selectedViewport}/${this.selectedTheme}`;
|
||||
const sectionName = this.selectedSection
|
||||
? encodeURIComponent(this.selectedSection.name)
|
||||
: this.selectedType; // Fallback for legacy
|
||||
const baseUrl = `/wcctools-route/${sectionName}/${this.selectedItemName}/${this.selectedDemoIndex}/${this.selectedViewport}/${this.selectedTheme}`;
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
|
||||
if (this.searchQuery) {
|
||||
queryParams.set('search', this.searchQuery);
|
||||
}
|
||||
if (this.frameScrollY > 0) {
|
||||
queryParams.set('frameScrollY', this.frameScrollY.toString());
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
this.domtools.router.pushUrl(fullUrl);
|
||||
}
|
||||
|
||||
@@ -255,19 +521,31 @@ export class WccDashboard extends DeesElement {
|
||||
}
|
||||
|
||||
private updateUrlWithScrollState() {
|
||||
const baseUrl = `/wcctools-route/${this.selectedType}/${this.selectedItemName}/${this.selectedViewport}/${this.selectedTheme}`;
|
||||
const sectionName = this.selectedSection
|
||||
? encodeURIComponent(this.selectedSection.name)
|
||||
: this.selectedType; // Fallback for legacy
|
||||
const baseUrl = `/wcctools-route/${sectionName}/${this.selectedItemName}/${this.selectedDemoIndex}/${this.selectedViewport}/${this.selectedTheme}`;
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
|
||||
if (this.searchQuery) {
|
||||
queryParams.set('search', this.searchQuery);
|
||||
}
|
||||
if (this.frameScrollY > 0) {
|
||||
queryParams.set('frameScrollY', this.frameScrollY.toString());
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
// Use replaceState to update URL without navigation
|
||||
window.history.replaceState(null, '', fullUrl);
|
||||
}
|
||||
|
||||
@@ -11,18 +11,28 @@ declare global {
|
||||
@customElement('wcc-frame')
|
||||
export class WccFrame extends DeesElement {
|
||||
@property()
|
||||
public viewport: string;
|
||||
accessor viewport: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor advancedEditorOpen: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor 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;
|
||||
bottom: 100px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
@@ -41,7 +51,19 @@ export class WccFrame extends DeesElement {
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
${(() => {
|
||||
${this.isNative ? `
|
||||
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 rgba(255, 174, 175, 1);
|
||||
left: ${this.sidebarWidth}px;
|
||||
`}
|
||||
transition: ${this.isResizing ? 'none' : 'all 0.3s ease'};
|
||||
${this.isNative ? 'padding: 0px;' : (() => {
|
||||
switch (this.viewport) {
|
||||
case 'desktop':
|
||||
return `
|
||||
@@ -50,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;
|
||||
`;
|
||||
}
|
||||
@@ -70,7 +92,7 @@ export class WccFrame extends DeesElement {
|
||||
}
|
||||
|
||||
.viewport {
|
||||
${this.viewport !== 'desktop'
|
||||
${!this.isNative && this.viewport !== 'desktop'
|
||||
? html` border-right: 1px dotted #444; border-left: 1px dotted #444; `
|
||||
: html``
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, state } from '@design.estate/dees-element';
|
||||
import { WccDashboard } from './wcc-dashboard.js';
|
||||
import type { TTemplateFactory } from './wcctools.helpers.js';
|
||||
import './wcc-record-button.js';
|
||||
import './wcc-recording-panel.js';
|
||||
|
||||
export type TPropertyType = 'String' | 'Number' | 'Boolean' | 'Object' | 'Enum' | 'Array';
|
||||
|
||||
@@ -17,48 +20,103 @@ export class WccProperties extends DeesElement {
|
||||
@property({
|
||||
type: WccDashboard
|
||||
})
|
||||
public dashboardRef: WccDashboard;
|
||||
accessor dashboardRef: WccDashboard;
|
||||
|
||||
@property()
|
||||
public selectedItem: (() => TemplateResult) | DeesElement;
|
||||
accessor selectedItem: TTemplateFactory | DeesElement;
|
||||
|
||||
@property()
|
||||
public selectedViewport: TEnvironment = 'native';
|
||||
accessor selectedViewport: TEnvironment = 'native';
|
||||
|
||||
@property()
|
||||
public selectedTheme: TTheme = 'dark';
|
||||
accessor selectedTheme: TTheme = 'dark';
|
||||
|
||||
@property()
|
||||
public warning: string = null;
|
||||
accessor warning: string = null;
|
||||
|
||||
@property()
|
||||
accessor isNative: boolean = false;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor sidebarWidth: number = 200;
|
||||
|
||||
@state()
|
||||
propertyContent: TemplateResult[] = [];
|
||||
accessor propertyContent: TemplateResult[] = [];
|
||||
|
||||
@state()
|
||||
accessor editingProperties: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
value: any;
|
||||
element: HTMLElement;
|
||||
editorValue: string;
|
||||
editorError: string;
|
||||
}> = [];
|
||||
|
||||
// Recording coordination state
|
||||
@state()
|
||||
accessor showRecordingPanel: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor isRecording: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor recordingDuration: number = 0;
|
||||
|
||||
// Delayed hide for native mode transition
|
||||
@state()
|
||||
accessor isHidden: boolean = false;
|
||||
|
||||
public editorHeight: number = 300;
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
/* CSS Variables - Always dark theme */
|
||||
--background: #0a0a0a;
|
||||
--foreground: #e5e5e5;
|
||||
--card: #0f0f0f;
|
||||
--card-foreground: #f0f0f0;
|
||||
--muted: #1a1a1a;
|
||||
--muted-foreground: #666;
|
||||
--accent: #222;
|
||||
--accent-foreground: #fff;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--input: #141414;
|
||||
--primary: #3b82f6;
|
||||
--primary-foreground: #fff;
|
||||
--ring: #3b82f6;
|
||||
--radius: 4px;
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
|
||||
/* Base styles */
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
left: 200px;
|
||||
height: 100px;
|
||||
left: ${this.sidebarWidth}px;
|
||||
height: ${this.editingProperties.length > 0 ? 100 + this.editorHeight : 100}px;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
display: ${this.isHidden ? 'none' : 'block'};
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 150px 300px 70px;
|
||||
grid-template-columns: 1fr 150px 350px 100px;
|
||||
height: 100%;
|
||||
}
|
||||
.properties {
|
||||
border-right: 1px solid #999;
|
||||
height: 100px;
|
||||
background: transparent;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 33% 33% 33%;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
border-right: 1px solid var(--border);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
@@ -77,152 +135,585 @@ export class WccProperties extends DeesElement {
|
||||
}
|
||||
|
||||
.properties .property {
|
||||
padding: 5px;
|
||||
background: #444;
|
||||
border: 1px solid #000;
|
||||
padding: 0.4rem;
|
||||
background: transparent;
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.properties .property:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.properties .property-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 400;
|
||||
color: #888;
|
||||
margin-bottom: 0.2rem;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.properties input,
|
||||
.properties input[type="text"],
|
||||
.properties input[type="number"],
|
||||
.properties select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: #333;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: var(--input);
|
||||
border: 1px solid transparent;
|
||||
color: var(--foreground);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.properties input[type="text"]:focus,
|
||||
.properties input[type="number"]:focus,
|
||||
.properties select:focus {
|
||||
border-color: var(--primary);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.properties input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.properties .editor-button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--input);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.properties .editor-button:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.viewportSelector,
|
||||
.themeSelector {
|
||||
.themeSelector,
|
||||
.shareSelector {
|
||||
user-select: none;
|
||||
border-right: 1px solid #999;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.selectorButtons2 {
|
||||
display: grid;
|
||||
grid-template-columns: 50% 50%;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
flex: 1;
|
||||
}
|
||||
.selectorButtons4 {
|
||||
display: grid;
|
||||
grid-template-columns: 25% 25% 25% 25%;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
flex: 1;
|
||||
}
|
||||
.selectorButtons5 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
flex: 1;
|
||||
}
|
||||
.selectorButtons1 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
flex: 1;
|
||||
}
|
||||
.button {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.25rem;
|
||||
text-align: center;
|
||||
border: 1px solid #000;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
font-size: 0.65rem;
|
||||
gap: 0.2rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
color: #333;
|
||||
background: #fff;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.button.selected {
|
||||
background: #455a64;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.button.selected:hover {
|
||||
color: #ffffff;
|
||||
background: #455a64;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.button .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 300;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.button.selected .material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400;
|
||||
}
|
||||
|
||||
.panelheading {
|
||||
padding: 5px;
|
||||
font-weight: bold;
|
||||
background: #444;
|
||||
border: 1px solid #000;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.65rem;
|
||||
background: rgba(59, 130, 246, 0.03);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.docs {
|
||||
text-align: center;
|
||||
line-height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
transition: all 0.15s ease;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.docs:hover {
|
||||
color: #333;
|
||||
background: #fff;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.warning {
|
||||
position: absolute;
|
||||
background: #222;
|
||||
color: #CCC;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 520px;
|
||||
text-align: center;
|
||||
padding: 35px;
|
||||
font-size: 25px;
|
||||
background: rgba(20, 20, 20, 0.8);
|
||||
color: #888;
|
||||
top: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
right: calc(600px + 0.5rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.advanced-editor-container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: ${this.editorHeight}px;
|
||||
background: #050505;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.editor-header-bar {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.editor-header-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.editor-close-all {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
color: #999;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.editor-close-all:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.editors-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
gap: 0;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.editors-container::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.editors-container::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editors-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editors-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.editor-instance {
|
||||
min-width: 320px;
|
||||
flex: 1;
|
||||
max-width: 480px;
|
||||
background: rgba(10, 10, 10, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
margin-right: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.editor-instance:hover {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.editor-instance:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.editor-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor-button:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.editor-button.primary {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.editor-button.primary:hover {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #d0d0d0;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
padding: 0.75rem;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: all 0.15s ease;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-textarea:focus {
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
}
|
||||
|
||||
.editor-textarea::selection {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.editor-error {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-top: 1px solid rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.editor-error::before {
|
||||
content: '!';
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
||||
<div class="grid">
|
||||
<div class="properties">
|
||||
<div class="panelheading">Properties</div>
|
||||
${this.propertyContent}
|
||||
${this.editingProperties.length > 0 ? html`
|
||||
<div class="advanced-editor-container">
|
||||
<div class="editor-header-bar">
|
||||
<div class="editor-header-title">Property Editors</div>
|
||||
<button class="editor-close-all" @click=${this.closeAllEditors}>
|
||||
Close All
|
||||
</button>
|
||||
</div>
|
||||
<div class="editors-container">
|
||||
${this.editingProperties.length === 0 ? html`
|
||||
<div style="
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
">
|
||||
<div>
|
||||
<div style="margin-bottom: 0.5rem; font-size: 1.5rem; opacity: 0.5;">{ }</div>
|
||||
<div>No properties being edited</div>
|
||||
<div style="font-size: 0.75rem; margin-top: 0.25rem; opacity: 0.7;">Click "Edit Object/Array" buttons to start editing</div>
|
||||
</div>
|
||||
</div>
|
||||
` : null}
|
||||
${this.editingProperties.map(prop => html`
|
||||
<div class="editor-instance">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">${prop.name}</div>
|
||||
<div class="editor-actions">
|
||||
<button class="editor-button" @click=${() => this.handleEditorCancel(prop.id)}>✕</button>
|
||||
<button class="editor-button primary" @click=${() => this.handleEditorSave(prop.id)}>✓</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-content">
|
||||
<textarea
|
||||
class="editor-textarea"
|
||||
.value=${prop.editorValue}
|
||||
@input=${(e: InputEvent) => {
|
||||
const editor = this.editingProperties.find(p => p.id === prop.id);
|
||||
if (editor) {
|
||||
editor.editorValue = (e.target as HTMLTextAreaElement).value;
|
||||
editor.editorError = '';
|
||||
this.requestUpdate();
|
||||
}
|
||||
}}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const start = target.selectionStart;
|
||||
const end = target.selectionEnd;
|
||||
const value = target.value;
|
||||
target.value = value.substring(0, start) + ' ' + value.substring(end);
|
||||
target.selectionStart = target.selectionEnd = start + 2;
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
${prop.editorError ? html`
|
||||
<div class="editor-error">${prop.editorError}</div>
|
||||
` : null}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="themeSelector">
|
||||
<div class="panelheading">Theme</div>
|
||||
<div class="selectorButtons2">
|
||||
<div
|
||||
class="button ${this.selectedTheme === 'dark' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectTheme('dark');
|
||||
}}
|
||||
>
|
||||
Dark<br /><i class="material-symbols-outlined">brightness_3</i>
|
||||
` : null}
|
||||
<div class="main-content">
|
||||
<div class="grid">
|
||||
<div class="properties">
|
||||
${this.propertyContent}
|
||||
</div>
|
||||
<div class="themeSelector">
|
||||
<div class="panelheading">Theme</div>
|
||||
<div class="selectorButtons2">
|
||||
<div
|
||||
class="button ${this.selectedTheme === 'dark' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectTheme('dark');
|
||||
}}
|
||||
>
|
||||
Dark<i class="material-symbols-outlined">brightness_3</i>
|
||||
</div>
|
||||
<div
|
||||
class="button ${this.selectedTheme === 'bright' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectTheme('bright');
|
||||
}}
|
||||
>
|
||||
Bright<i class="material-symbols-outlined">flare</i>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="button ${this.selectedTheme === 'bright' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectTheme('bright');
|
||||
}}
|
||||
>
|
||||
Bright<br /><i class="material-symbols-outlined">flare</i>
|
||||
</div>
|
||||
<div class="viewportSelector">
|
||||
<div class="panelheading">Viewport</div>
|
||||
<div class="selectorButtons5">
|
||||
<div
|
||||
class="button ${this.selectedViewport === 'phone' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectViewport('phone');
|
||||
}}
|
||||
>
|
||||
Phone<i class="material-symbols-outlined">smartphone</i>
|
||||
</div>
|
||||
<div
|
||||
class="button ${this.selectedViewport === 'phablet' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectViewport('phablet');
|
||||
}}
|
||||
>
|
||||
Phablet<i class="material-symbols-outlined">smartphone</i>
|
||||
</div>
|
||||
<div
|
||||
class="button ${this.selectedViewport === 'tablet' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectViewport('tablet');
|
||||
}}
|
||||
>
|
||||
Tablet<i class="material-symbols-outlined">tablet</i>
|
||||
</div>
|
||||
<div
|
||||
class="button ${this.selectedViewport === 'desktop' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectViewport('desktop');
|
||||
}}
|
||||
>
|
||||
Desktop<i class="material-symbols-outlined">desktop_windows</i>
|
||||
</div>
|
||||
<div
|
||||
class="button ${this.selectedViewport === 'native' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectViewport('native');
|
||||
}}
|
||||
>
|
||||
Native<i class="material-symbols-outlined">fullscreen</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shareSelector">
|
||||
<div class="panelheading">Share</div>
|
||||
<div class="selectorButtons1">
|
||||
<div
|
||||
class="button ${this.isRecording ? 'selected' : ''}"
|
||||
@click=${() => this.handleRecordButtonClick()}
|
||||
>
|
||||
Record<i class="material-symbols-outlined">${this.isRecording ? 'stop_circle' : 'videocam'}</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viewportSelector">
|
||||
<div class="panelheading">Viewport</div>
|
||||
<div class="selectorButtons4">
|
||||
<div
|
||||
class="button ${this.selectedViewport === 'phone' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectViewport('phone');
|
||||
}}
|
||||
>
|
||||
Phone<br /><i class="material-symbols-outlined">smartphone</i>
|
||||
</div>
|
||||
<div
|
||||
class="button ${this.selectedViewport === 'phablet' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectViewport('phablet');
|
||||
}}
|
||||
>
|
||||
Phablet<br /><i class="material-symbols-outlined">smartphone</i>
|
||||
</div>
|
||||
<div
|
||||
class="button ${this.selectedViewport === 'tablet' ? 'selected' : null}"
|
||||
@click=${() => {
|
||||
this.selectViewport('tablet');
|
||||
}}
|
||||
>
|
||||
Tablet<br /><i class="material-symbols-outlined">tablet</i>
|
||||
</div>
|
||||
<div
|
||||
class="button ${this.selectedViewport === 'desktop' ||
|
||||
this.selectedViewport === 'native'
|
||||
? 'selected'
|
||||
: null}"
|
||||
@click=${() => {
|
||||
this.selectViewport('native');
|
||||
}}
|
||||
>
|
||||
Desktop<br /><i class="material-symbols-outlined">desktop_windows</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docs">Docs</div>
|
||||
${this.warning ? html`<div class="warning">${this.warning}</div>` : null}
|
||||
</div>
|
||||
${this.warning ? html`<div class="warning">${this.warning}</div>` : null}
|
||||
|
||||
<!-- Recording Panel (options + preview) -->
|
||||
${this.showRecordingPanel ? html`
|
||||
<wcc-recording-panel
|
||||
.dashboardRef=${this.dashboardRef}
|
||||
@recording-start=${() => { this.isRecording = true; }}
|
||||
@recording-stop=${() => { this.isRecording = false; }}
|
||||
@duration-update=${(e: CustomEvent) => { this.recordingDuration = e.detail.duration; }}
|
||||
@close=${() => { this.showRecordingPanel = false; this.isRecording = false; this.recordingDuration = 0; }}
|
||||
></wcc-recording-panel>
|
||||
` : null}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -356,7 +847,7 @@ export class WccProperties extends DeesElement {
|
||||
propertyArray.push(
|
||||
html`
|
||||
<div class="property">
|
||||
${key} / ${propertyTypeString}<br />
|
||||
<div class="property-label">${key} (${propertyTypeString})</div>
|
||||
${(() => {
|
||||
switch (propertyTypeString) {
|
||||
case 'Boolean':
|
||||
@@ -401,6 +892,17 @@ export class WccProperties extends DeesElement {
|
||||
`;
|
||||
})}
|
||||
</select>`;
|
||||
case 'Object':
|
||||
case 'Array':
|
||||
return html`<button
|
||||
class="editor-button"
|
||||
style="width: 100%; margin-top: 0.25rem;"
|
||||
@click="${() => this.openAdvancedEditor(key, firstFoundInstantiatedElement[key], firstFoundInstantiatedElement)}"
|
||||
>
|
||||
Edit ${propertyTypeString}
|
||||
</button>`;
|
||||
default:
|
||||
return html`<div style="color: #666; font-size: 0.7rem;">Unsupported type</div>`;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
@@ -430,16 +932,29 @@ export class WccProperties extends DeesElement {
|
||||
this.dashboardRef.buildUrl();
|
||||
}
|
||||
|
||||
public async scheduleUpdate() {
|
||||
try {
|
||||
await this.createProperties();
|
||||
} catch (error) {
|
||||
console.error('Error creating properties:', error);
|
||||
// Clear property content on error to show clean state
|
||||
this.propertyContent = [];
|
||||
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 => {
|
||||
console.error('Error creating properties:', error);
|
||||
this.propertyContent = [];
|
||||
});
|
||||
}
|
||||
// Always call super.scheduleUpdate to ensure component updates
|
||||
super.scheduleUpdate();
|
||||
}
|
||||
|
||||
public selectViewport(viewport: TEnvironment) {
|
||||
@@ -452,4 +967,116 @@ export class WccProperties extends DeesElement {
|
||||
);
|
||||
this.dashboardRef.buildUrl();
|
||||
}
|
||||
|
||||
private openAdvancedEditor(propertyName: string, value: any, element: HTMLElement) {
|
||||
// Check if this property is already being edited
|
||||
const existingEditor = this.editingProperties.find(p => p.name === propertyName && p.element === element);
|
||||
if (existingEditor) {
|
||||
return; // Property is already open for editing
|
||||
}
|
||||
|
||||
const newEditor = {
|
||||
id: `${propertyName}-${Date.now()}`,
|
||||
name: propertyName,
|
||||
value: value,
|
||||
element: element,
|
||||
editorValue: JSON.stringify(value, null, 2),
|
||||
editorError: ''
|
||||
};
|
||||
|
||||
this.editingProperties = [...this.editingProperties, newEditor];
|
||||
|
||||
// Notify parent to resize frame if this is the first editor
|
||||
if (this.editingProperties.length === 1) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('editorStateChanged', {
|
||||
detail: { isOpen: true },
|
||||
bubbles: true
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleEditorSave(editorId: string) {
|
||||
const editor = this.editingProperties.find(p => p.id === editorId);
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const parsedValue = JSON.parse(editor.editorValue);
|
||||
editor.element[editor.name] = parsedValue;
|
||||
|
||||
// Remove this editor from the list
|
||||
this.editingProperties = this.editingProperties.filter(p => p.id !== editorId);
|
||||
|
||||
// If no more editors, notify parent to resize frame
|
||||
if (this.editingProperties.length === 0) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('editorStateChanged', {
|
||||
detail: { isOpen: false },
|
||||
bubbles: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh properties display
|
||||
this.createProperties();
|
||||
} catch (error) {
|
||||
// Update error for this specific editor
|
||||
const editorIndex = this.editingProperties.findIndex(p => p.id === editorId);
|
||||
if (editorIndex !== -1) {
|
||||
this.editingProperties[editorIndex].editorError = `Invalid JSON: ${error.message}`;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEditorCancel(editorId: string) {
|
||||
// Remove this editor from the list
|
||||
this.editingProperties = this.editingProperties.filter(p => p.id !== editorId);
|
||||
|
||||
// If no more editors, notify parent to resize frame
|
||||
if (this.editingProperties.length === 0) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('editorStateChanged', {
|
||||
detail: { isOpen: false },
|
||||
bubbles: true
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private closeAllEditors() {
|
||||
this.editingProperties = [];
|
||||
|
||||
// Notify parent to resize frame back
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('editorStateChanged', {
|
||||
detail: { isOpen: false },
|
||||
bubbles: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private toggleNative() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('toggleNative', {
|
||||
bubbles: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Recording Methods ====================
|
||||
|
||||
private handleRecordButtonClick() {
|
||||
if (this.isRecording) {
|
||||
// Stop recording by calling the panel's stopRecording method
|
||||
const panel = this.shadowRoot?.querySelector('wcc-recording-panel') as any;
|
||||
if (panel && panel.stopRecording) {
|
||||
panel.stopRecording();
|
||||
}
|
||||
} else {
|
||||
// Toggle the recording panel
|
||||
this.showRecordingPanel = !this.showRecordingPanel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
108
ts_web/elements/wcc-record-button.ts
Normal file
108
ts_web/elements/wcc-record-button.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { DeesElement, customElement, html, css, property, type TemplateResult } from '@design.estate/dees-element';
|
||||
|
||||
@customElement('wcc-record-button')
|
||||
export class WccRecordButton extends DeesElement {
|
||||
@property({ type: String })
|
||||
accessor state: 'idle' | 'recording' = 'idle';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor duration: number = 0;
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: #666;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
:host(.recording) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.rec-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
:host(.recording) .rec-icon {
|
||||
animation: pulse-recording 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-recording {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.9); }
|
||||
}
|
||||
|
||||
.recording-timer {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="content">
|
||||
<div class="rec-icon"></div>
|
||||
${this.state === 'recording' ? html`
|
||||
<span class="recording-timer">${this.formatDuration(this.duration)}</span>
|
||||
` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async connectedCallback(): Promise<void> {
|
||||
await super.connectedCallback();
|
||||
this.addEventListener('click', this.handleClick);
|
||||
}
|
||||
|
||||
async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
this.removeEventListener('click', this.handleClick);
|
||||
}
|
||||
|
||||
private handleClick = (): void => {
|
||||
this.dispatchEvent(new CustomEvent('record-click', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
};
|
||||
|
||||
updated(changedProperties: Map<string, unknown>): void {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('state')) {
|
||||
if (this.state === 'recording') {
|
||||
this.classList.add('recording');
|
||||
} else {
|
||||
this.classList.remove('recording');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
978
ts_web/elements/wcc-recording-panel.ts
Normal file
978
ts_web/elements/wcc-recording-panel.ts
Normal file
@@ -0,0 +1,978 @@
|
||||
import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { RecorderService } from '../services/recorder.service.js';
|
||||
import type { WccDashboard } from './wcc-dashboard.js';
|
||||
|
||||
@customElement('wcc-recording-panel')
|
||||
export class WccRecordingPanel extends DeesElement {
|
||||
// External configuration
|
||||
@property({ attribute: false })
|
||||
accessor dashboardRef: WccDashboard;
|
||||
|
||||
// Panel state
|
||||
@state()
|
||||
accessor panelState: 'options' | 'recording' | 'preview' = 'options';
|
||||
|
||||
// Recording options
|
||||
@state()
|
||||
accessor recordingMode: 'viewport' | 'screen' = 'viewport';
|
||||
|
||||
@state()
|
||||
accessor audioEnabled: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor selectedMicrophoneId: string = '';
|
||||
|
||||
@state()
|
||||
accessor availableMicrophones: MediaDeviceInfo[] = [];
|
||||
|
||||
@state()
|
||||
accessor audioLevel: number = 0;
|
||||
|
||||
// Recording state
|
||||
@state()
|
||||
accessor recordingDuration: number = 0;
|
||||
|
||||
// Preview/trim state
|
||||
@state()
|
||||
accessor previewVideoUrl: string = '';
|
||||
|
||||
@state()
|
||||
accessor trimStart: number = 0;
|
||||
|
||||
@state()
|
||||
accessor trimEnd: number = 0;
|
||||
|
||||
@state()
|
||||
accessor videoDuration: number = 0;
|
||||
|
||||
@state()
|
||||
accessor isDraggingTrim: 'start' | 'end' | null = null;
|
||||
|
||||
@state()
|
||||
accessor isExporting: boolean = false;
|
||||
|
||||
// Service instance
|
||||
private recorderService: RecorderService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.recorderService = new RecorderService({
|
||||
onDurationUpdate: (duration) => {
|
||||
this.recordingDuration = duration;
|
||||
this.dispatchEvent(new CustomEvent('duration-update', {
|
||||
detail: { duration },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
},
|
||||
onRecordingComplete: (blob) => {
|
||||
this.handleRecordingComplete(blob);
|
||||
},
|
||||
onAudioLevelUpdate: (level) => {
|
||||
this.audioLevel = level;
|
||||
},
|
||||
onStreamEnded: () => {
|
||||
this.stopRecording();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
css`
|
||||
:host {
|
||||
/* CSS Variables */
|
||||
--background: #0a0a0a;
|
||||
--foreground: #e5e5e5;
|
||||
--input: #141414;
|
||||
--primary: #3b82f6;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
}
|
||||
|
||||
/* Recording Options Panel */
|
||||
.recording-options-panel {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 116px;
|
||||
width: 360px;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.recording-options-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recording-options-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.recording-options-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.recording-options-close:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.recording-options-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.recording-option-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recording-option-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.recording-option-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recording-mode-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.recording-mode-btn {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--input);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #999;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recording-mode-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.recording-mode-btn.selected {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.audio-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.audio-toggle input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.audio-toggle label {
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.microphone-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--input);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-size: 0.75rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.microphone-select:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.microphone-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.audio-level-container {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.audio-level-label {
|
||||
font-size: 0.65rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.audio-level-bar {
|
||||
height: 8px;
|
||||
background: var(--input);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.audio-level-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #22c55e, #84cc16, #eab308);
|
||||
border-radius: 4px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.start-recording-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #dc2626;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.start-recording-btn:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.start-recording-btn .rec-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Preview Modal */
|
||||
.preview-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.preview-modal {
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.preview-modal-header {
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-modal-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.preview-modal-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.preview-modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.preview-modal-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.preview-video-container {
|
||||
background: #000;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-modal-actions {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.preview-btn.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.preview-btn.secondary:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.preview-btn.primary {
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.preview-btn.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.preview-btn.primary:disabled {
|
||||
background: #1e3a5f;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Trim Timeline Styles */
|
||||
.trim-section {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.trim-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.trim-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.trim-duration-info {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.trim-timeline {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
background: var(--input);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.75rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.trim-track {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 6px;
|
||||
background: #333;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.trim-selected {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 6px;
|
||||
background: var(--primary);
|
||||
transform: translateY(-50%);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trim-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 36px;
|
||||
background: white;
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: 4px;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: ew-resize;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.trim-handle:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.trim-handle:active {
|
||||
background: var(--primary);
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
|
||||
.trim-handle::before {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: #666;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.trim-handle:active::before {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.trim-time-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.65rem;
|
||||
color: #666;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.trim-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.trim-action-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--input);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #999;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trim-action-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.export-spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
`
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.panelState === 'options') {
|
||||
return this.renderOptionsPanel();
|
||||
} else if (this.panelState === 'preview') {
|
||||
return this.renderPreviewModal();
|
||||
}
|
||||
return html``;
|
||||
}
|
||||
|
||||
private renderOptionsPanel(): TemplateResult {
|
||||
return html`
|
||||
<div class="recording-options-panel">
|
||||
<div class="recording-options-header">
|
||||
<span class="recording-options-title">Recording Settings</span>
|
||||
<button class="recording-options-close" @click=${() => this.close()}>✕</button>
|
||||
</div>
|
||||
<div class="recording-options-content">
|
||||
<div class="recording-option-group">
|
||||
<div class="recording-option-label">Record Area</div>
|
||||
<div class="recording-mode-buttons">
|
||||
<button
|
||||
class="recording-mode-btn ${this.recordingMode === 'viewport' ? 'selected' : ''}"
|
||||
@click=${() => this.recordingMode = 'viewport'}
|
||||
>
|
||||
Viewport Only
|
||||
</button>
|
||||
<button
|
||||
class="recording-mode-btn ${this.recordingMode === 'screen' ? 'selected' : ''}"
|
||||
@click=${() => this.recordingMode = 'screen'}
|
||||
>
|
||||
Entire Screen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recording-option-group">
|
||||
<div class="recording-option-label">Audio</div>
|
||||
<div class="audio-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="audioToggle"
|
||||
?checked=${this.audioEnabled}
|
||||
@change=${(e: Event) => this.handleAudioToggle((e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<label for="audioToggle">Enable Microphone</label>
|
||||
</div>
|
||||
|
||||
${this.audioEnabled ? html`
|
||||
<select
|
||||
class="microphone-select"
|
||||
.value=${this.selectedMicrophoneId}
|
||||
@change=${(e: Event) => this.handleMicrophoneChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Select Microphone...</option>
|
||||
${this.availableMicrophones.map(mic => html`
|
||||
<option value=${mic.deviceId}>${mic.label || `Microphone ${mic.deviceId.slice(0, 8)}`}</option>
|
||||
`)}
|
||||
</select>
|
||||
|
||||
${this.selectedMicrophoneId ? html`
|
||||
<div class="audio-level-container">
|
||||
<div class="audio-level-label">Input Level</div>
|
||||
<div class="audio-level-bar">
|
||||
<div class="audio-level-fill" style="width: ${this.audioLevel}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : null}
|
||||
` : null}
|
||||
</div>
|
||||
|
||||
<button class="start-recording-btn" @click=${() => this.startRecording()}>
|
||||
<div class="rec-dot"></div>
|
||||
Start Recording
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPreviewModal(): TemplateResult {
|
||||
return html`
|
||||
<div class="preview-modal-overlay" @click=${(e: Event) => {
|
||||
if ((e.target as HTMLElement).classList.contains('preview-modal-overlay')) {
|
||||
this.discardRecording();
|
||||
}
|
||||
}}>
|
||||
<div class="preview-modal">
|
||||
<div class="preview-modal-header">
|
||||
<span class="preview-modal-title">Recording Preview</span>
|
||||
<button class="preview-modal-close" @click=${() => this.discardRecording()}>✕</button>
|
||||
</div>
|
||||
<div class="preview-modal-content">
|
||||
<div class="preview-video-container">
|
||||
<video
|
||||
class="preview-video"
|
||||
src=${this.previewVideoUrl}
|
||||
controls
|
||||
@loadedmetadata=${(e: Event) => this.handleVideoLoaded(e.target as HTMLVideoElement)}
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<!-- Trim Section -->
|
||||
<div class="trim-section">
|
||||
<div class="trim-section-header">
|
||||
<span class="trim-section-title">Trim Video</span>
|
||||
<span class="trim-duration-info">
|
||||
${this.formatDuration(Math.floor(this.trimEnd - this.trimStart))}
|
||||
${this.trimStart > 0 || this.trimEnd < this.videoDuration
|
||||
? `(trimmed from ${this.formatDuration(Math.floor(this.videoDuration))})`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="trim-timeline"
|
||||
@mousedown=${(e: MouseEvent) => this.handleTimelineClick(e)}
|
||||
@mousemove=${(e: MouseEvent) => this.handleTimelineDrag(e)}
|
||||
@mouseup=${() => this.handleTimelineDragEnd()}
|
||||
@mouseleave=${() => this.handleTimelineDragEnd()}
|
||||
>
|
||||
<div class="trim-track"></div>
|
||||
<div
|
||||
class="trim-selected"
|
||||
style="left: ${this.getHandlePositionStyle(this.trimStart)}; right: ${this.getHandlePositionFromEndStyle(this.trimEnd)};"
|
||||
></div>
|
||||
<div
|
||||
class="trim-handle start-handle"
|
||||
style="left: ${this.getHandlePositionStyle(this.trimStart)};"
|
||||
@mousedown=${(e: MouseEvent) => { e.stopPropagation(); this.isDraggingTrim = 'start'; }}
|
||||
></div>
|
||||
<div
|
||||
class="trim-handle end-handle"
|
||||
style="left: ${this.getHandlePositionStyle(this.trimEnd)};"
|
||||
@mousedown=${(e: MouseEvent) => { e.stopPropagation(); this.isDraggingTrim = 'end'; }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="trim-time-labels">
|
||||
<span>${this.formatDuration(Math.floor(this.trimStart))}</span>
|
||||
<span>${this.formatDuration(Math.floor(this.trimEnd))}</span>
|
||||
</div>
|
||||
|
||||
<div class="trim-actions">
|
||||
<button class="trim-action-btn" @click=${() => this.resetTrim()}>
|
||||
Reset Trim
|
||||
</button>
|
||||
<button class="trim-action-btn" @click=${() => this.previewTrimmedSection()}>
|
||||
Preview Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="preview-modal-actions">
|
||||
<button class="preview-btn secondary" @click=${() => this.discardRecording()}>Discard</button>
|
||||
<button
|
||||
class="preview-btn primary"
|
||||
?disabled=${this.isExporting}
|
||||
@click=${() => this.downloadRecording()}
|
||||
>
|
||||
${this.isExporting ? html`<span class="export-spinner"></span>Exporting...` : 'Download WebM'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==================== Audio Methods ====================
|
||||
|
||||
private async handleAudioToggle(enabled: boolean): Promise<void> {
|
||||
this.audioEnabled = enabled;
|
||||
if (enabled) {
|
||||
this.availableMicrophones = await this.recorderService.loadMicrophones(true);
|
||||
if (this.availableMicrophones.length > 0 && !this.selectedMicrophoneId) {
|
||||
this.selectedMicrophoneId = this.availableMicrophones[0].deviceId;
|
||||
await this.recorderService.startAudioMonitoring(this.selectedMicrophoneId);
|
||||
}
|
||||
} else {
|
||||
this.recorderService.stopAudioMonitoring();
|
||||
this.selectedMicrophoneId = '';
|
||||
this.audioLevel = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMicrophoneChange(deviceId: string): Promise<void> {
|
||||
this.selectedMicrophoneId = deviceId;
|
||||
if (deviceId) {
|
||||
await this.recorderService.startAudioMonitoring(deviceId);
|
||||
} else {
|
||||
this.recorderService.stopAudioMonitoring();
|
||||
this.audioLevel = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Recording Methods ====================
|
||||
|
||||
private async startRecording(): Promise<void> {
|
||||
try {
|
||||
let viewportElement: HTMLElement | undefined;
|
||||
if (this.recordingMode === 'viewport' && this.dashboardRef) {
|
||||
const wccFrame = await this.dashboardRef.wccFrame;
|
||||
viewportElement = await wccFrame.getViewportElement();
|
||||
}
|
||||
|
||||
await this.recorderService.startRecording({
|
||||
mode: this.recordingMode,
|
||||
audioDeviceId: this.audioEnabled ? this.selectedMicrophoneId : undefined,
|
||||
viewportElement
|
||||
});
|
||||
|
||||
this.panelState = 'recording';
|
||||
this.dispatchEvent(new CustomEvent('recording-start', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
this.panelState = 'options';
|
||||
}
|
||||
}
|
||||
|
||||
public stopRecording(): void {
|
||||
this.recorderService.stopRecording();
|
||||
}
|
||||
|
||||
private handleRecordingComplete(blob: Blob): void {
|
||||
if (this.previewVideoUrl) {
|
||||
URL.revokeObjectURL(this.previewVideoUrl);
|
||||
}
|
||||
this.previewVideoUrl = URL.createObjectURL(blob);
|
||||
this.panelState = 'preview';
|
||||
this.dispatchEvent(new CustomEvent('recording-stop', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private discardRecording(): void {
|
||||
if (this.previewVideoUrl) {
|
||||
URL.revokeObjectURL(this.previewVideoUrl);
|
||||
this.previewVideoUrl = '';
|
||||
}
|
||||
this.recorderService.reset();
|
||||
this.trimStart = 0;
|
||||
this.trimEnd = 0;
|
||||
this.videoDuration = 0;
|
||||
this.isExporting = false;
|
||||
this.recordingDuration = 0;
|
||||
this.close();
|
||||
}
|
||||
|
||||
private async downloadRecording(): Promise<void> {
|
||||
const recordedBlob = this.recorderService.recordedBlob;
|
||||
if (!recordedBlob) return;
|
||||
|
||||
this.isExporting = true;
|
||||
|
||||
try {
|
||||
let blobToDownload: Blob;
|
||||
|
||||
// Handle trimming if needed
|
||||
const needsTrim = this.trimStart > 0.1 || this.trimEnd < this.videoDuration - 0.1;
|
||||
|
||||
if (needsTrim) {
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (video) {
|
||||
blobToDownload = await this.recorderService.exportTrimmedVideo(video, this.trimStart, this.trimEnd);
|
||||
} else {
|
||||
blobToDownload = recordedBlob;
|
||||
}
|
||||
} else {
|
||||
blobToDownload = recordedBlob;
|
||||
}
|
||||
|
||||
// Trigger download
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const filename = `wcctools-recording-${timestamp}.webm`;
|
||||
|
||||
const url = URL.createObjectURL(blobToDownload);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
this.discardRecording();
|
||||
} catch (error) {
|
||||
console.error('Error exporting video:', error);
|
||||
this.isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Trim Methods ====================
|
||||
|
||||
private handleVideoLoaded(video: HTMLVideoElement): void {
|
||||
// WebM files from MediaRecorder may have Infinity/NaN duration
|
||||
// Fall back to the tracked recording duration
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : this.recordingDuration;
|
||||
this.videoDuration = duration;
|
||||
this.trimStart = 0;
|
||||
this.trimEnd = duration;
|
||||
}
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private getHandlePositionStyle(time: number): string {
|
||||
if (this.videoDuration === 0) return '12px';
|
||||
const percentage = time / this.videoDuration;
|
||||
// Formula: 12px padding + percentage of remaining width (total - 24px padding)
|
||||
// At 0%: 12px (left edge of track)
|
||||
// At 100%: calc(100% - 12px) (right edge of track)
|
||||
return `calc(12px + ${(percentage * 100).toFixed(2)}% - ${(percentage * 24).toFixed(2)}px)`;
|
||||
}
|
||||
|
||||
private getHandlePositionFromEndStyle(time: number): string {
|
||||
if (this.videoDuration === 0) return '12px';
|
||||
const percentage = time / this.videoDuration;
|
||||
const remainingPercentage = 1 - percentage;
|
||||
// For CSS 'right' property: distance from right edge
|
||||
// At trimEnd = 100%: right = 12px (at right edge of track)
|
||||
// At trimEnd = 0%: right = calc(100% - 12px) (at left edge of track)
|
||||
return `calc(12px + ${(remainingPercentage * 100).toFixed(2)}% - ${(remainingPercentage * 24).toFixed(2)}px)`;
|
||||
}
|
||||
|
||||
private handleTimelineClick(e: MouseEvent): void {
|
||||
if (this.isDraggingTrim) return;
|
||||
|
||||
const timeline = e.currentTarget as HTMLElement;
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, (x - 12) / (rect.width - 24)));
|
||||
const time = percentage * this.videoDuration;
|
||||
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (video) {
|
||||
video.currentTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
private handleTimelineDrag(e: MouseEvent): void {
|
||||
if (!this.isDraggingTrim) return;
|
||||
|
||||
const timeline = e.currentTarget as HTMLElement;
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, (x - 12) / (rect.width - 24)));
|
||||
const time = percentage * this.videoDuration;
|
||||
|
||||
const minDuration = 1;
|
||||
|
||||
if (this.isDraggingTrim === 'start') {
|
||||
this.trimStart = Math.min(time, this.trimEnd - minDuration);
|
||||
this.trimStart = Math.max(0, this.trimStart);
|
||||
} else if (this.isDraggingTrim === 'end') {
|
||||
this.trimEnd = Math.max(time, this.trimStart + minDuration);
|
||||
this.trimEnd = Math.min(this.videoDuration, this.trimEnd);
|
||||
}
|
||||
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (video) {
|
||||
video.currentTime = this.isDraggingTrim === 'start' ? this.trimStart : this.trimEnd;
|
||||
}
|
||||
}
|
||||
|
||||
private handleTimelineDragEnd(): void {
|
||||
this.isDraggingTrim = null;
|
||||
}
|
||||
|
||||
private resetTrim(): void {
|
||||
this.trimStart = 0;
|
||||
this.trimEnd = this.videoDuration;
|
||||
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (video) {
|
||||
video.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private previewTrimmedSection(): void {
|
||||
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = this.trimStart;
|
||||
video.play();
|
||||
|
||||
const checkTime = () => {
|
||||
if (video.currentTime >= this.trimEnd) {
|
||||
video.pause();
|
||||
video.removeEventListener('timeupdate', checkTime);
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', checkTime);
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
private close(): void {
|
||||
this.recorderService.stopAudioMonitoring();
|
||||
this.dispatchEvent(new CustomEvent('close', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
this.recorderService.dispose();
|
||||
if (this.previewVideoUrl) {
|
||||
URL.revokeObjectURL(this.previewVideoUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
40
ts_web/elements/wcctools.helpers.ts
Normal file
40
ts_web/elements/wcctools.helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export type TTemplateFactory = () => TemplateResult | Promise<TemplateResult>;
|
||||
|
||||
// Demo can be a single function or an array of functions
|
||||
export type TDemoDefinition = TTemplateFactory | TTemplateFactory[];
|
||||
|
||||
export const resolveTemplateFactory = async (
|
||||
factoryArg: TTemplateFactory
|
||||
): Promise<TemplateResult> => {
|
||||
return await Promise.resolve(factoryArg());
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the number of demos for an element
|
||||
*/
|
||||
export const getDemoCount = (demo: TDemoDefinition): number => {
|
||||
if (Array.isArray(demo)) {
|
||||
return demo.length;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific demo by index (0-based internally, displayed as 1-based)
|
||||
*/
|
||||
export const getDemoAtIndex = (demo: TDemoDefinition, index: number): TTemplateFactory | null => {
|
||||
if (Array.isArray(demo)) {
|
||||
return demo[index] ?? null;
|
||||
}
|
||||
// Single demo - only index 0 is valid
|
||||
return index === 0 ? demo : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an element has multiple demos
|
||||
*/
|
||||
export const hasMultipleDemos = (demo: TDemoDefinition): boolean => {
|
||||
return Array.isArray(demo) && demo.length > 1;
|
||||
};
|
||||
@@ -1,12 +1,86 @@
|
||||
import { WccDashboard } from './elements/wcc-dashboard.js';
|
||||
import { LitElement, type TemplateResult } from 'lit';
|
||||
import { LitElement } from 'lit';
|
||||
import type { TTemplateFactory } from './elements/wcctools.helpers.js';
|
||||
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 { WccRecordButton } from './elements/wcc-record-button.js';
|
||||
export { WccRecordingPanel } from './elements/wcc-recording-panel.js';
|
||||
|
||||
// Export types for external use
|
||||
export type { IWccConfig, IWccSection } from './wcctools.interfaces.js';
|
||||
|
||||
/**
|
||||
* Converts legacy (elements, pages) format to new sections config
|
||||
*/
|
||||
const convertLegacyToConfig = (
|
||||
elementsArg?: { [key: string]: LitElement },
|
||||
pagesArg?: Record<string, TTemplateFactory>
|
||||
): IWccConfig => {
|
||||
const sections: IWccSection[] = [];
|
||||
|
||||
if (pagesArg && Object.keys(pagesArg).length > 0) {
|
||||
sections.push({
|
||||
name: 'Pages',
|
||||
type: 'pages',
|
||||
items: pagesArg,
|
||||
});
|
||||
}
|
||||
|
||||
if (elementsArg && Object.keys(elementsArg).length > 0) {
|
||||
sections.push({
|
||||
name: 'Elements',
|
||||
type: 'elements',
|
||||
items: elementsArg,
|
||||
});
|
||||
}
|
||||
|
||||
return { sections };
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the argument is the new config format
|
||||
*/
|
||||
const isWccConfig = (arg: any): arg is IWccConfig => {
|
||||
return arg && typeof arg === 'object' && 'sections' in arg && Array.isArray(arg.sections);
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup WCC Tools dashboard
|
||||
*
|
||||
* New format (recommended):
|
||||
* ```typescript
|
||||
* setupWccTools({
|
||||
* sections: [
|
||||
* { name: 'Elements', type: 'elements', items: elements },
|
||||
* { name: 'Pages', type: 'pages', items: pages },
|
||||
* ]
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Legacy format (still supported):
|
||||
* ```typescript
|
||||
* setupWccTools(elements, pages);
|
||||
* ```
|
||||
*/
|
||||
const setupWccTools = (
|
||||
configOrElements?: IWccConfig | { [key: string]: LitElement },
|
||||
pagesArg?: Record<string, TTemplateFactory>
|
||||
) => {
|
||||
let config: IWccConfig;
|
||||
|
||||
if (isWccConfig(configOrElements)) {
|
||||
config = configOrElements;
|
||||
} else {
|
||||
config = convertLegacyToConfig(configOrElements, pagesArg);
|
||||
}
|
||||
|
||||
const setupWccTools = (elementsArg?: { [key: string]: LitElement }, pagesArg?: { [key: string]: () => TemplateResult }) => {
|
||||
let hasRun = false;
|
||||
const runWccToolsSetup = async () => {
|
||||
if (document.readyState === 'complete' && !hasRun) {
|
||||
hasRun = true;
|
||||
const wccTools = new WccDashboard(elementsArg as any, pagesArg);
|
||||
const wccTools = new WccDashboard(config);
|
||||
document.querySelector('body').append(wccTools);
|
||||
}
|
||||
};
|
||||
|
||||
173
ts_web/readme.md
Normal file
173
ts_web/readme.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# @design.estate/dees-wcctools
|
||||
|
||||
🛠️ **Web Component Catalogue Tools** — The core dashboard and UI components for building interactive component catalogues
|
||||
|
||||
## Overview
|
||||
|
||||
This is the main module of `@design.estate/dees-wcctools`, providing the complete dashboard experience for developing, testing, and documenting web components.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add -D @design.estate/dees-wcctools
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Sections-based Configuration (Recommended)
|
||||
|
||||
```typescript
|
||||
import { setupWccTools } from '@design.estate/dees-wcctools';
|
||||
import * as elements from './elements/index.js';
|
||||
import * as views from './views/index.js';
|
||||
import * as pages from './pages/index.js';
|
||||
|
||||
setupWccTools({
|
||||
sections: [
|
||||
{
|
||||
name: 'Pages',
|
||||
type: 'pages',
|
||||
items: pages,
|
||||
},
|
||||
{
|
||||
name: 'Views',
|
||||
type: 'elements',
|
||||
items: views,
|
||||
icon: 'web',
|
||||
},
|
||||
{
|
||||
name: 'Elements',
|
||||
type: 'elements',
|
||||
items: elements,
|
||||
filter: (name) => !name.startsWith('internal-'),
|
||||
sort: ([a], [b]) => a.localeCompare(b),
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Legacy Format (Still Supported)
|
||||
|
||||
```typescript
|
||||
import { setupWccTools } from '@design.estate/dees-wcctools';
|
||||
import { MyButton } from './components/my-button.js';
|
||||
|
||||
setupWccTools({
|
||||
'my-button': MyButton,
|
||||
});
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
### Main Entry Point
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `setupWccTools` | Initialize the component catalogue dashboard |
|
||||
| `IWccConfig` | TypeScript interface for sections configuration |
|
||||
| `IWccSection` | TypeScript interface for individual section |
|
||||
|
||||
### Recording Components
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `RecorderService` | Service class for screen/viewport recording |
|
||||
| `WccRecordButton` | Record button UI component |
|
||||
| `WccRecordingPanel` | Recording options and preview panel |
|
||||
| `IRecorderEvents` | TypeScript interface for recorder callbacks |
|
||||
| `IRecordingOptions` | TypeScript interface for recording options |
|
||||
|
||||
## Section Configuration
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `name` | `string` | Display name for the section header |
|
||||
| `type` | `'elements' \| 'pages'` | Rendering behavior |
|
||||
| `items` | `Record<string, any>` | Element classes or page factories |
|
||||
| `filter` | `(name, item) => boolean` | Optional filter function |
|
||||
| `sort` | `([a, itemA], [b, itemB]) => number` | Optional sort function |
|
||||
| `icon` | `string` | Material Symbols icon name |
|
||||
| `collapsed` | `boolean` | Start section collapsed (default: false) |
|
||||
|
||||
## Internal Components
|
||||
|
||||
The module includes these internal web components:
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `wcc-dashboard` | Main dashboard container with routing |
|
||||
| `wcc-sidebar` | Navigation sidebar with collapsible sections |
|
||||
| `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 |
|
||||
|
||||
## RecorderService API
|
||||
|
||||
For programmatic recording control:
|
||||
|
||||
```typescript
|
||||
import { RecorderService, type IRecorderEvents } from '@design.estate/dees-wcctools';
|
||||
|
||||
const events: IRecorderEvents = {
|
||||
onDurationUpdate: (duration) => console.log(`Recording: ${duration}s`),
|
||||
onRecordingComplete: (blob) => saveBlob(blob),
|
||||
onAudioLevelUpdate: (level) => updateMeter(level),
|
||||
onError: (error) => console.error(error),
|
||||
onStreamEnded: () => console.log('User stopped sharing'),
|
||||
};
|
||||
|
||||
const recorder = new RecorderService(events);
|
||||
|
||||
// Load available microphones
|
||||
const mics = await recorder.loadMicrophones(true);
|
||||
|
||||
// Start audio level monitoring
|
||||
await recorder.startAudioMonitoring(mics[0].deviceId);
|
||||
|
||||
// Start recording
|
||||
await recorder.startRecording({
|
||||
mode: 'viewport',
|
||||
audioDeviceId: mics[0].deviceId,
|
||||
viewportElement: document.querySelector('.viewport'),
|
||||
});
|
||||
|
||||
// Stop recording
|
||||
recorder.stopRecording();
|
||||
|
||||
// Export trimmed video
|
||||
const trimmedBlob = await recorder.exportTrimmedVideo(videoElement, startTime, endTime);
|
||||
|
||||
// Cleanup
|
||||
recorder.dispose();
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ts_web/
|
||||
├── index.ts # Main exports
|
||||
├── wcctools.interfaces.ts # Type definitions
|
||||
├── elements/
|
||||
│ ├── wcc-dashboard.ts # Root dashboard component
|
||||
│ ├── wcc-sidebar.ts # Navigation sidebar
|
||||
│ ├── wcc-frame.ts # Responsive viewport
|
||||
│ ├── wcc-properties.ts # Property editing panel
|
||||
│ ├── wcc-record-button.ts # Recording button
|
||||
│ ├── wcc-recording-panel.ts # Recording options/preview
|
||||
│ └── wcctools.helpers.ts # Shared utilities
|
||||
├── services/
|
||||
│ └── recorder.service.ts # MediaRecorder abstraction
|
||||
└── pages/
|
||||
└── index.ts # Built-in pages
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 🎨 Interactive component preview
|
||||
- 📂 Section-based sidebar with filtering & sorting
|
||||
- 🔧 Real-time property editing with type detection
|
||||
- 🌓 Theme switching (light/dark)
|
||||
- 📱 Responsive viewport testing
|
||||
- 🎬 Screen recording with trimming
|
||||
- 🔗 URL-based deep linking
|
||||
393
ts_web/services/recorder.service.ts
Normal file
393
ts_web/services/recorder.service.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* RecorderService - Handles all MediaRecorder, audio monitoring, and video export logic
|
||||
*/
|
||||
|
||||
export interface IRecorderEvents {
|
||||
onDurationUpdate?: (duration: number) => void;
|
||||
onRecordingComplete?: (blob: Blob) => void;
|
||||
onAudioLevelUpdate?: (level: number) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onStreamEnded?: () => void;
|
||||
}
|
||||
|
||||
export interface IRecordingOptions {
|
||||
mode: 'viewport' | 'screen';
|
||||
audioDeviceId?: string;
|
||||
viewportElement?: HTMLElement;
|
||||
}
|
||||
|
||||
export class RecorderService {
|
||||
// Recording state
|
||||
private mediaRecorder: MediaRecorder | null = null;
|
||||
private recordedChunks: Blob[] = [];
|
||||
private durationInterval: number | null = null;
|
||||
private _duration: number = 0;
|
||||
private _recordedBlob: Blob | null = null;
|
||||
private _isRecording: boolean = false;
|
||||
|
||||
// Audio monitoring state
|
||||
private audioContext: AudioContext | null = null;
|
||||
private audioAnalyser: AnalyserNode | null = null;
|
||||
private audioMonitoringInterval: number | null = null;
|
||||
private monitoringStream: MediaStream | null = null;
|
||||
|
||||
// Current recording stream
|
||||
private currentStream: MediaStream | null = null;
|
||||
|
||||
// Event callbacks
|
||||
private events: IRecorderEvents = {};
|
||||
|
||||
constructor(events?: IRecorderEvents) {
|
||||
if (events) {
|
||||
this.events = events;
|
||||
}
|
||||
}
|
||||
|
||||
// Public getters
|
||||
get isRecording(): boolean {
|
||||
return this._isRecording;
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
get recordedBlob(): Blob | null {
|
||||
return this._recordedBlob;
|
||||
}
|
||||
|
||||
// Update event callbacks
|
||||
setEvents(events: IRecorderEvents): void {
|
||||
this.events = { ...this.events, ...events };
|
||||
}
|
||||
|
||||
// ==================== Microphone Management ====================
|
||||
|
||||
async loadMicrophones(requestPermission: boolean = false): Promise<MediaDeviceInfo[]> {
|
||||
try {
|
||||
if (requestPermission) {
|
||||
// Request permission by getting a temporary stream
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.filter(d => d.kind === 'audioinput');
|
||||
} catch (error) {
|
||||
console.error('Error loading microphones:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async startAudioMonitoring(deviceId: string): Promise<void> {
|
||||
this.stopAudioMonitoring();
|
||||
|
||||
if (!deviceId) return;
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { deviceId: { exact: deviceId } }
|
||||
});
|
||||
|
||||
this.monitoringStream = stream;
|
||||
this.audioContext = new AudioContext();
|
||||
const source = this.audioContext.createMediaStreamSource(stream);
|
||||
this.audioAnalyser = this.audioContext.createAnalyser();
|
||||
this.audioAnalyser.fftSize = 256;
|
||||
source.connect(this.audioAnalyser);
|
||||
|
||||
const dataArray = new Uint8Array(this.audioAnalyser.frequencyBinCount);
|
||||
|
||||
this.audioMonitoringInterval = window.setInterval(() => {
|
||||
if (this.audioAnalyser) {
|
||||
this.audioAnalyser.getByteFrequencyData(dataArray);
|
||||
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
|
||||
const level = Math.min(100, (average / 128) * 100);
|
||||
this.events.onAudioLevelUpdate?.(level);
|
||||
}
|
||||
}, 50);
|
||||
} catch (error) {
|
||||
console.error('Error starting audio monitoring:', error);
|
||||
this.events.onAudioLevelUpdate?.(0);
|
||||
}
|
||||
}
|
||||
|
||||
stopAudioMonitoring(): void {
|
||||
if (this.audioMonitoringInterval) {
|
||||
clearInterval(this.audioMonitoringInterval);
|
||||
this.audioMonitoringInterval = null;
|
||||
}
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
if (this.monitoringStream) {
|
||||
this.monitoringStream.getTracks().forEach(track => track.stop());
|
||||
this.monitoringStream = null;
|
||||
}
|
||||
this.audioAnalyser = null;
|
||||
}
|
||||
|
||||
// ==================== Recording Control ====================
|
||||
|
||||
async startRecording(options: IRecordingOptions): Promise<void> {
|
||||
try {
|
||||
// Stop audio monitoring before recording
|
||||
this.stopAudioMonitoring();
|
||||
|
||||
// Get video stream based on mode
|
||||
const displayMediaOptions: DisplayMediaStreamOptions = {
|
||||
video: {
|
||||
displaySurface: options.mode === 'viewport' ? 'browser' : 'monitor'
|
||||
} as MediaTrackConstraints,
|
||||
audio: false
|
||||
};
|
||||
|
||||
// Add preferCurrentTab hint for viewport mode
|
||||
if (options.mode === 'viewport') {
|
||||
(displayMediaOptions as any).preferCurrentTab = true;
|
||||
}
|
||||
|
||||
const videoStream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
|
||||
|
||||
// If viewport mode, try to crop to viewport element using Element Capture API
|
||||
if (options.mode === 'viewport' && options.viewportElement) {
|
||||
try {
|
||||
if ('CropTarget' in window) {
|
||||
const cropTarget = await (window as any).CropTarget.fromElement(options.viewportElement);
|
||||
const [videoTrack] = videoStream.getVideoTracks();
|
||||
await (videoTrack as any).cropTo(cropTarget);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Element Capture not supported, recording full tab:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine video with audio if enabled
|
||||
let combinedStream = videoStream;
|
||||
if (options.audioDeviceId) {
|
||||
try {
|
||||
const audioStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { deviceId: { exact: options.audioDeviceId } }
|
||||
});
|
||||
combinedStream = new MediaStream([
|
||||
...videoStream.getVideoTracks(),
|
||||
...audioStream.getAudioTracks()
|
||||
]);
|
||||
} catch (audioError) {
|
||||
console.warn('Could not add audio:', audioError);
|
||||
}
|
||||
}
|
||||
|
||||
// Store stream for cleanup
|
||||
this.currentStream = combinedStream;
|
||||
|
||||
// Create MediaRecorder
|
||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||
? 'video/webm;codecs=vp9'
|
||||
: 'video/webm';
|
||||
|
||||
this.mediaRecorder = new MediaRecorder(combinedStream, { mimeType });
|
||||
this.recordedChunks = [];
|
||||
|
||||
this.mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
this.recordedChunks.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = () => this.handleRecordingComplete();
|
||||
|
||||
// Handle stream ending (user clicks "Stop sharing")
|
||||
videoStream.getVideoTracks()[0].onended = () => {
|
||||
if (this._isRecording) {
|
||||
this.stopRecording();
|
||||
this.events.onStreamEnded?.();
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(1000); // Capture in 1-second chunks
|
||||
|
||||
// Start duration timer
|
||||
this._duration = 0;
|
||||
this.durationInterval = window.setInterval(() => {
|
||||
this._duration++;
|
||||
this.events.onDurationUpdate?.(this._duration);
|
||||
}, 1000);
|
||||
|
||||
this._isRecording = true;
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
this._isRecording = false;
|
||||
this.events.onError?.(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
stopRecording(): void {
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
|
||||
if (this.durationInterval) {
|
||||
clearInterval(this.durationInterval);
|
||||
this.durationInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRecordingComplete(): Promise<void> {
|
||||
// Create blob from recorded chunks
|
||||
const blob = new Blob(this.recordedChunks, { type: 'video/webm' });
|
||||
|
||||
this._recordedBlob = blob;
|
||||
|
||||
// Stop all tracks
|
||||
if (this.currentStream) {
|
||||
this.currentStream.getTracks().forEach(track => track.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
|
||||
this._isRecording = false;
|
||||
this.events.onRecordingComplete?.(this._recordedBlob);
|
||||
}
|
||||
|
||||
// ==================== Trim & Export ====================
|
||||
|
||||
async exportTrimmedVideo(
|
||||
videoElement: HTMLVideoElement,
|
||||
trimStart: number,
|
||||
trimEnd: number
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create a canvas for capturing frames
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoElement.videoWidth || 1280;
|
||||
canvas.height = videoElement.videoHeight || 720;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Could not get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create canvas stream for video
|
||||
const canvasStream = canvas.captureStream(30);
|
||||
|
||||
// Try to capture audio from video element
|
||||
let combinedStream: MediaStream;
|
||||
|
||||
try {
|
||||
// Create audio context to capture video's audio
|
||||
const audioCtx = new AudioContext();
|
||||
const source = audioCtx.createMediaElementSource(videoElement);
|
||||
const destination = audioCtx.createMediaStreamDestination();
|
||||
source.connect(destination);
|
||||
source.connect(audioCtx.destination); // Also play through speakers
|
||||
|
||||
// Combine video (from canvas) and audio (from video element)
|
||||
combinedStream = new MediaStream([
|
||||
...canvasStream.getVideoTracks(),
|
||||
...destination.stream.getAudioTracks()
|
||||
]);
|
||||
|
||||
// Store audioCtx for cleanup
|
||||
const cleanup = () => {
|
||||
audioCtx.close();
|
||||
};
|
||||
|
||||
this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, cleanup, resolve, reject);
|
||||
} catch (audioError) {
|
||||
console.warn('Could not capture audio, recording video only:', audioError);
|
||||
combinedStream = canvasStream;
|
||||
this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, () => {}, resolve, reject);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private recordTrimmedStream(
|
||||
video: HTMLVideoElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stream: MediaStream,
|
||||
trimStart: number,
|
||||
trimEnd: number,
|
||||
cleanup: () => void,
|
||||
resolve: (blob: Blob) => void,
|
||||
reject: (error: Error) => void
|
||||
): void {
|
||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||
? 'video/webm;codecs=vp9'
|
||||
: 'video/webm';
|
||||
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
const chunks: Blob[] = [];
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunks.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
cleanup();
|
||||
resolve(new Blob(chunks, { type: 'video/webm' }));
|
||||
};
|
||||
|
||||
recorder.onerror = (e) => {
|
||||
cleanup();
|
||||
reject(new Error('Recording error: ' + e));
|
||||
};
|
||||
|
||||
// Seek to trim start
|
||||
video.currentTime = trimStart;
|
||||
|
||||
video.onseeked = () => {
|
||||
// Start recording
|
||||
recorder.start(100);
|
||||
|
||||
// Start playing
|
||||
video.play();
|
||||
|
||||
// Draw frames to canvas
|
||||
const drawFrame = () => {
|
||||
if (video.currentTime >= trimEnd || video.paused || video.ended) {
|
||||
video.pause();
|
||||
video.onseeked = null;
|
||||
|
||||
// Give a small delay before stopping to ensure last frame is captured
|
||||
setTimeout(() => {
|
||||
if (recorder.state === 'recording') {
|
||||
recorder.stop();
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
requestAnimationFrame(drawFrame);
|
||||
};
|
||||
|
||||
drawFrame();
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Cleanup ====================
|
||||
|
||||
reset(): void {
|
||||
this._recordedBlob = null;
|
||||
this.recordedChunks = [];
|
||||
this._duration = 0;
|
||||
this._isRecording = false;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stopRecording();
|
||||
this.stopAudioMonitoring();
|
||||
this.reset();
|
||||
|
||||
if (this.currentStream) {
|
||||
this.currentStream.getTracks().forEach(track => track.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
ts_web/wcctools.interfaces.ts
Normal file
31
ts_web/wcctools.interfaces.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Configuration for a section in the WCC Tools sidebar
|
||||
*/
|
||||
export interface IWccSection {
|
||||
/** Display name for the section header */
|
||||
name: string;
|
||||
/** How items in this section are rendered - 'elements' show demos, 'pages' render directly */
|
||||
type: 'elements' | 'pages';
|
||||
/** The items in this section - either element classes or page factory functions */
|
||||
items: Record<string, any>;
|
||||
/** Optional filter function to include/exclude items */
|
||||
filter?: (name: string, item: any) => boolean;
|
||||
/** Optional sort function for ordering items */
|
||||
sort?: (a: [string, any], b: [string, any]) => number;
|
||||
/** Optional Material icon name for the section header */
|
||||
icon?: string;
|
||||
/** Whether this section should start collapsed (default: false) */
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration object for setupWccTools
|
||||
*/
|
||||
export interface IWccConfig {
|
||||
sections: IWccSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for element selection types - now section-based
|
||||
*/
|
||||
export type TElementType = 'element' | 'page';
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
|
||||
Reference in New Issue
Block a user