experimental: Youtube animations (#5072)

## Description

### Not finished, should be merged as has small issues fixed

https://video-viking.wstd.work/
https://video-viking.wstd.work/entry

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
Ivan Starkov authored and GitHub committed 2025-04-06 14:47:16 +03:00
1 parent c5ce5f6313
commit e5369ef930
31 files changed
+1070 -30

No files matched your search

@@ -20,7 +20,11 @@ import {
Separator,
} from "@webstudio-is/design-system";
import { compareMedia } from "@webstudio-is/css-engine";
import { componentCategories, collectionComponent } from "@webstudio-is/sdk";
import {
componentCategories,
collectionComponent,
parseComponentName,
} from "@webstudio-is/sdk";
import type { Breakpoint, Page } from "@webstudio-is/sdk";
import type { TemplateMeta } from "@webstudio-is/template";
import {
@@ -104,12 +108,24 @@ const $componentOptions = computed(
if (category === "hidden" || category === "internal") {
continue;
}
if (
category === "animations" &&
isFeatureEnabled("animation") === false
) {
continue;
}
const [namespace, shortName] = parseComponentName(name);
if (
isFeatureEnabled("videoAnimation") === false &&
namespace === "@webstudio-is/sdk-components-animation" &&
shortName === "VideoAnimation"
) {
continue;
}
// show only xml category and collection component in xml documents
if (selectedPage?.meta.documentType === "xml") {
if (category !== "xml" && name !== collectionComponent) {
@@ -136,6 +152,17 @@ const $componentOptions = computed(
if (meta.category === "hidden" || meta.category === "internal") {
continue;
}
const [namespace, shortName] = parseComponentName(name);
if (
isFeatureEnabled("videoAnimation") === false &&
namespace === "@webstudio-is/sdk-components-animation" &&
shortName === "VideoAnimation"
) {
continue;
}
const componentMeta = metas.get(name);
const label =
meta.label ??
@@ -60,7 +60,7 @@ const $metas = computed(
const availableComponents = new Set<string>();
const metas: Meta[] = [];
for (const [name, componentMeta] of componentMetas) {
const [namespace] = parseComponentName(name);
const [namespace, shortName] = parseComponentName(name);
if (
isFeatureEnabled("animation") === false &&
namespace === "@webstudio-is/sdk-components-animation"
@@ -68,6 +68,14 @@ const $metas = computed(
continue;
}
if (
isFeatureEnabled("videoAnimation") === false &&
namespace === "@webstudio-is/sdk-components-animation" &&
shortName === "VideoAnimation"
) {
continue;
}
// only set available components from component meta
availableComponents.add(name);
metas.push({
@@ -81,6 +89,15 @@ const $metas = computed(
}
for (const [name, templateMeta] of templates) {
const componentMeta = componentMetas.get(name);
const [namespace, shortName] = parseComponentName(name);
if (
isFeatureEnabled("videoAnimation") === false &&
namespace === "@webstudio-is/sdk-components-animation" &&
shortName === "VideoAnimation"
) {
continue;
}
metas.push({
name,
category: templateMeta.category ?? "hidden",
@@ -117,10 +117,12 @@ const RangeValueInput = ({
onChange={(styleValue) => {
setIntermediateValue(styleValue);
const parsedValue = rangeUnitValueSchema.safeParse(styleValue);
if (parsedValue.success) {
onChange(parsedValue.data, true);
return;
if (styleValue?.type !== "intermediate") {
const parsedValue = rangeUnitValueSchema.safeParse(styleValue);
if (parsedValue.success) {
onChange(parsedValue.data, true);
return;
}
}
onChange(undefined, true);
@@ -156,7 +156,10 @@ const InsetValueInput = ({
intermediateValue={intermediateValue}
onChange={(styleValue) => {
setIntermediateValue(styleValue);
handleEphemeralChange(styleValue);
if (styleValue?.type !== "intermediate") {
handleEphemeralChange(styleValue);
}
}}
getOptions={() => [
{
+4
View File
@@ -34,4 +34,8 @@ sudo chown -R $USER:$(id -g) /tmp/letsencrypt
cp /tmp/letsencrypt/data/live/wstd.dev/fullchain.pem ./https/fullchain.pem
cp /tmp/letsencrypt/data/live/wstd.dev/privkey.pem ./https/privkey.pem
# Haproxy key
cd https
cat ./fullchain.pem ./privkey.pem > ./haproxy.pem
```
+52
View File
@@ -0,0 +1,52 @@
-----BEGIN CERTIFICATE-----
MIIDfTCCAwOgAwIBAgISBGlZgTahbbnWR8+AkWolrm9LMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
NTAeFw0yNTAyMTcxODI4MTVaFw0yNTA1MTgxODI4MTRaMBMxETAPBgNVBAMTCHdz
dGQuZGV2MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE58iEIOTmVWcpdfZ1LCUJ
N9YCbBkyQWAoLBw8fGSHYDy6fuJk4nu6CezlbrtztxXwhE2F0mFC1Nt534oE/AM2
N6OCAhYwggISMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUqUg23PsUvozbnvwDeUnh
H/y5tCEwHwYDVR0jBBgwFoAUnytfzzwhT50Et+0rLMTGcIvS1w0wVQYIKwYBBQUH
AQEESTBHMCEGCCsGAQUFBzABhhVodHRwOi8vZTUuby5sZW5jci5vcmcwIgYIKwYB
BQUHMAKGFmh0dHA6Ly9lNS5pLmxlbmNyLm9yZy8wHwYDVR0RBBgwFoIKKi53c3Rk
LmRldoIId3N0ZC5kZXYwEwYDVR0gBAwwCjAIBgZngQwBAgEwggEEBgorBgEEAdZ5
AgQCBIH1BIHyAPAAdgDPEVbu1S58r/OHW9lpLpvpGnFnSrAX7KwB0lt3zsw7CAAA
AZUVYJRVAAAEAwBHMEUCIBo6ysjtpEwifx6nsCchTzFPgTH4y0bUnroPqRfeTlCW
AiEA6l/rsOp+GO4IwK9kxsl9Vx4NANPd1e6MFUodif3SvGgAdgATSt8atZhCCXgM
b+9MepGkFrcjSc5YV2rfrtqnwqvgIgAAAZUVYJVCAAAEAwBHMEUCIQC+MEiwIE/n
CgObWyawZQxdRkz7mTbzfrdjGKig5CzzqwIgPG7LeDvi8+3g9fRCzMUqCul0/ZR/
I5a0TZWnTLfpgZYwCgYIKoZIzj0EAwMDaAAwZQIweMMytJ56Hpk2K/2veAWGcYIE
MhoarnMQ+SZgu+Quxa7NFnbbRJvoom+dcPxnIFqqAjEAlXhCSHLZLnnNJBVbxTu6
DSNdjRiJpSXkJlsTFvCGGJ8Xy4vBAYlrz1KSyy4AWu0D
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEVzCCAj+gAwIBAgIRAIOPbGPOsTmMYgZigxXJ/d4wDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCRTUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQNCzqK
a2GOtu/cX1jnxkJFVKtj9mZhSAouWXW0gQI3ULc/FnncmOyhKJdyIBwsz9V8UiBO
VHhbhBRrwJCuhezAUUE8Wod/Bk3U/mDR+mwt4X2VEIiiCFQPmRpM5uoKrNijgfgw
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSfK1/PPCFPnQS37SssxMZw
i9LXDTAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAH3KdNEVCQdqk0LKyuNImTKdRJY1C
2uw2SJajuhqkyGPY8C+zzsufZ+mgnhnq1A2KVQOSykOEnUbx1cy637rBAihx97r+
bcwbZM6sTDIaEriR/PLk6LKs9Be0uoVxgOKDcpG9svD33J+G9Lcfv1K9luDmSTgG
6XNFIN5vfI5gs/lMPyojEMdIzK9blcl2/1vKxO8WGCcjvsQ1nJ/Pwt8LQZBfOFyV
XP8ubAp/au3dc4EKWG9MO5zcx1qT9+NXRGdVWxGvmBFRAajciMfXME1ZuGmk3/GO
koAM7ZkjZmleyokP1LGzmfJcUd9s7eeu1/9/eg5XlXd/55GtYjAM+C4DG5i7eaNq
cm2F+yxYIPt6cbbtYVNJCGfHWqHEQ4FYStUyFnv8sjyqU8ypgZaNJ9aVcWSICLOI
E1/Qv/7oKsnZCWJ926wU6RqG1OYPGOi1zuABhLw61cuPVDT28nQS/e6z95cJXq0e
K1BcaJ6fJZsmbjRgD5p3mvEf5vdQM7MCEvU0tHbsx2I5mHHJoABHb8KVBgWp/lcX
GWiWaeOyB7RP+OfDtvi2OsapxXiV7vNVs7fMlrRjY1joKaqmmycnBvAq14AEbtyL
sVfOS66B8apkeFX2NY4XPEYV4ZSCe8VHPrdrERk2wILG3T/EGmSIkCYVUMSnjmJd
VQD9F6Na/+zmXCc=
-----END CERTIFICATE-----
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgvt6DDpBvBQ2MIS0K
JoESPznVFSHunvJfrATHECtBkAShRANCAATnyIQg5OZVZyl19nUsJQk31gJsGTJB
YCgsHDx8ZIdgPLp+4mTie7oJ7OVuu3O3FfCETYXSYULU23nfigT8AzY3
-----END PRIVATE KEY-----
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Proxies https://wstd.dev:4001 to http://localhost:4002
# Used for debugging when HTTPS is required
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PEM_PATH="$SCRIPT_DIR/haproxy.pem"
TMP_CFG=$(mktemp /tmp/haproxy.XXXXXX)
echo $TMP_CFG
cat <<EOF > "$TMP_CFG"
frontend proxy-https
bind *:4001 ssl crt ${PEM_PATH} alpn h2,http/1.1
default_backend proxy
backend proxy
balance roundrobin
mode http
http-request set-header X-Forwarded-Host %[req.hdr(Host)]
server rgw1 localhost:4002 check
EOF
exec haproxy -f "$TMP_CFG" -db
+1
View File
@@ -3,4 +3,5 @@ export const internalComponents = false;
export const unsupportedBrowsers = false;
export const aiRadixComponents = false;
export const animation = false;
export const videoAnimation = false;
export const resourceProp = false;
@@ -16,6 +16,7 @@ export const propsToArgTypes = (
// Exclude webstudio builder props see react-sdk/src/tree/webstudio-component.tsx
.filter(([propName]) => propName.startsWith("data-ws-") === false)
.filter(([propName]) => propName.startsWith("$webstudio") === false)
.filter(([propName]) => propName.startsWith("$") === false)
// Exclude props that are in the exclude list
.filter(([propName]) => exclude.includes(propName) === false)
.map(([propName, propItem]) => {
@@ -0,0 +1,5 @@
import type { PropMeta } from "@webstudio-is/sdk";
export const props: Record<string, PropMeta> = {
timeline: { required: false, control: "boolean", type: "boolean" },
};
@@ -1,3 +1,4 @@
export { AnimateChildren } from "./animate-children";
export { AnimateText } from "./animate-text";
export { StaggerAnimation } from "./stagger-animation";
export { VideoAnimation } from "./video-animation";
@@ -1,3 +1,4 @@
export { meta as AnimateChildren } from "./animate-children.ws";
export { meta as AnimateText } from "./animate-text.ws";
export { meta as StaggerAnimation } from "./stagger-animation.ws";
export { meta as VideoAnimation } from "./video-animation.ws";
@@ -1,3 +1,4 @@
export { propsMeta as AnimateChildren } from "./animate-children.ws";
export { propsMeta as AnimateText } from "./animate-text.ws";
export { propsMeta as StaggerAnimation } from "./stagger-animation.ws";
export { propsMeta as VideoAnimation } from "./video-animation.ws";
@@ -0,0 +1,12 @@
import { forwardRef, type ElementRef } from "react";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type ProgressAnimationProps<T extends Record<string, unknown> = {}> = {
children: React.ReactNode;
} & T;
export const createProgressAnimation = <T extends Record<string, unknown>>() =>
forwardRef<ElementRef<"div">, ProgressAnimationProps<T>>((props, ref) => {
// Implementation is located in private-src
return <div ref={ref} {...props} />;
});
@@ -0,0 +1,3 @@
import { createProxy } from "@webstudio-is/template";
export const animation = createProxy("@webstudio-is/sdk-components-animation:");
@@ -1 +1 @@
export {};
export { meta as VideoAnimation } from "./video-animation.template";
@@ -0,0 +1,22 @@
import { $, type TemplateMeta } from "@webstudio-is/template";
import { YoutubeIcon } from "@webstudio-is/icons/svg";
import { animation } from "./shared/proxy";
export const meta: TemplateMeta = {
category: "animations",
description: "Video Animation",
order: 2,
icon: YoutubeIcon,
template: (
<animation.VideoAnimation>
<$.Video
preload="auto"
autoPlay={true}
muted={true}
playsInline={true}
crossOrigin="anonymous"
></$.Video>
</animation.VideoAnimation>
),
};
@@ -0,0 +1,5 @@
import { createProgressAnimation } from "./shared/create-progress-animation";
export const VideoAnimation = createProgressAnimation<{ timeline?: boolean }>();
const displayName = "VideoAnimation";
VideoAnimation.displayName = displayName;
@@ -0,0 +1,22 @@
import { Youtube1cIcon } from "@webstudio-is/icons/svg";
import type { WsComponentMeta, WsComponentPropsMeta } from "@webstudio-is/sdk";
import { animation } from "./shared/meta";
import { props } from "./__generated__/video-animation.props";
export const meta: WsComponentMeta = {
type: "container",
icon: Youtube1cIcon,
label: "Video Animation",
constraints: [
{ relation: "parent", component: { $eq: animation.AnimateChildren } },
{
relation: "child",
text: false,
},
],
};
export const propsMeta: WsComponentPropsMeta = {
props,
initialProps: ["timeline"],
};
@@ -71,6 +71,7 @@
"@webstudio-is/sdk-cli": "workspace:*",
"@webstudio-is/template": "workspace:*",
"@webstudio-is/tsconfig": "workspace:*",
"nanostores": "^0.11.3",
"react": "18.3.0-canary-14898b6a9-20240318",
"react-dom": "18.3.0-canary-14898b6a9-20240318",
"vitest": "^3.0.8"
@@ -536,15 +536,7 @@ export const props: Record<string, PropMeta> = {
description:
"Overrides the browser's default tab order and follows the one specified instead.",
},
tag: {
description:
"Use HTML heading levels (h1-h6) to structure content hierarchically, with h1 as the main title and subsequent levels representing sub-sections. Maintain a logical order and avoid skipping levels to ensure consistent and meaningful organization.",
required: false,
control: "select",
type: "string",
defaultValue: "h1",
options: ["h1", "h2", "h3", "h4", "h5", "h6"],
},
tag: { required: false, control: "text", type: "string" },
title: {
required: false,
control: "text",
@@ -536,15 +536,7 @@ export const props: Record<string, PropMeta> = {
description:
"Overrides the browser's default tab order and follows the one specified instead.",
},
tag: {
description:
"Use this property to change the HTML tag of this element to semantically structure and describe the content of a webpage. This can be important for accessibility tools and search engine optimization.",
required: false,
control: "select",
type: "string",
defaultValue: "div",
options: ["div", "cite", "figcaption", "span"],
},
tag: { required: false, control: "text", type: "string" },
title: {
required: false,
control: "text",
@@ -0,0 +1,647 @@
import type { PropMeta } from "@webstudio-is/sdk";
export const props: Record<string, PropMeta> = {
about: { required: false, control: "text", type: "string" },
accessKey: {
required: false,
control: "text",
type: "string",
description: "Keyboard shortcut to activate or add focus to the element.",
},
"aria-activedescendant": {
description:
"Identifies the currently active element when DOM focus is on a composite widget, textbox, group, or application.",
required: false,
control: "text",
type: "string",
},
"aria-atomic": {
description:
"Indicates whether assistive technologies will present all, or only parts of, the changed region based on the change notifications defined by the aria-relevant attribute.",
required: false,
control: "boolean",
type: "boolean",
},
"aria-autocomplete": {
description:
"Indicates whether inputting text could trigger display of one or more predictions of the user's intended value for an input and specifies how predictions would be\npresented if they are made.",
required: false,
control: "select",
type: "string",
options: ["list", "none", "inline", "both"],
},
"aria-braillelabel": {
description:
"Defines a string value that labels the current element, which is intended to be converted into Braille.",
required: false,
control: "text",
type: "string",
},
"aria-brailleroledescription": {
description:
"Defines a human-readable, author-localized abbreviated description for the role of an element, which is intended to be converted into Braille.",
required: false,
control: "text",
type: "string",
},
"aria-busy": { required: false, control: "boolean", type: "boolean" },
"aria-checked": {
description:
'Indicates the current "checked" state of checkboxes, radio buttons, and other widgets.',
required: false,
control: "text",
type: "string",
},
"aria-colcount": {
description:
"Defines the total number of columns in a table, grid, or treegrid.",
required: false,
control: "number",
type: "number",
},
"aria-colindex": {
description:
"Defines an element's column index or position with respect to the total number of columns within a table, grid, or treegrid.",
required: false,
control: "number",
type: "number",
},
"aria-colindextext": {
description: "Defines a human readable text alternative of aria-colindex.",
required: false,
control: "text",
type: "string",
},
"aria-colspan": {
description:
"Defines the number of columns spanned by a cell or gridcell within a table, grid, or treegrid.",
required: false,
control: "number",
type: "number",
},
"aria-controls": {
description:
"Identifies the element (or elements) whose contents or presence are controlled by the current element.",
required: false,
control: "text",
type: "string",
},
"aria-current": {
description:
"Indicates the element that represents the current item within a container or set of related elements.",
required: false,
control: "text",
type: "string",
},
"aria-describedby": {
description:
"Identifies the element (or elements) that describes the object.",
required: false,
control: "text",
type: "string",
},
"aria-description": {
description:
"Defines a string value that describes or annotates the current element.",
required: false,
control: "text",
type: "string",
},
"aria-details": {
description:
"Identifies the element that provides a detailed, extended description for the object.",
required: false,
control: "text",
type: "string",
},
"aria-disabled": {
description:
"Indicates that the element is perceivable but disabled, so it is not editable or otherwise operable.",
required: false,
control: "boolean",
type: "boolean",
},
"aria-dropeffect": {
description:
"Indicates what functions can be performed when a dragged object is released on the drop target.",
required: false,
control: "select",
type: "string",
options: ["link", "none", "copy", "execute", "move", "popup"],
},
"aria-errormessage": {
description:
"Identifies the element that provides an error message for the object.",
required: false,
control: "text",
type: "string",
},
"aria-expanded": {
description:
"Indicates whether the element, or another grouping element it controls, is currently expanded or collapsed.",
required: false,
control: "boolean",
type: "boolean",
},
"aria-flowto": {
description:
"Identifies the next element (or elements) in an alternate reading order of content which, at the user's discretion,\nallows assistive technology to override the general default of reading in document source order.",
required: false,
control: "text",
type: "string",
},
"aria-grabbed": {
description:
'Indicates an element\'s "grabbed" state in a drag-and-drop operation.',
required: false,
control: "boolean",
type: "boolean",
},
"aria-haspopup": {
description:
"Indicates the availability and type of interactive popup element, such as menu or dialog, that can be triggered by an element.",
required: false,
control: "text",
type: "string",
},
"aria-hidden": {
description:
"Indicates whether the element is exposed to an accessibility API.",
required: false,
control: "boolean",
type: "boolean",
},
"aria-invalid": {
description:
"Indicates the entered value does not conform to the format expected by the application.",
required: false,
control: "text",
type: "string",
},
"aria-keyshortcuts": {
description:
"Indicates keyboard shortcuts that an author has implemented to activate or give focus to an element.",
required: false,
control: "text",
type: "string",
},
"aria-label": {
description:
"Provides the accessible name that describes an interactive element if no other accessible name exists, for example in a button that contains an image with no text.",
required: false,
control: "text",
type: "string",
},
"aria-labelledby": {
description:
"Identifies the element (or elements) that labels the current element.",
required: false,
control: "text",
type: "string",
},
"aria-level": {
description:
"Defines the hierarchical level of an element within a structure.",
required: false,
control: "number",
type: "number",
},
"aria-live": {
description:
"Indicates that an element will be updated, and describes the types of updates the user agents, assistive technologies, and user can expect from the live region.",
required: false,
control: "radio",
type: "string",
options: ["off", "assertive", "polite"],
},
"aria-modal": {
description: "Indicates whether an element is modal when displayed.",
required: false,
control: "boolean",
type: "boolean",
},
"aria-multiline": {
description:
"Indicates whether a text box accepts multiple lines of input or only a single line.",
required: false,
control: "boolean",
type: "boolean",
},
"aria-multiselectable": {
description:
"Indicates that the user may select more than one item from the current selectable descendants.",
required: false,
control: "boolean",
type: "boolean",
},
"aria-orientation": {
description:
"Indicates whether the element's orientation is horizontal, vertical, or unknown/ambiguous.",
required: false,
control: "radio",
type: "string",
options: ["horizontal", "vertical"],
},
"aria-owns": {
description:
"Identifies an element (or elements) in order to define a visual, functional, or contextual parent/child relationship\nbetween DOM elements where the DOM hierarchy cannot be used to represent the relationship.",
required: false,
control: "text",
type: "string",
},
"aria-placeholder": {
description:
"Defines a short hint (a word or short phrase) intended to aid the user with data entry when the control has no value.\nA hint could be a sample value or a brief description of the expected format.",
required: false,
control: "text",
type: "string",
},
"aria-posinset": {
description:
"Defines an element's number or position in the current set of listitems or treeitems. Not required if all elements in the set are present in the DOM.",
required: false,
control: "number",
type: "number",
},
"aria-pressed": {
description: 'Indicates the current "pressed" state of toggle buttons.',
required: false,
control: "text",
type: "string",
},
"aria-readonly": {
description:
"Indicates that the element is not editable, but is otherwise operable.",
required: false,
control: "boolean",
type: "boolean",
},
"aria-relevant": {
description:
"Indicates what notifications the user agent will trigger when the accessibility tree within a live region is modified.",
required: false,
control: "select",
type: "string",
options: [
"text",
"additions",
"additions removals",
"additions text",
"all",
"removals",
"removals additions",
"removals text",
"text additions",
"text removals",
],
},
"aria-required": {
description:
"Indicates that user input is required on the element before a form may be submitted.",
required: false,
control: "boolean",
type: "boolean",
},
"aria-roledescription": {
description:
"Defines a human-readable, author-localized description for the role of an element.",
required: false,
control: "text",
type: "string",
},
"aria-rowcount": {
description:
"Defines the total number of rows in a table, grid, or treegrid.",
required: false,
control: "number",
type: "number",
},
"aria-rowindex": {
description:
"Defines an element's row index or position with respect to the total number of rows within a table, grid, or treegrid.",
required: false,
control: "number",
type: "number",
},
"aria-rowindextext": {
description: "Defines a human readable text alternative of aria-rowindex.",
required: false,
control: "text",
type: "string",
},
"aria-rowspan": {
description:
"Defines the number of rows spanned by a cell or gridcell within a table, grid, or treegrid.",
required: false,
control: "number",
type: "number",
},
"aria-selected": {
description: 'Indicates the current "selected" state of various widgets.',
required: false,
control: "boolean",
type: "boolean",
},
"aria-setsize": {
description:
"Defines the number of items in the current set of listitems or treeitems. Not required if all elements in the set are present in the DOM.",
required: false,
control: "number",
type: "number",
},
"aria-sort": {
description:
"Indicates if items in a table or grid are sorted in ascending or descending order.",
required: false,
control: "select",
type: "string",
options: ["none", "ascending", "descending", "other"],
},
"aria-valuemax": {
description: "Defines the maximum allowed value for a range widget.",
required: false,
control: "number",
type: "number",
},
"aria-valuemin": {
description: "Defines the minimum allowed value for a range widget.",
required: false,
control: "number",
type: "number",
},
"aria-valuenow": {
description: "Defines the current value for a range widget.",
required: false,
control: "number",
type: "number",
},
"aria-valuetext": {
description:
"Defines the human readable text alternative of aria-valuenow for a range widget.",
required: false,
control: "text",
type: "string",
},
autoCapitalize: {
required: false,
control: "text",
type: "string",
description:
"Sets whether input is automatically capitalized when entered by user.",
},
autoCorrect: { required: false, control: "text", type: "string" },
autoFocus: {
required: false,
control: "boolean",
type: "boolean",
description:
"Indicates that an element should be focused on page load, or when its parent dialog is displayed.",
},
autoPlay: {
required: false,
control: "boolean",
type: "boolean",
description: "The audio or video should play as soon as possible.",
},
autoSave: { required: false, control: "text", type: "string" },
className: { required: false, control: "text", type: "string" },
color: {
required: false,
control: "color",
type: "string",
description:
"This attribute sets the text color using either a named color or a color specified in the hexadecimal #RRGGBB format. Note: This is a legacy attribute. Please use the CSS color property instead.",
},
content: {
required: false,
control: "text",
type: "string",
description:
"A value associated with http-equiv orname depending on the context.",
},
contextMenu: {
required: false,
control: "text",
type: "string",
description:
"Defines the ID of a menu element which willserve as the element's context menu.",
},
controls: {
required: false,
control: "boolean",
type: "boolean",
description:
"Indicates whether the browser should show playback controls to the user.",
},
controlsList: { required: false, control: "text", type: "string" },
crossOrigin: {
required: false,
control: "radio",
type: "string",
options: ["", "anonymous", "use-credentials"],
description: "How the element handles cross-origin requests",
},
datatype: { required: false, control: "text", type: "string" },
defaultChecked: { required: false, control: "boolean", type: "boolean" },
defaultValue: { required: false, control: "text", type: "string" },
dir: {
required: false,
control: "text",
type: "string",
description:
"Defines the text direction. Allowed values are ltr (Left-To-Right) or rtl (Right-To-Left)",
},
disablePictureInPicture: {
required: false,
control: "boolean",
type: "boolean",
},
disableRemotePlayback: {
required: false,
control: "boolean",
type: "boolean",
},
draggable: {
required: false,
control: "boolean",
type: "boolean",
description: "Defines whether the element can be dragged.",
},
height: {
required: false,
control: "number",
type: "number",
description: "Defines the image’s height in pixels.",
},
hidden: {
required: false,
control: "boolean",
type: "boolean",
description:
"Prevents rendering of given element, while keeping child elements, e.g. script elements, active.",
},
id: {
required: false,
control: "text",
type: "string",
description:
"Often used with CSS to style a specific element. The value of this attribute must be unique.",
},
inputMode: {
description:
"Hints at the type of data that might be entered by the user while editing the element or its contents",
required: false,
control: "select",
type: "string",
options: [
"search",
"text",
"none",
"tel",
"url",
"email",
"numeric",
"decimal",
],
},
is: {
description:
"Specify that a standard HTML element should behave like a defined custom built-in element",
required: false,
control: "text",
type: "string",
},
itemID: { required: false, control: "text", type: "string" },
itemProp: { required: false, control: "text", type: "string" },
itemRef: { required: false, control: "text", type: "string" },
itemScope: { required: false, control: "boolean", type: "boolean" },
itemType: { required: false, control: "text", type: "string" },
lang: {
required: false,
control: "text",
type: "string",
description: "Defines the language used in the element.",
},
loop: {
required: false,
control: "boolean",
type: "boolean",
description:
"Indicates whether the media should start playing from the start when it's finished.",
},
mediaGroup: { required: false, control: "text", type: "string" },
muted: {
required: false,
control: "boolean",
type: "boolean",
description:
"Indicates whether the audio will be initially silenced on page load.",
},
nonce: { required: false, control: "text", type: "string" },
playsInline: {
required: false,
control: "boolean",
type: "boolean",
description:
'A Boolean attribute indicating that the video is to be played "inline"; that is, within the element\'s playback area. Note that the absence of this attribute does not imply that the video will always be played in fullscreen.',
},
poster: {
required: false,
control: "text",
type: "string",
description:
"A URL indicating a poster frame to show until the user plays or seeks.",
},
prefix: { required: false, control: "text", type: "string" },
preload: {
required: false,
control: "text",
type: "string",
description:
"Indicates whether the whole resource, parts of it or nothing should be preloaded.",
},
property: { required: false, control: "text", type: "string" },
radioGroup: { required: false, control: "text", type: "string" },
rel: {
required: false,
control: "text",
type: "string",
description:
"Specifies the relationship of the target object to the link object.",
},
resource: { required: false, control: "text", type: "string" },
results: { required: false, control: "number", type: "number" },
rev: { required: false, control: "text", type: "string" },
role: {
required: false,
control: "text",
type: "string",
description:
"Defines an explicit role for an element for use by assistive technologies.",
},
security: { required: false, control: "text", type: "string" },
slot: {
required: false,
control: "text",
type: "string",
description: "Assigns a slot in a shadow DOM shadow tree to an element.",
},
spellCheck: {
required: false,
control: "boolean",
type: "boolean",
description: "Indicates whether spell checking is allowed for the element.",
},
src: {
required: false,
control: "text",
type: "string",
description: "The URL of the embeddable content.",
},
suppressContentEditableWarning: {
required: false,
control: "boolean",
type: "boolean",
},
suppressHydrationWarning: {
required: false,
control: "boolean",
type: "boolean",
},
tabIndex: {
required: false,
control: "number",
type: "number",
description:
"Overrides the browser's default tab order and follows the one specified instead.",
},
title: {
required: false,
control: "text",
type: "string",
description:
"Text to be displayed in a tooltip when hovering over the element.",
},
translate: {
required: false,
control: "radio",
type: "string",
options: ["yes", "no"],
description:
"Specify whether an element's attribute values and the values of its text node children are to be translated when the page is localized, or whether to leave them unchanged.",
},
typeof: { required: false, control: "text", type: "string" },
unselectable: {
required: false,
control: "radio",
type: "string",
options: ["on", "off"],
},
vocab: { required: false, control: "text", type: "string" },
width: {
required: false,
control: "number",
type: "number",
description: "Defines the image’s width in pixels.",
},
};
@@ -42,3 +42,4 @@ export { HeadSlot } from "./head-slot";
export { HeadLink } from "./head-link";
export { HeadMeta } from "./head-meta";
export { HeadTitle } from "./head-title";
export { Video } from "./video";
@@ -42,3 +42,4 @@ export { meta as HeadSlot } from "./head-slot.ws";
export { meta as HeadLink } from "./head-link.ws";
export { meta as HeadMeta } from "./head-meta.ws";
export { meta as HeadTitle } from "./head-title.ws";
export { meta as Video } from "./video.ws";
@@ -42,3 +42,4 @@ export { propsMeta as HeadSlot } from "./head-slot.ws";
export { propsMeta as HeadLink } from "./head-link.ws";
export { propsMeta as HeadMeta } from "./head-meta.ws";
export { propsMeta as HeadTitle } from "./head-title.ws";
export { propsMeta as Video } from "./video.ws";
+142
View File
@@ -0,0 +1,142 @@
import {
forwardRef,
type ElementRef,
type ComponentProps,
useEffect,
useId,
} from "react";
import type { Atom } from "nanostores";
export const defaultTag = "video";
// To avoid composing refs
const videoIdAttribute = "data-ws-video-id";
const READY_STATE = {
HAVE_NOTHING: 0,
HAVE_METADATA: 1,
HAVE_CURRENT_DATA: 2,
HAVE_FUTURE_DATA: 3,
HAVE_ENOUGH_DATA: 4,
};
export const Video = forwardRef<
ElementRef<typeof defaultTag>,
ComponentProps<typeof defaultTag> & {
$progress?: Atom<number | undefined>;
$visible?: Atom<boolean>;
$timeline?: boolean;
}
>(({ $progress, $visible, $timeline, children, ...props }, ref) => {
const id = useId();
const videoIdProps = {
[videoIdAttribute]: id,
};
useEffect(() => {
if ($progress === undefined) {
return;
}
if ($visible === undefined) {
return;
}
const video = document.querySelector(`[${videoIdAttribute}="${id}"]`);
if (video === null) {
return;
}
if (false === video instanceof HTMLVideoElement) {
return;
}
// Safari IOS does not seek video without play called at least once
// this is in case autoPlay is not set
video.play().catch(() => {
/**/
});
video.pause();
if ($timeline) {
return $progress.subscribe((progress) => {
if (video.readyState < READY_STATE.HAVE_METADATA) {
return;
}
if (!video.paused) {
video.pause();
}
if (video.seeking) {
return;
}
let duration = video.duration;
if (Number.isNaN(duration)) {
return;
}
if (!Number.isFinite(duration)) {
// Set to 60s on streaming videos
duration = 60;
}
video.currentTime = (progress ?? 0) * duration;
});
}
let isPlaying = false;
let isVisible = false;
const unsubscribeVisible = $visible.subscribe((visible) => {
isVisible = visible;
// Seek video only if it's invisible to avoid jumps
if (isVisible === false && isPlaying === false && !video.loop) {
video.currentTime = 0;
}
});
const unsubscribeProgress = $progress.subscribe((progress) => {
if (
isPlaying &&
(progress === undefined || progress === 0 || progress === 1)
) {
isPlaying = false;
video.pause();
// Seek video only if it's invisible to avoid jumps
if (isVisible === false && isPlaying === false && !video.loop) {
video.currentTime = 0;
}
return;
}
if (!isPlaying) {
isPlaying = true;
if (!video.ended) {
video.play().catch(() => {
/**/
});
}
}
});
return () => {
unsubscribeProgress();
unsubscribeVisible();
};
}, [$progress, $timeline, $visible, id]);
return (
<video {...props} {...videoIdProps} ref={ref}>
{children}
</video>
);
});
Video.displayName = "Video";
@@ -0,0 +1,37 @@
import { VideoIcon } from "@webstudio-is/icons/svg";
import {
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
import { props } from "./__generated__/video.props";
export const meta: WsComponentMeta = {
type: "control",
icon: VideoIcon,
presetStyle: {
video: [
{
property: "max-width",
value: { type: "unit", unit: "%", value: 100 },
},
],
},
};
export const propsMeta: WsComponentPropsMeta = {
props,
initialProps: [
"id",
"className",
"width",
"height",
"src",
"autoPlay",
"controls",
"loop",
"muted",
"preload",
"playsInline",
],
};
+23 -3
View File
@@ -189,7 +189,10 @@ const getVideoId = (url?: string) => {
}
};
const getVideoUrl = (options: YouTubePlayerOptions, videoUrlOrigin: string) => {
const getVideoUrl = (
options: YouTubePlayerOptions & { enablejsapi: boolean },
videoUrlOrigin: string
) => {
const videoId = getVideoId(options.url);
const url = new URL(videoUrlOrigin);
@@ -208,7 +211,10 @@ const getVideoUrl = (options: YouTubePlayerOptions, videoUrlOrigin: string) => {
}
}
const optionsKeys = Object.keys(options) as (keyof YouTubePlayerParameters)[];
const optionsKeys = Object.keys(options) as (
| keyof YouTubePlayerParameters
| "enablejsapi"
)[];
const parameters: Record<string, string | undefined> = {};
@@ -303,7 +309,9 @@ const getVideoUrl = (options: YouTubePlayerOptions, videoUrlOrigin: string) => {
case "playlist":
parameters.playlist = options.playlist;
break;
case "enablejsapi":
parameters.enablejsapi = options.enablejsapi ? "1" : "0";
break;
default:
optionsKey satisfies never;
}
@@ -468,6 +476,11 @@ export const YouTube = forwardRef<Ref, Props>(
loading = "lazy",
autoplay,
showPreview,
showAnnotations,
showCaptions,
showControls,
allowFullscreen,
keyboard,
children,
privacyEnhancedMode,
inline = false,
@@ -483,12 +496,19 @@ export const YouTube = forwardRef<Ref, Props>(
(privacyEnhancedMode ?? true)
? PLAYER_PRIVACY_ENHANVED_MODE_CDN
: PLAYER_ORIGINAL_CDN;
const videoUrl = getVideoUrl(
{
...rest,
inline,
url,
keyboard,
showAnnotations,
showCaptions,
allowFullscreen,
showControls,
autoplay: true,
enablejsapi: false,
},
videoUrlOrigin
);
+3
View File
@@ -1990,6 +1990,9 @@ importers:
'@webstudio-is/tsconfig':
specifier: workspace:*
version: link:../tsconfig
nanostores:
specifier: ^0.11.3
version: 0.11.3
react:
specifier: 18.3.0-canary-14898b6a9-20240318
version: 18.3.0-canary-14898b6a9-20240318