diff --git a/packages/flat-components/package.json b/packages/flat-components/package.json index 8d1bc369d46..9995b53502c 100644 --- a/packages/flat-components/package.json +++ b/packages/flat-components/package.json @@ -19,6 +19,7 @@ "@netless/flat-i18n": "workspace:*", "@netless/flat-server-api": "workspace:*", "@netless/flat-services": "workspace:*", + "@wopjs/dom": "^0.1.3", "antd": "^4.23.2", "classnames": "^2.3.1", "date-fns": "^2.29.3", diff --git a/packages/flat-components/src/components/ClassroomPage/AvatarWindow/AvatarWindow.stories.tsx b/packages/flat-components/src/components/ClassroomPage/AvatarWindow/AvatarWindow.stories.tsx new file mode 100644 index 00000000000..f67a3bb5531 --- /dev/null +++ b/packages/flat-components/src/components/ClassroomPage/AvatarWindow/AvatarWindow.stories.tsx @@ -0,0 +1,58 @@ +import React, { useState } from "react"; +import { Meta, Story } from "@storybook/react"; +import { AvatarWindow, AvatarWindowProps, fixRect } from "."; +import { VideoAvatar } from "../VideoAvatar"; + +const storyMeta: Meta = { + title: "ClassroomPage/AvatarWindow", + component: AvatarWindow, + argTypes: {}, +}; + +export default storyMeta; + +export const Overview: Story> = props => { + const [camera, setCamera] = useState(false); + const [mic, setMic] = useState(true); + const [rect, setRect] = useState({ x: 10, y: 20, width: 100, height: 75 }); + + return ( +
+ setRect(fixRect(rect, handle, 3 / 4, 100, 500, 400))} + > + { + if (camera !== camera_) { + setCamera(camera_); + } + if (mic !== mic_) { + setMic(mic_); + } + }} + userUUID="" + /> + +
+ ); +}; diff --git a/packages/flat-components/src/components/ClassroomPage/AvatarWindow/index.tsx b/packages/flat-components/src/components/ClassroomPage/AvatarWindow/index.tsx new file mode 100644 index 00000000000..85790b91661 --- /dev/null +++ b/packages/flat-components/src/components/ClassroomPage/AvatarWindow/index.tsx @@ -0,0 +1,278 @@ +import "./style.less"; + +import React, { useEffect, useRef } from "react"; +import classNames from "classnames"; +import { listen } from "@wopjs/dom"; + +const preventEvent = (ev: React.UIEvent | Event): void => { + ev.stopPropagation(); + if (ev.cancelable) { + ev.preventDefault(); + } +}; + +export interface AvatarWindowProps { + mode: "normal" | "maximized"; + rect: Rectangle; + index?: number; + zIndex?: number; + hidden?: boolean; + readonly?: boolean; + onClick?: () => void; + onResize?: (newRectangle: Rectangle, handle?: ResizeHandle) => void; + onDoubleClick?: () => void; + onDragging?: (ev: PointerEvent) => void; + onDragEnd?: (ev: PointerEvent) => void; +} + +export interface Rectangle { + x: number; + y: number; + width: number; + height: number; +} + +export type ResizeHandle = "" | "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se"; + +export const AvatarWindow: React.FC = ({ + mode, + rect, + index, + zIndex, + hidden, + readonly, + children, + onClick, + onResize, + onDoubleClick, + onDragging, + onDragEnd, +}) => { + const lastClick = useRef({ t: 0, x: -100, y: -100 }); + const disposers = useRef void>>([]); + + useEffect( + () => () => { + disposers.current.forEach(dispose => dispose()); + disposers.current = []; + }, + [], + ); + + const handleTrackStart = (ev: React.PointerEvent): void => { + if (!ev.isPrimary || readonly || ev.button !== 0) { + return; + } + + const target = ev.target as HTMLElement; + // filter out events on buttons, which should be handled by the button itself + for ( + let node: HTMLElement | null = target; + node && node !== ev.currentTarget; + node = node.parentElement + ) { + if (node.tagName === "BUTTON") { + return; + } + } + + const now = Date.now(); + if (now - lastClick.current.t <= 500) { + if ( + Math.abs(lastClick.current.x - ev.clientX) <= 5 && + Math.abs(lastClick.current.y - ev.clientY) <= 5 + ) { + onDoubleClick?.(); + } + return; + } + lastClick.current = { t: now, x: ev.clientX, y: ev.clientY }; + + const main = ev.currentTarget.parentElement as HTMLElement; + preventEvent(ev); + target.setPointerCapture(ev.pointerId); + main.classList.add("window-grabbing"); + + const trackingHandle = target.dataset?.windowHandle as ResizeHandle | undefined; + const { pageX: trackStartPageX, pageY: trackStartPageY } = ev; + + const handleTracking = (ev: PointerEvent): void => { + if (!ev.isPrimary || readonly) { + return; + } + + preventEvent(ev); + + const { pageX, pageY } = ev; + const offsetX = pageX - trackStartPageX; + const offsetY = pageY - trackStartPageY; + + let { x: newX, y: newY, width: newWidth, height: newHeight } = rect; + + switch (trackingHandle) { + case "n": { + newY = rect.y + offsetY; + newHeight = rect.height - offsetY; + break; + } + case "s": { + newHeight = rect.height + offsetY; + break; + } + case "w": { + newX = rect.x + offsetX; + newWidth = rect.width - offsetX; + break; + } + case "e": { + newWidth = rect.width + offsetX; + break; + } + case "nw": { + newX = rect.x + offsetX; + newY = rect.y + offsetY; + newWidth = rect.width - offsetX; + newHeight = rect.height - offsetY; + break; + } + case "ne": { + newY = rect.y + offsetY; + newWidth = rect.width + offsetX; + newHeight = rect.height - offsetY; + break; + } + case "sw": { + newX = rect.x + offsetX; + newWidth = rect.width - offsetX; + newHeight = rect.height + offsetY; + break; + } + case "se": { + newWidth = rect.width + offsetX; + newHeight = rect.height + offsetY; + break; + } + default: { + newX = rect.x + offsetX; + newY = rect.y + offsetY; + break; + } + } + + onDragging?.(ev); + onResize?.({ x: newX, y: newY, width: newWidth, height: newHeight }, trackingHandle); + }; + + const handleTrackEnd = (ev: PointerEvent): void => { + if (!ev.isPrimary) { + return; + } + + target.releasePointerCapture(ev.pointerId); + preventEvent(ev); + onDragEnd?.(ev); + + disposers.current.forEach(dispose => dispose()); + disposers.current = []; + }; + + disposers.current.push( + () => main.classList.remove("window-grabbing"), + listen(window, "pointermove", handleTracking, { passive: false }), + listen(window, "pointerup", handleTrackEnd, { passive: false }), + listen(window, "pointercancel", handleTrackEnd, { passive: false }), + ); + }; + + const style = + mode === "normal" + ? ({ + position: "absolute", + // Prevent a rendering issue on Chrome when set transform to sub-pixel values + width: rect.width | 0, + height: rect.height | 0, + transform: `translate(${rect.x | 0}px,${rect.y | 0}px)`, + zIndex: zIndex, + } as React.CSSProperties) + : ({ + flex: 1, + order: index, + } as React.CSSProperties); + + return ( +