Remotion LabRemotion Lab
Player在 Remotion Player 中實作拖放功能

在 Remotion Player 中實作拖放功能

學習如何在 Remotion Player 中使用滑鼠事件建立可拖曳和縮放的互動式元素。

在 Remotion Player 中實作拖放功能

Remotion Player 支援對滑鼠事件做出反應,允許在畫布上建立互動式效果。

一般注意事項

指標事件的運作方式與一般 React 中基本相同。

停用 controls prop 以移除任何可能造成遮擋的元素。你可以在 Player 外部渲染播放控制項

Player 可能套用了 CSS scale() 變換。如果你量測元素,需要除以 useCurrentScale() 所取得的縮放值。

你可以透過 inputProps 將狀態更新函式傳遞給 Player。另外,將 Player 包裝在 React Context 中也可以達到相同效果。

完整範例

以下是一個完整的拖放與縮放範例實作。

定義資料型別

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

縮放控制點元件

ResizeHandle.tsx
import React, { useCallback, useMemo } from 'react';
import { useCurrentScale } from 'remotion';
import type { Item } from './item';
 
const HANDLE_SIZE = 8;
 
type HandleType = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
 
export const ResizeHandle: React.FC<{
  type: HandleType;
  setItem: (id: 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('未知的類型:' + 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) => ({ ...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 style={style} onPointerDown={onPointerDown} />;
};

圖層元件

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
      from={item.from}
      durationInFrames={item.durationInFrames}
    >
      <div style={style} />
    </Sequence>
  );
};

選取外框元件(包含拖曳)

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: (id: 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 = selectedItem === item.id;
  const showOutline = isSelected || hovered;
 
  const style: React.CSSProperties = useMemo(() => {
    return {
      position: 'absolute',
      left: item.left,
      top: item.top,
      width: item.width,
      height: item.height,
      cursor: isDragging ? 'grabbing' : 'grab',
      outline: showOutline
        ? `${scaledBorder}px solid #0B84F3`
        : undefined,
    };
  }, [
    isDragging,
    item.height,
    item.left,
    item.top,
    item.width,
    scaledBorder,
    showOutline,
  ]);
 
  const onPointerDown = useCallback(
    (e: React.MouseEvent) => {
      if (e.button !== 0) return;
      e.stopPropagation();
      setSelectedItem(item.id);
 
      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) => ({
          ...i,
          left: Math.round(item.left + offsetX),
          top: Math.round(item.top + offsetY),
          isDragging: true,
        }));
      };
 
      const onPointerUp = () => {
        changeItem(item.id, (i) => ({ ...i, isDragging: false }));
        window.removeEventListener('pointermove', onPointerMove);
      };
 
      window.addEventListener('pointermove', onPointerMove, { passive: true });
      window.addEventListener('pointerup', onPointerUp, { once: true });
    },
    [changeItem, item.id, item.left, item.top, scale, setSelectedItem],
  );
 
  return (
    <div
      style={style}
      onPointerDown={onPointerDown}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      {isSelected && (
        <>
          <ResizeHandle type="top-left" setItem={changeItem} item={item} />
          <ResizeHandle type="top-right" setItem={changeItem} item={item} />
          <ResizeHandle type="bottom-left" setItem={changeItem} item={item} />
          <ResizeHandle type="bottom-right" setItem={changeItem} item={item} />
        </>
      )}
    </div>
  );
};

主應用程式元件

App.tsx
import React, { useCallback, useState } from 'react';
import { Player } from '@remotion/player';
import type { Item } from './item';
import { Layer } from './Layer';
import { SelectionOutline } from './SelectionOutline';
 
const INITIAL_ITEMS: Item[] = [
  {
    id: 1,
    from: 0,
    durationInFrames: 300,
    left: 100,
    top: 100,
    width: 200,
    height: 150,
    color: '#3498db',
    isDragging: false,
  },
  {
    id: 2,
    from: 0,
    durationInFrames: 300,
    left: 350,
    top: 200,
    width: 180,
    height: 120,
    color: '#e74c3c',
    isDragging: false,
  },
];
 
const MyComposition: React.FC<{
  items: Item[];
  setItems: React.Dispatch<React.SetStateAction<Item[]>>;
}> = ({ items, setItems }) => {
  const [selectedItem, setSelectedItem] = useState<number | null>(null);
 
  const changeItem = useCallback(
    (id: number, updater: (item: Item) => Item) => {
      setItems((prev) =>
        prev.map((item) => (item.id === id ? updater(item) : item))
      );
    },
    [setItems],
  );
 
  return (
    <div style={{ position: 'relative', width: '100%', height: '100%' }}>
      {items.map((item) => (
        <Layer key={item.id} item={item} />
      ))}
      {items.map((item) => (
        <SelectionOutline
          key={item.id}
          item={item}
          changeItem={changeItem}
          setSelectedItem={setSelectedItem}
          selectedItem={selectedItem}
          isDragging={item.isDragging}
        />
      ))}
    </div>
  );
};
 
export const App: React.FC = () => {
  const [items, setItems] = useState<Item[]>(INITIAL_ITEMS);
 
  return (
    <Player
      component={MyComposition}
      inputProps={{ items, setItems }}
      durationInFrames={300}
      compositionWidth={1920}
      compositionHeight={1080}
      fps={30}
      style={{ width: '100%' }}
    />
  );
};

關鍵概念說明

縮放補償

由於 Player 可能被 CSS 縮放,所有滑鼠位移計算都需要除以當前縮放值:

const scale = useCurrentScale();
const offsetX = (pointerMoveEvent.clientX - initialX) / scale;

使用 window 監聽器

拖曳期間的 pointermovepointerup 事件應該掛載在 window 上,而不是元素本身,這樣即使滑鼠移出元素範圍,拖曳仍能繼續:

window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('pointerup', onPointerUp, { once: true });

狀態共享

透過 inputProps 傳遞狀態:

<Player
  component={MyComposition}
  inputProps={{ items, setItems }}
  // ...
/>

另請參閱