Skip to main content

在 Remotion 播放器中拖放

Remotion 播放器支持对鼠标事件做出反应,允许在画布上构建交互。
尝试拖动并调整下面元素的大小。

🌐 The Remotion Player supports reacting to mouse events allowing for building interactions on the canvas.
Try to drag and resize the elements below.



一般考虑

🌐 General considerations

指针事件的工作方式大致与普通 React 中相同。

🌐 Pointer events work mostly just like in regular React.

禁用 controls 属性以禁用任何阻碍元素。你可以在 播放器外部 渲染播放控制。

🌐 Disable the controls prop to disable any obstructing elements. You can render Playback controls outside of the Player.

播放器可能已应用 CSS scale()如果你测量元素,你需要除以通过 useCurrentScale() 得到的缩放比例。

🌐 The Player might have CSS scale() applied to it.
If you measure elements, you need to divide by the scale obtained by useCurrentScale().

你可以通过 inputProps 将状态更新函数传递给 Player。
或者,将 Player 封装在 React Context 中也可以。

🌐 You can pass state update functions via inputProps to the Player.
Alternatively, wrapping the Player in React Context also works.

示例

🌐 Example

让我们搭建上面的演示。

🌐 Let's build the demo above.

以下特性已被定义为目标:

🌐 The following features have been defined as goals:

  • 能够使用鼠标自由定位和调整方块的大小。
  • 一次只能选择一项。点击空白处将取消选择该项。
  • 物品不能溢出容器,然而蓝色轮廓可以溢出容器。

1
数据结构

创建一个 TypeScript 类型来描述你的项目的结构。

🌐 Create a TypeScript type that describes the shape of your item.

在这个示例中,我们存储一个标识符 id、起始和结束帧 fromdurationInFrames、位置 lefttopwidthheight,一个背景 color 以及一个布尔值 isDragging 来在拖动时微调行为。

🌐 In this example we store an identifier id, start and end frame from and durationInFrames, the position left, top, width and height, a background color as well as a boolean isDragging to fine-tune the behavior while dragging.

item.ts
export type Item = { id: number; durationInFrames: number; from: number; height: number; left: number; top: number; width: number; color: string; isDragging: boolean; };

如果你想支持不同类型的项目(文本、视频、图片),请参见 这里

🌐 If you like to support different types of items (solid, video, image), see here.

2
物品渲染

声明一个渲染该项目的 React 组件。

🌐 Declare a React component that renders the item.

Layer.tsx
import React, {useMemo} from 'react'; import {Sequence} from 'remotion'; import type {Item} from './item'; export const Layer: React.FC<{ item: Item; }> = ({item}) => { const style: React.CSSProperties = useMemo(() => { return { backgroundColor: item.color, position: 'absolute', left: item.left, top: item.top, width: item.width, height: item.height, }; }, [item.color, item.height, item.left, item.top, item.width]); return ( <Sequence key={item.id} from={item.from} durationInFrames={item.durationInFrames} layout="none" > <div style={style} /> </Sequence> ); };

通过使用 <Sequence> 组件,该项仅在 fromfrom + durationInFrames 之间显示。

🌐 By using a <Sequence> component, the item is only displayed from from until from + durationInFrames.

通过添加 layout="none"<div> 被作为 DOM 的直接子元素挂载。

🌐 By adding layout="none", the <div> is mounted as a direct child to the DOM.

3
轮廓渲染

创建一个 React 组件,当某个项目被选中或悬停时显示轮廓。

🌐 Create a React component that renders an outline when an item isselected or hovered.

