diff --git a/client/src/components/ChatMessage.tsx b/client/src/components/ChatMessage.tsx index 47f6823d2..c05f5462b 100644 --- a/client/src/components/ChatMessage.tsx +++ b/client/src/components/ChatMessage.tsx @@ -10,7 +10,7 @@ import DOMPurify from "dompurify"; import GraveComponent from "./grave"; import { RoleOutline, translateRoleOutline } from "../game/roleListState.d"; import { CopyButton } from "./ClipboardButtons"; -import { useGameState, useLobbyOrGameState, usePlayerState } from "./useHooks"; +import { useGameState, useLobbyOrGameState, usePlayerNames, usePlayerState, useSpectator } from "./useHooks"; import { KiraResult, KiraResultDisplay } from "../menu/game/gameScreenContent/AbilityMenu/AbilitySelectionTypes/KiraSelectionMenu"; import { AuditorResult } from "../menu/game/gameScreenContent/AbilityMenu/RoleSpecificMenus/AuditorMenu"; import { ControllerID, AbilitySelection, translateControllerID, controllerIdToLink } from "../game/abilityInput"; @@ -37,7 +37,8 @@ const ChatElement = React.memo(( const [mouseHovering, setMouseHovering] = React.useState(false); const message = props.message; - const playerNames = props.playerNames ?? GAME_MANAGER.getPlayerNames(); + const realPlayerNames = usePlayerNames(); + const playerNames = props.playerNames ?? realPlayerNames; const chatMessageStyles = require("../resources/styling/chatMessage.json"); if(message.variant === undefined){ console.error("ChatElement message with undefined variant:"); @@ -132,33 +133,13 @@ const ChatElement = React.memo(( /> case "playerDied": - - let graveRoleString: string; - switch (message.variant.grave.information.type) { - case "obscured": - graveRoleString = translate("obscured"); - break; - case "normal": - graveRoleString = translate("role."+message.variant.grave.information.role+".name"); - break; - } - - return
- - {(chatGroupIcon??"")} {translate("chatMessage.playerDied", - playerNames[message.variant.grave.player], graveRoleString - )} - - } - defaultOpen={GAME_MANAGER.getMySpectator()} - > - - -
; + return } return
; }); +function PlayerDiedChatMessage(props: Readonly<{ + playerKeywordData?: KeywordDataMap, + style: string, + chatGroupIcon: string | null, + playerNames: string[], + message: ChatMessage & { variant: { type: "playerDied" } } +}>): ReactElement { + let graveRoleString: string; + switch (props.message.variant.grave.information.type) { + case "obscured": + graveRoleString = translate("obscured"); + break; + case "normal": + graveRoleString = translate("role."+props.message.variant.grave.information.role+".name"); + break; + } + + const spectator = useSpectator(); + + return
+ + {(props.chatGroupIcon ?? "")} {translate("chatMessage.playerDied", + props.playerNames[props.message.variant.grave.player], graveRoleString + )} + + } + defaultOpen={spectator} + > + + +
; +} + function LobbyChatMessage(props: Readonly<{ message: ChatMessage & { variant: { type: "lobbyMessage" } } playerNames: string[], @@ -326,14 +344,9 @@ export function sanitizePlayerMessage(text: string): string { export function translateChatMessage( message: ChatMessageVariant, - playerNames?: string[], + playerNames: string[], roleList?: RoleOutline[] ): string { - - if (playerNames === undefined) { - playerNames = GAME_MANAGER.getPlayerNames(); - } - switch (message.type) { case "lobbyMessage": return sanitizePlayerMessage(replaceMentions(message.text, playerNames)); @@ -522,7 +535,7 @@ export function translateChatMessage( out = translate("chatMessage.abilityUsed.selection.twoRoleOutlineOption", first, second); break; case "string": - out = translate("chatMessage.abilityUsed.selection.string", sanitizePlayerMessage(replaceMentions(message.selection.selection))); + out = translate("chatMessage.abilityUsed.selection.string", sanitizePlayerMessage(replaceMentions(message.selection.selection, playerNames))); break; case "integer": let text = translateChecked("controllerId."+controllerIdToLink(message.abilityId).replace(/\//g, ".") + ".integer." + message.selection.selection); diff --git a/client/src/components/Popover.tsx b/client/src/components/Popover.tsx new file mode 100644 index 000000000..b84a41044 --- /dev/null +++ b/client/src/components/Popover.tsx @@ -0,0 +1,127 @@ +import React, { ReactElement, useEffect, useMemo, useRef } from "react"; +import ReactDOM from "react-dom/client"; +import { THEME_CSS_ATTRIBUTES } from ".."; + +export default function Popover(props: Readonly<{ + open: boolean, + children: JSX.Element, + setOpenOrClosed: (open: boolean) => void, + onRender?: (popoverElement: HTMLDivElement, anchorElement?: T | undefined) => void + anchorRef?: React.RefObject, + className?: string +}>): ReactElement { + const thisRef = useRef(null); + const popoverRef = useRef(document.createElement('div')); + + const popoverRoot = useMemo(() => { + const popoverElement = popoverRef.current; + popoverElement.style.position = "absolute"; + + document.body.appendChild(popoverElement); + return ReactDOM.createRoot(popoverElement); + }, []) + + //set ref + useEffect(() => { + const initialPopover = popoverRef.current; + return () => { + setTimeout(() => { + popoverRoot.unmount(); + }) + initialPopover.remove(); + + popoverRef.current = document.createElement('div'); + } + }, [popoverRoot]) + + //match css styles + useEffect(() => { + const styleBenefactor = props.anchorRef?.current ?? thisRef.current; + const popoverElement = popoverRef.current; + + if (styleBenefactor) { + // Match styles + THEME_CSS_ATTRIBUTES.forEach(prop => { + popoverElement.style.setProperty(`--${prop}`, getComputedStyle(styleBenefactor).getPropertyValue(`--${prop}`)) + }) + + popoverElement.className = 'popover ' + (props.className ?? '') + } + }, [props.anchorRef, props.className]) + + // This is for the popover's anchor, not the element named Anchor + const [anchorLocation, setAnchorLocation] = React.useState(() => { + const bounds = props.anchorRef?.current?.getBoundingClientRect(); + + if (bounds) { + return { top: bounds.top, left: bounds.left } + } else { + return {top: 0, left: 0} + } + }); + + //close on scroll + useEffect(() => { + const listener = () => { + const bounds = props.anchorRef?.current?.getBoundingClientRect(); + if ( + bounds && + props.open && + ( + anchorLocation.top !== bounds?.top || + anchorLocation.left !== bounds?.left + ) + ) + props.setOpenOrClosed(false); + }; + + window.addEventListener("scroll", listener, true); + window.addEventListener("resize", listener); + return () => { + window.removeEventListener("scroll", listener, true); + window.removeEventListener("resize", listener); + } + }) + + //open and set position + useEffect(() => { + const popoverElement = popoverRef.current; + const anchorElement = props.anchorRef?.current; + + if (props.open) { + popoverRoot.render(props.children); + + if (anchorElement) { + const anchorBounds = anchorElement.getBoundingClientRect(); + + setAnchorLocation({top: anchorBounds.top, left: anchorBounds.left}); + } + + setTimeout(() => { + popoverElement.hidden = false; + + if (props.onRender) { + props.onRender(popoverElement, anchorElement ?? undefined) + } + }) + } else { + popoverElement.hidden = true; + } + }, [props, popoverRoot]) + + //close on click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!popoverRef.current?.contains(event.target as Node) && props.open) { + props.setOpenOrClosed(false); + } + }; + + setTimeout(() => { + document.addEventListener("click", handleClickOutside); + }) + return () => document.removeEventListener("click", handleClickOutside); + }, [props]); + + return
+} \ No newline at end of file diff --git a/client/src/components/Select.tsx b/client/src/components/Select.tsx index 3357b6fe4..6ab415352 100644 --- a/client/src/components/Select.tsx +++ b/client/src/components/Select.tsx @@ -1,9 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; import { Button, RawButton } from "./Button"; import "./select.css"; import Icon from "./Icon"; -import ReactDOM from "react-dom/client"; -import { THEME_CSS_ATTRIBUTES } from ".."; +import Popover from "./Popover"; export type SelectOptionsNoSearch = Map; export type SelectOptionsSearch = Map; @@ -45,7 +44,7 @@ export default function Select(props: Readonly< } }, [props]); - const [open, setOpen]= React.useState(false); + const [open, setOpen] = React.useState(false); const [searchString, setSearchString] = React.useState(""); @@ -101,157 +100,80 @@ export default function Select(props: Readonly< } } - const buttonRef = useRef(null); - const dropdownRef = useRef(document.createElement('div')); + const ref = useRef(null); - const dropdownRoot = useMemo(() => { - const dropdownElement = dropdownRef.current; - dropdownElement.style.position = "absolute"; - - document.body.appendChild(dropdownElement); - return ReactDOM.createRoot(dropdownElement); - }, []) - - //set ref - useEffect(() => { - const initialDropdown = dropdownRef.current; - return () => { - setTimeout(() => { - dropdownRoot.unmount(); - }) - initialDropdown.remove(); - - dropdownRef.current = document.createElement('div'); - } - }, [dropdownRoot]) + const value = optionsSearch.get(props.value); + if(value === undefined) { + console.error(`Value not found in options ${props.value}`); + } - //match css styles - useEffect(() => { - const buttonElement = buttonRef.current; - const dropdownElement = dropdownRef.current; + return <> + {handleSetOpen(!open)}} + className={"custom-select "+(props.className?props.className:"")} + onKeyDown={(e)=>{ + if(props.disabled) return; + if(e.key === "Enter" && !open) { + e.preventDefault(); + handleSetOpen(true); + }else if(e.key === "Tab") { + handleSetOpen(false); + }else{ + e.preventDefault(); + handleKeyInput(e.key); + } + }} + > + {open === true ? + keyboard_arrow_up : + keyboard_arrow_down} + {value !== undefined?value[0]:props.value.toString()} + + { + if (!buttonElement) return; + + const buttonBounds = buttonElement.getBoundingClientRect(); + dropdownElement.style.width = `${buttonBounds.width}px`; + dropdownElement.style.left = `${buttonBounds.left}px`; - if (buttonElement) { - // Match styles - THEME_CSS_ATTRIBUTES.forEach(prop => { - dropdownElement.style.setProperty(`--${prop}`, getComputedStyle(buttonElement).getPropertyValue(`--${prop}`)) - }) - - dropdownElement.className = 'custom-select-options' - } - }, []) - - const [buttonLocation, setButtonLocation] = React.useState({top: 0, left: 0}); - - //close on scroll - useEffect(() => { - const listener = (ev: Event) => { - const bounds = buttonRef.current?.getBoundingClientRect(); - if ( - open && - ( - buttonLocation.top !== bounds?.top || - buttonLocation.left !== bounds?.left - ) - ) - handleSetOpen(false); - }; + const spaceAbove = buttonBounds.top; + const spaceBelow = window.innerHeight - buttonBounds.bottom; - window.addEventListener("scroll", listener, true); - window.addEventListener("resize", listener); - return () => { - window.removeEventListener("scroll", listener, true); - window.removeEventListener("resize", listener); - } - }) - - //open and set position - useEffect(() => { - const buttonElement = buttonRef.current; - const dropdownElement = dropdownRef.current; - - if (buttonElement && open) { - dropdownRoot.render( spaceBelow) { + const newHeight = Math.min(maxHeight, spaceAbove - .25 * oneRem, optionsHeight); + dropdownElement.style.height = `${newHeight}px`; + dropdownElement.style.top = `unset`; + dropdownElement.style.bottom = `${spaceBelow + buttonBounds.height + .25 * oneRem}px`; + } else { + const newHeight = Math.min(maxHeight, spaceBelow - .25 * oneRem, optionsHeight); + dropdownElement.style.height = `${newHeight}px`; + dropdownElement.style.top = `${spaceAbove + buttonBounds.height + .25 * oneRem}px`; + dropdownElement.style.bottom = `unset`; + } + }} + anchorRef={ref} + > + { if(props.disabled) return; handleSetOpen(false); handleOnChange(value); }} - />); - - - dropdownElement.hidden = false; - - const buttonBounds = buttonElement.getBoundingClientRect(); - // Position - dropdownElement.style.width = `${buttonBounds.width}px`; - dropdownElement.style.left = `${buttonBounds.left}px`; - setButtonLocation({top: buttonBounds.top, left: buttonBounds.left}); - - const spaceAbove = buttonBounds.top; - const spaceBelow = window.innerHeight - buttonBounds.bottom; - - const oneRem = parseFloat(getComputedStyle(buttonElement).fontSize); - - if (spaceAbove > spaceBelow) { - const newHeight = Math.min((25 - .25) * oneRem, spaceAbove - .25 * oneRem); - dropdownElement.style.height = `${newHeight}px`; - dropdownElement.style.top = `unset`; - dropdownElement.style.bottom = `${spaceBelow + buttonBounds.height + .25 * oneRem}px`; - } else { - const newHeight = Math.min((25 - .25) * oneRem, spaceBelow - .25 * oneRem); - dropdownElement.style.height = `${newHeight}px`; - dropdownElement.style.top = `${spaceAbove + buttonBounds.height + .25 * oneRem}px`; - dropdownElement.style.bottom = `unset`; - } - } else { - dropdownElement.hidden = true; - } - }, [handleOnChange, handleSetOpen, open, props.disabled, optionsNoSearch, dropdownRoot, searchString]) - - //close on click outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (!dropdownRef.current?.contains(event.target as Node) && open) { - handleSetOpen(false); - } - }; - - setTimeout(() => { - document.addEventListener("click", handleClickOutside); - }) - return () => document.removeEventListener("click", handleClickOutside); - }, [handleSetOpen, open]); - - const value = optionsSearch.get(props.value); - if(value === undefined) { - console.error(`Value not found in options ${props.value}`); - } - - return {handleSetOpen(!open)}} - className={"custom-select "+(props.className?props.className:"")} - onKeyDown={(e)=>{ - if(props.disabled) return; - if(e.key === "Enter" && !open) { - e.preventDefault(); - handleSetOpen(true); - }else if(e.key === "Tab") { - handleSetOpen(false); - }else{ - e.preventDefault(); - handleKeyInput(e.key); - } - }} - > - {open === true ? - keyboard_arrow_up : - keyboard_arrow_down} - {value !== undefined?value[0]:props.value.toString()} - + /> + + } function SelectOptions(props: Readonly<{ @@ -259,11 +181,8 @@ function SelectOptions(props: Readonly<{ options: SelectOptionsNoSearch, onChange?: (value: K)=>void, }>) { - return
- {props.searchString!==undefined? - props.searchString - :null} + {props.searchString ?? null} {[...props.options.entries()] .map(([key, value]) => { return