-
Notifications
You must be signed in to change notification settings - Fork 828
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(flat-components): add avatar window (#1847)
* feat(flat-components): add avatar window * delete debug code
- Loading branch information
Showing
19 changed files
with
1,258 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
58 changes: 58 additions & 0 deletions
58
packages/flat-components/src/components/ClassroomPage/AvatarWindow/AvatarWindow.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Pick<AvatarWindowProps, "readonly" | "onDoubleClick">> = props => { | ||
const [camera, setCamera] = useState(false); | ||
const [mic, setMic] = useState(true); | ||
const [rect, setRect] = useState({ x: 10, y: 20, width: 100, height: 75 }); | ||
|
||
return ( | ||
<div | ||
style={{ | ||
width: "500px", | ||
height: "400px", | ||
overflow: "hidden", | ||
border: "1px solid green", | ||
position: "relative", | ||
}} | ||
> | ||
<AvatarWindow | ||
mode="normal" | ||
readonly={props.readonly} | ||
rect={rect} | ||
onDoubleClick={props.onDoubleClick} | ||
onResize={(rect, handle) => setRect(fixRect(rect, handle, 3 / 4, 100, 500, 400))} | ||
> | ||
<VideoAvatar | ||
isCreator | ||
avatarUser={{ | ||
name: "Hello", | ||
userUUID: "", | ||
mic, | ||
camera, | ||
avatar: "http://placekitten.com/64/64", | ||
}} | ||
updateDeviceState={(_, camera_, mic_) => { | ||
if (camera !== camera_) { | ||
setCamera(camera_); | ||
} | ||
if (mic !== mic_) { | ||
setMic(mic_); | ||
} | ||
}} | ||
userUUID="" | ||
/> | ||
</AvatarWindow> | ||
</div> | ||
); | ||
}; |
278 changes: 278 additions & 0 deletions
278
packages/flat-components/src/components/ClassroomPage/AvatarWindow/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<AvatarWindowProps> = ({ | ||
mode, | ||
rect, | ||
index, | ||
zIndex, | ||
hidden, | ||
readonly, | ||
children, | ||
onClick, | ||
onResize, | ||
onDoubleClick, | ||
onDragging, | ||
onDragEnd, | ||
}) => { | ||
const lastClick = useRef({ t: 0, x: -100, y: -100 }); | ||
const disposers = useRef<Array<() => void>>([]); | ||
|
||
useEffect( | ||
() => () => { | ||
disposers.current.forEach(dispose => dispose()); | ||
disposers.current = []; | ||
}, | ||
[], | ||
); | ||
|
||
const handleTrackStart = (ev: React.PointerEvent<HTMLDivElement>): 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 ( | ||
<div | ||
className={classNames("window", { | ||
"window-readonly": readonly, | ||
"window-maximized": mode === "maximized", | ||
})} | ||
data-index={index} | ||
data-z-index={zIndex} | ||
hidden={hidden} | ||
style={style} | ||
> | ||
<div className="window-main" onClick={onClick} onPointerDown={handleTrackStart}> | ||
{children} | ||
</div> | ||
<div className="window-resize-handles" onPointerDown={handleTrackStart}> | ||
<div className="window-n window-resize-handle" data-window-handle="n" /> | ||
<div className="window-s window-resize-handle" data-window-handle="s" /> | ||
<div className="window-w window-resize-handle" data-window-handle="w" /> | ||
<div className="window-e window-resize-handle" data-window-handle="e" /> | ||
<div className="window-nw window-resize-handle" data-window-handle="nw" /> | ||
<div className="window-ne window-resize-handle" data-window-handle="ne" /> | ||
<div className="window-sw window-resize-handle" data-window-handle="sw" /> | ||
<div className="window-se window-resize-handle" data-window-handle="se" /> | ||
</div> | ||
</div> | ||
); | ||
}; | ||
|
||
const clamp = (value: number, min: number, max: number): number => | ||
value < min ? min : value > max ? max : value; | ||
|
||
export const fixRect = ( | ||
input: Rectangle, | ||
handle: ResizeHandle | undefined, | ||
ratio: number, | ||
minWidth: number, | ||
maxWidth: number, | ||
maxHeight: number, | ||
): Rectangle => { | ||
const { x, y, width, height } = input; | ||
|
||
// Keep the ratio | ||
const fixedWidth = height / ratio; | ||
const fixedHeight = width * ratio; | ||
let newRect: Rectangle; | ||
if (!handle || handle === "e" || handle === "w") { | ||
newRect = { x, y, width, height: fixedHeight }; | ||
} else if (handle === "s" || handle === "n") { | ||
newRect = { x, y, width: fixedWidth, height }; | ||
} else if (fixedHeight < height) { | ||
const newY = handle === "ne" || handle === "nw" ? y + height - fixedHeight : y; | ||
newRect = { x, y: newY, width, height: fixedHeight }; | ||
} else { | ||
const newX = handle === "nw" || handle === "sw" ? x + width - fixedWidth : x; | ||
newRect = { x: newX, y, width: fixedWidth, height }; | ||
} | ||
|
||
// Clamp size | ||
if (!(minWidth <= newRect.width && newRect.width <= maxWidth && newRect.height <= maxHeight)) { | ||
const newWidth = clamp(newRect.width, minWidth, Math.min(maxWidth, maxHeight / ratio)); | ||
const newHeight = newWidth * ratio; | ||
if (handle === "w" || handle === "sw" || handle === "nw") { | ||
newRect.x = x + width - newWidth; | ||
} | ||
if (handle === "n" || handle === "ne" || handle === "nw") { | ||
newRect.y = y + height - newHeight; | ||
} | ||
newRect.width = newWidth; | ||
newRect.height = newHeight; | ||
} | ||
|
||
// Clamp position | ||
newRect.x = clamp(newRect.x, 0, maxWidth - newRect.width); | ||
newRect.y = clamp(newRect.y, 0, maxHeight - newRect.height); | ||
|
||
return newRect; | ||
}; |
Oops, something went wrong.