SelectionOutline.tsx
import React, {useCallback, useMemo} from 'react'; import {useCurrentScale} from 'remotion'; import {ResizeHandle} from './ResizeHandle'; import type {Item} from './item'; export const SelectionOutline: React.FC<{ item: Item; changeItem: (itemId: number, updater: (item: Item) => Item) => void; setSelectedItem: React.Dispatch<React.SetStateAction<number | null>>; selectedItem: number | null; isDragging: boolean; }> = ({item, changeItem, setSelectedItem, selectedItem, isDragging}) => { const scale = useCurrentScale(); const scaledBorder = Math.ceil(2 / scale); const [hovered, setHovered] = React.useState(false); const onMouseEnter = useCallback(() => { setHovered(true); }, []); const onMouseLeave = useCallback(() => { setHovered(false); }, []); const isSelected = item.id === selectedItem; const style: React.CSSProperties = useMemo(() => { return { width: item.width, height: item.height, left: item.left, top: item.top, position: 'absolute', outline: (hovered && !isDragging) || isSelected ? `${scaledBorder}px solid #0B84F3` : undefined, userSelect: 'none', touchAction: 'none', }; }, [item, hovered, isDragging, isSelected, scaledBorder]); const startDragging = useCallback( (e: PointerEvent | React.MouseEvent) => { const initialX = e.clientX; const initialY = e.clientY; const onPointerMove = (pointerMoveEvent: PointerEvent) => { const offsetX = (pointerMoveEvent.clientX - initialX) / scale; const offsetY = (pointerMoveEvent.clientY - initialY) / scale; changeItem(item.id, (i) => { return { ...i, left: Math.round(item.left + offsetX), top: Math.round(item.top + offsetY), isDragging: true, }; }); }; const onPointerUp = () => { changeItem(item.id, (i) => { return { ...i, isDragging: false, }; }); window.removeEventListener('pointermove', onPointerMove); }; window.addEventListener('pointermove', onPointerMove, {passive: true}); window.addEventListener('pointerup', onPointerUp, { once: true, }); }, [item, scale, changeItem], ); const onPointerDown = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (e.button !== 0) { return; } setSelectedItem(item.id); startDragging(e); }, [item.id, setSelectedItem, startDragging], ); return ( <div onPointerDown={onPointerDown} onPointerEnter={onMouseEnter} onPointerLeave={onMouseLeave} style={style} > {isSelected ? ( <> <ResizeHandle item={item} setItem={changeItem} type="top-left" /> <ResizeHandle item={item} setItem={changeItem} type="top-right" /> <ResizeHandle item={item} setItem={changeItem} type="bottom-left" /> <ResizeHandle item={item} setItem={changeItem} type="bottom-right" /> </> ) : null} </div> ); };
ResizeHandle.tsx
import React, {useCallback, useMemo} from 'react'; import {useCurrentScale} from 'remotion'; import type {Item} from './item'; const HANDLE_SIZE = 8; export const ResizeHandle: React.FC<{ type: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; setItem: (itemId: number, updater: (item: Item) => Item) => void; item: Item; }> = ({type, setItem, item}) => { const scale = useCurrentScale(); const size = Math.round(HANDLE_SIZE / scale); const borderSize = 1 / scale; const sizeStyle: React.CSSProperties = useMemo(() => { return { position: 'absolute', height: size, width: size, backgroundColor: 'white', border: `${borderSize}px solid #0B84F3`, }; }, [borderSize, size]); const margin = -size / 2 - borderSize; const style: React.CSSProperties = useMemo(() => { if (type === 'top-left') { return { ...sizeStyle, marginLeft: margin, marginTop: margin, cursor: 'nwse-resize', }; } if (type === 'top-right') { return { ...sizeStyle, marginTop: margin, marginRight: margin, right: 0, cursor: 'nesw-resize', }; } if (type === 'bottom-left') { return { ...sizeStyle, marginBottom: margin, marginLeft: margin, bottom: 0, cursor: 'nesw-resize', }; } if (type === 'bottom-right') { return { ...sizeStyle, marginBottom: margin, marginRight: margin, right: 0, bottom: 0, cursor: 'nwse-resize', }; } throw new Error('Unknown type: ' + JSON.stringify(type)); }, [margin, sizeStyle, type]); const onPointerDown = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (e.button !== 0) { return; } const initialX = e.clientX; const initialY = e.clientY; const onPointerMove = (pointerMoveEvent: PointerEvent) => { const offsetX = (pointerMoveEvent.clientX - initialX) / scale; const offsetY = (pointerMoveEvent.clientY - initialY) / scale; const isLeft = type === 'top-left' || type === 'bottom-left'; const isTop = type === 'top-left' || type === 'top-right'; setItem(item.id, (i) => { const newWidth = item.width + (isLeft ? -offsetX : offsetX); const newHeight = item.height + (isTop ? -offsetY : offsetY); const newLeft = item.left + (isLeft ? offsetX : 0); const newTop = item.top + (isTop ? offsetY : 0); return { ...i, width: Math.max(1, Math.round(newWidth)), height: Math.max(1, Math.round(newHeight)), left: Math.min(item.left + item.width - 1, Math.round(newLeft)), top: Math.min(item.top + item.height - 1, Math.round(newTop)), isDragging: true, }; }); }; const onPointerUp = () => { setItem(item.id, (i) => { return { ...i, isDragging: false, }; }); window.removeEventListener('pointermove', onPointerMove); }; window.addEventListener('pointermove', onPointerMove, {passive: true}); window.addEventListener('pointerup', onPointerUp, { once: true, }); }, [item, scale, setItem, type], ); return <div onPointerDown={onPointerDown} style={style} />; };

Z-索引:在本教程的后续部分,这些元素将被渲染在所有图层之上,以便它们能够接收鼠标事件。
点击其中一个元素将选择其下方的项目。

缩放<Player /> 应用了 CSS scale 变换。
由于我们使用的是指针事件的坐标,因此需要将它们除以 useCurrentScale() 才能得到正确的位置。

边框的厚度也会受比例的影响,所以我们也需要将它们除以比例。

🌐 The thickness of the borders will also be affected by the scale, so we need to divide them by the scale too.

指针事件处理:在任何 pointerdown 事件上,我们都会调用 e.stopPropagation() 阻止事件冒泡。
之后,我们会将任何冒泡上来的事件视为空白区域的点击,并取消选择该项。

如果 e.button !== 0,我们也不会采取任何操作。这可以防止在右键点击后物品移动的漏洞。

🌐 We also don't do any action if e.button !== 0. This prevents a bug where the item moves after a right click.

我们添加 userSelect: 'none' 来禁用浏览器的原生选择和拖动行为。

🌐 We add userSelect: 'none' to disable the native selection and drag behavior of the browser.

我们还添加了 touchAction: 'none' 来在拖动时禁用移动端页面滚动。

🌐 We also add touchAction: 'none' to disable scrolling the page on mobile while dragging.

我们通过使用 isDragging 属性来跟踪物品是否正在被拖动,并在物品被拖动时禁用悬停效果。

🌐 We keep track if the item is being dragged by using the isDragging property and disable the hover effect while an item is being dragged.

4
排序大纲

让我们创建一个渲染所有轮廓的组件。 最后渲染的项目 将会在所有其他项目之上

🌐 Let's create a component that renders all outlines.
The item which gets rendered as last element will be on top of all other items.

被选中的项目应该显示在所有其他项目之上,因为它是对鼠标事件有反应的项目。

🌐 The item that is selected should be rendered on top of all other items because it is the item that is responsive to mouse events.

我们最后渲染选中的轮廓。这确保了调整大小的控制点始终位于最上层。这个逻辑只适用于轮廓,而不适用于实际图层。

🌐 We render the selected outlines last. This ensures that the resize handles are always on top.
This logic only applies to the outlines, not the actual layers.

SortedOutlines.tsx
import React from 'react'; import {Sequence} from 'remotion'; import {SelectionOutline} from './SelectionOutline'; import type {Item} from './item'; const displaySelectedItemOnTop = ( items: Item[], selectedItem: number | null, ): Item[] => { const selectedItems = items.filter((item) => item.id === selectedItem); const unselectedItems = items.filter((item) => item.id !== selectedItem); return [...unselectedItems, ...selectedItems]; }; export const SortedOutlines: React.FC<{ items: Item[]; selectedItem: number | null; changeItem: (itemId: number, updater: (item: Item) => Item) => void; setSelectedItem: React.Dispatch<React.SetStateAction<number | null>>; }> = ({items, selectedItem, changeItem, setSelectedItem}) => { const itemsToDisplay = React.useMemo( () => displaySelectedItemOnTop(items, selectedItem), [items, selectedItem], ); const isDragging = React.useMemo( () => items.some((item) => item.isDragging), [items], ); return itemsToDisplay.map((item) => { return ( <Sequence key={item.id} from={item.from} durationInFrames={item.durationInFrames} layout="none" > <SelectionOutline changeItem={changeItem} item={item} setSelectedItem={setSelectedItem} selectedItem={selectedItem} isDragging={isDragging} /> </Sequence> ); }); };

5
综合起来

接下来,我们创建一个主组件来渲染这些项目和概述。

🌐 Next, we create a main component that renders the items and the outlines.

Main.tsx
import React, {useCallback} from 'react'; import {AbsoluteFill} from 'remotion'; import type {Item} from './item'; import {Layer} from './Layer'; import {SortedOutlines} from './SortedOutlines'; export type MainProps = { readonly items: Item[]; readonly setSelectedItem: React.Dispatch<React.SetStateAction<number | null>>; readonly selectedItem: number | null; readonly changeItem: (itemId: number, updater: (item: Item) => Item) => void; }; const outer: React.CSSProperties = { backgroundColor: '#eee', }; const layerContainer: React.CSSProperties = { overflow: 'hidden', }; export const Main: React.FC<MainProps> = ({ items, setSelectedItem, selectedItem, changeItem, }) => { const onPointerDown = useCallback( (e: React.PointerEvent) => { if (e.button !== 0) { return; } setSelectedItem(null); }, [setSelectedItem], ); return ( <AbsoluteFill style={outer} onPointerDown={onPointerDown}> <AbsoluteFill style={layerContainer}> {items.map((item) => { return <Layer key={item.id} item={item} />; })} </AbsoluteFill> <SortedOutlines selectedItem={selectedItem} items={items} setSelectedItem={setSelectedItem} changeItem={changeItem} /> </AbsoluteFill> ); };

Z-轴排序: 我们先渲染图层,然后渲染轮廓。这意味着轮廓会渲染在对象的上方。

溢出行为:请注意,我们对图层和轮廓应用了不同的 overflow 行为。
我们隐藏图层的溢出,但显示轮廓的溢出。
这是一种类似于 Figma 框架的 UI 行为。

点击处理:我们也在画布上监听 pointerdown 事件。
任何未在之前使用 e.stopPropagation() 停止的事件都将被视为空白处的点击,并导致取消选中该项目。

6
渲染播放器

我们现在可以在 Remotion 播放器中渲染我们创建的 Main 组件。

🌐 We can now render the Main component that we created in the Remotion Player.

Demo.tsx
import {Player} from '@remotion/player'; import React, {useCallback, useMemo, useState} from 'react'; import type {MainProps} from './Main'; import {Main} from './Main'; import type {Item} from './item'; export const DragAndDropDemo: React.FC = () => { const [items, setItems] = useState<Item[]>([ { left: 395, top: 270, width: 540, durationInFrames: 100, from: 0, height: 540, id: 0, color: '#ccc', isDragging: false, }, { left: 985, top: 270, width: 540, durationInFrames: 100, from: 0, height: 540, id: 1, color: '#ccc', isDragging: false, }, ]); const [selectedItem, setSelectedItem] = useState<number | null>(null); const changeItem = useCallback( (itemId: number, updater: (item: Item) => Item) => { setItems((oldItems) => { return oldItems.map((item) => { if (item.id === itemId) { return updater(item); } return item; }); }); }, [], ); const inputProps: MainProps = useMemo(() => { return { items, setSelectedItem, changeItem, selectedItem, }; }, [changeItem, items, selectedItem]); return ( <Player style={{ width: '100%', }} component={Main} compositionHeight={1080} compositionWidth={1920} durationInFrames={300} fps={30} inputProps={inputProps} overflowVisible /> ); };

我们省略 controls 属性以禁用内置控件。
你可以在播放器之外添加 自定义控件

🌐 We omit the controls prop to disable the built-in controls.
You can add custom controls outside the Player.

我们添加 overflowVisible 属性,以便当轮廓超出画布时仍然可见。 请记住,我们已经隐藏了图层本身的溢出,因为它在最终视频中也不会可见。

🌐 We add the overflowVisible prop to make the outlines visible if they go outside the canvas.
Remember that we've hidden the overflow of the layers itself because it would also not be visible in the final video.

我们现在已经实现了周到的拖放交互!

🌐 We've now implemented thoughtful drag and drop interactions!

另请参阅

🌐 See also