Merged PR 171: Refactor the multiple context menus into a single component + Fix eslint

Refactor the multiple context menus into a single component + Fix eslint
This commit is contained in:
Eric Nguyen 2022-08-29 15:03:47 +00:00
parent ad126c6c28
commit 87c4ea1fe5
8 changed files with 173 additions and 269 deletions

View file

@ -4,7 +4,6 @@ module.exports = {
es2021: true es2021: true
}, },
extends: [ extends: [
'only-warn',
'plugin:react/recommended', 'plugin:react/recommended',
'standard-with-typescript' 'standard-with-typescript'
], ],
@ -18,6 +17,7 @@ module.exports = {
project: './tsconfig.json' project: './tsconfig.json'
}, },
plugins: [ plugins: [
'only-warn',
'react', 'react',
'react-hooks', 'react-hooks',
'@typescript-eslint' '@typescript-eslint'

View file

@ -12,6 +12,7 @@ import { IEditorState } from '../../Interfaces/IEditorState';
import { MAX_HISTORY } from '../../utils/default'; import { MAX_HISTORY } from '../../utils/default';
import { AddSymbol, OnPropertyChange as OnSymbolPropertyChange, DeleteSymbol, SelectSymbol } from './Actions/SymbolOperations'; import { AddSymbol, OnPropertyChange as OnSymbolPropertyChange, DeleteSymbol, SelectSymbol } from './Actions/SymbolOperations';
import { FindContainerById } from '../../utils/itertools'; import { FindContainerById } from '../../utils/itertools';
import { Menu } from '../Menu/Menu';
interface IEditorProps { interface IEditorProps {
root: Element | Document root: Element | Document
@ -20,24 +21,45 @@ interface IEditorProps {
historyCurrentStep: number historyCurrentStep: number
} }
export function UpdateCounters(counters: Record<string, number>, type: string): void { function InitActions(
if (counters[type] === null || menuActions: Map<any, any>,
counters[type] === undefined) { history: IHistoryState[],
counters[type] = 0; historyCurrentStep: number,
} else { setHistory: React.Dispatch<React.SetStateAction<IHistoryState[]>>,
counters[type]++; setHistoryCurrentStep: React.Dispatch<React.SetStateAction<number>>
} ): void {
} menuActions.set(
'elements-sidebar-row',
export function GetCurrentHistory(history: IHistoryState[], historyCurrentStep: number): IHistoryState[] { [{
return history.slice( text: 'Delete',
Math.max(0, history.length - MAX_HISTORY), action: (target: HTMLElement) => {
historyCurrentStep + 1 const id = target.id;
DeleteContainer(
id,
history,
historyCurrentStep,
setHistory,
setHistoryCurrentStep
); );
} }
}]
export function GetCurrentHistoryState(history: IHistoryState[], historyCurrentStep: number): IHistoryState { );
return history[historyCurrentStep]; menuActions.set(
'symbols-sidebar-row',
[{
text: 'Delete',
action: (target: HTMLElement) => {
const id = target.id;
DeleteSymbol(
id,
history,
historyCurrentStep,
setHistory,
setHistoryCurrentStep
);
}
}]
);
} }
function UseShortcuts( function UseShortcuts(
@ -106,10 +128,12 @@ function UseWindowEvents(
} }
export function Editor(props: IEditorProps): JSX.Element { export function Editor(props: IEditorProps): JSX.Element {
// States
const [history, setHistory] = React.useState<IHistoryState[]>(structuredClone(props.history)); const [history, setHistory] = React.useState<IHistoryState[]>(structuredClone(props.history));
const [historyCurrentStep, setHistoryCurrentStep] = React.useState<number>(props.historyCurrentStep); const [historyCurrentStep, setHistoryCurrentStep] = React.useState<number>(props.historyCurrentStep);
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
// Events
UseShortcuts(history, historyCurrentStep, setHistoryCurrentStep); UseShortcuts(history, historyCurrentStep, setHistoryCurrentStep);
UseWindowEvents( UseWindowEvents(
props.root, props.root,
@ -121,6 +145,11 @@ export function Editor(props: IEditorProps): JSX.Element {
setHistoryCurrentStep setHistoryCurrentStep
); );
// Context Menu
const menuActions = new Map();
InitActions(menuActions, history, historyCurrentStep, setHistory, setHistoryCurrentStep);
// Render
const configuration = props.configuration; const configuration = props.configuration;
const current = GetCurrentHistoryState(history, historyCurrentStep); const current = GetCurrentHistoryState(history, historyCurrentStep);
const selected = FindContainerById(current.mainContainer, current.selectedContainerId); const selected = FindContainerById(current.mainContainer, current.selectedContainerId);
@ -208,6 +237,31 @@ export function Editor(props: IEditorProps): JSX.Element {
> >
{current.mainContainer} {current.mainContainer}
</SVG> </SVG>
<Menu
getListener={() => editorRef.current}
actions={menuActions}
className="z-30 transition-opacity rounded bg-slate-200 py-1 drop-shadow-xl"
/>
</div> </div>
); );
} }
export function UpdateCounters(counters: Record<string, number>, type: string): void {
if (counters[type] === null ||
counters[type] === undefined) {
counters[type] = 0;
} else {
counters[type]++;
}
}
export function GetCurrentHistory(history: IHistoryState[], historyCurrentStep: number): IHistoryState[] {
return history.slice(
Math.max(0, history.length - MAX_HISTORY),
historyCurrentStep + 1
);
}
export function GetCurrentHistoryState(history: IHistoryState[], historyCurrentStep: number): IHistoryState {
return history[historyCurrentStep];
}

View file

@ -3,10 +3,6 @@ import { FixedSizeList as List } from 'react-window';
import { Properties } from '../ContainerProperties/ContainerProperties'; import { Properties } from '../ContainerProperties/ContainerProperties';
import { IContainerModel } from '../../Interfaces/IContainerModel'; import { IContainerModel } from '../../Interfaces/IContainerModel';
import { GetDepth, MakeIterator } from '../../utils/itertools'; import { GetDepth, MakeIterator } from '../../utils/itertools';
import { Menu } from '../Menu/Menu';
import { MenuItem } from '../Menu/MenuItem';
import { UseMouseEvents } from './MouseEventHandlers';
import { IPoint } from '../../Interfaces/IPoint';
import { ISymbolModel } from '../../Interfaces/ISymbolModel'; import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import { PropertyType } from '../../Enums/PropertyType'; import { PropertyType } from '../../Enums/PropertyType';
@ -22,29 +18,9 @@ interface IElementsSidebarProps {
type?: PropertyType type?: PropertyType
) => void ) => void
selectContainer: (containerId: string) => void selectContainer: (containerId: string) => void
deleteContainer: (containerid: string) => void
} }
export function ElementsSidebar(props: IElementsSidebarProps): JSX.Element { export function ElementsSidebar(props: IElementsSidebarProps): JSX.Element {
// States
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState<boolean>(false);
const [onClickContainerId, setOnClickContainerId] = React.useState<string>('');
const [contextMenuPosition, setContextMenuPosition] = React.useState<IPoint>({
x: 0,
y: 0
});
const elementRef = React.useRef<HTMLDivElement>(null);
// Event listeners
UseMouseEvents(
isContextMenuOpen,
elementRef,
setIsContextMenuOpen,
setOnClickContainerId,
setContextMenuPosition
);
// Render // Render
let isOpenClasses = '-right-64'; let isOpenClasses = '-right-64';
if (props.isOpen) { if (props.isOpen) {
@ -90,7 +66,7 @@ export function ElementsSidebar(props: IElementsSidebarProps): JSX.Element {
className={`fixed flex flex-col bg-slate-100 text-gray-800 transition-all h-full w-64 overflow-y-auto z-20 ${isOpenClasses}`} className={`fixed flex flex-col bg-slate-100 text-gray-800 transition-all h-full w-64 overflow-y-auto z-20 ${isOpenClasses}`}
> >
<div className="bg-slate-100 font-bold sidebar-title">Elements</div> <div className="bg-slate-100 font-bold sidebar-title">Elements</div>
<div ref={elementRef} className="h-96 text-gray-800"> <div className="h-96 text-gray-800">
<List <List
className="List divide-y divide-black" className="List divide-y divide-black"
itemCount={containers.length} itemCount={containers.length}
@ -101,20 +77,6 @@ export function ElementsSidebar(props: IElementsSidebarProps): JSX.Element {
{Row} {Row}
</List> </List>
</div> </div>
<Menu
className="transition-opacity rounded bg-slate-200 py-1 drop-shadow-xl"
x={contextMenuPosition.x}
y={contextMenuPosition.y}
isOpen={isContextMenuOpen}
>
<MenuItem
className="contextmenu-item"
text="Delete"
onClick={() => {
setIsContextMenuOpen(false);
props.deleteContainer(onClickContainerId);
} } />
</Menu>
<Properties <Properties
properties={props.selectedContainer?.properties} properties={props.selectedContainer?.properties}
symbols={props.symbols} symbols={props.symbols}

View file

@ -1,84 +0,0 @@
import React, { RefObject, Dispatch, SetStateAction, useEffect } from 'react';
import { IPoint } from '../../Interfaces/IPoint';
export function UseMouseEvents(
isContextMenuOpen: boolean,
elementRef: RefObject<HTMLDivElement>,
setIsContextMenuOpen: Dispatch<SetStateAction<boolean>>,
setOnClickContainerId: Dispatch<SetStateAction<string>>,
setContextMenuPosition: Dispatch<SetStateAction<IPoint>>
): void {
useEffect(() => {
function OnContextMenu(event: MouseEvent): void {
return HandleRightClick(
event,
setIsContextMenuOpen,
setOnClickContainerId,
setContextMenuPosition
);
}
function OnLeftClick(): void {
return HandleLeftClick(
isContextMenuOpen,
setIsContextMenuOpen,
setOnClickContainerId
);
}
elementRef.current?.addEventListener(
'contextmenu',
OnContextMenu
);
window.addEventListener(
'click',
OnLeftClick
);
return () => {
elementRef.current?.removeEventListener(
'contextmenu',
OnContextMenu
);
window.removeEventListener(
'click',
OnLeftClick
);
};
});
}
export function HandleRightClick(
event: MouseEvent,
setIsContextMenuOpen: React.Dispatch<React.SetStateAction<boolean>>,
setOnClickContainerId: React.Dispatch<React.SetStateAction<string>>,
setContextMenuPosition: React.Dispatch<React.SetStateAction<IPoint>>
): void {
event.preventDefault();
if (!(event.target instanceof HTMLButtonElement)) {
setIsContextMenuOpen(false);
setOnClickContainerId('');
return;
}
const contextMenuPosition: IPoint = { x: event.pageX, y: event.pageY };
setIsContextMenuOpen(true);
setOnClickContainerId(event.target.id);
setContextMenuPosition(contextMenuPosition);
}
export function HandleLeftClick(
isContextMenuOpen: boolean,
setIsContextMenuOpen: React.Dispatch<React.SetStateAction<boolean>>,
setOnClickContainerId: React.Dispatch<React.SetStateAction<string>>
): void {
if (!isContextMenuOpen) {
return;
}
setIsContextMenuOpen(false);
setOnClickContainerId('');
}

View file

@ -1,23 +1,109 @@
import * as React from 'react'; import * as React from 'react';
import { IPoint } from '../../Interfaces/IPoint';
import { MenuItem } from './MenuItem';
interface IMenuProps { interface IMenuProps {
getListener: () => HTMLElement | null
actions: Map<string, IAction[]>
className?: string className?: string
x: number }
y: number
isOpen: boolean export interface IAction {
children: React.ReactNode[] | React.ReactNode /** displayed */
text: string
/** function to be called on button click */
action: (target: HTMLElement) => void
}
function UseMouseEvents(
getListener: () => HTMLElement | null,
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
setContextMenuPosition: React.Dispatch<React.SetStateAction<IPoint>>,
setTarget: React.Dispatch<React.SetStateAction<HTMLElement | undefined>>
): void {
React.useEffect(() => {
function OnContextMenu(event: MouseEvent): void {
event.preventDefault();
const contextMenuPosition: IPoint = { x: event.pageX, y: event.pageY };
setIsOpen(true);
setContextMenuPosition(contextMenuPosition);
setTarget(event.target as HTMLElement); // this is wrong since target can be null and not undefined
}
function OnLeftClick(): void {
setIsOpen(false);
}
getListener()?.addEventListener(
'contextmenu',
OnContextMenu
);
window.addEventListener(
'click',
OnLeftClick
);
return () => {
getListener()?.removeEventListener(
'contextmenu',
OnContextMenu
);
window.removeEventListener(
'click',
OnLeftClick
);
};
});
} }
export function Menu(props: IMenuProps): JSX.Element { export function Menu(props: IMenuProps): JSX.Element {
const visible = props.isOpen ? 'visible opacity-1' : 'invisible opacity-0'; const [isOpen, setIsOpen] = React.useState(false);
const [contextMenuPosition, setContextMenuPosition] = React.useState<IPoint>({
x: 0,
y: 0
});
const [target, setTarget] = React.useState<HTMLElement>();
UseMouseEvents(
props.getListener,
setIsOpen,
setContextMenuPosition,
setTarget
);
let children;
if (target !== undefined) {
for (const className of target.classList) {
const actions = props.actions.get(className);
if (actions === undefined) {
continue;
}
children = actions.map((action, index) =>
<MenuItem
key={`contextmenu-item-${index}`}
className="contextmenu-item"
text={action.text}
onClick={() => action.action(target)} />
);
break;
};
}
const visible = isOpen ? 'visible opacity-1' : 'invisible opacity-0';
return ( return (
<div <div
className={`fixed ${props.className ?? ''} ${visible}`} className={`fixed ${props.className ?? ''} ${visible}`}
style={{ style={{
left: props.x, left: contextMenuPosition.x,
top: props.y top: contextMenuPosition.y
}}> }}>
{props.children} { children }
</div> </div>
); );
} }

View file

@ -1,84 +0,0 @@
import { RefObject, Dispatch, SetStateAction, useEffect } from 'react';
import { IPoint } from '../../Interfaces/IPoint';
export function UseMouseEvents(
isContextMenuOpen: boolean,
elementRef: RefObject<HTMLDivElement>,
setIsContextMenuOpen: Dispatch<SetStateAction<boolean>>,
setOnClickSymbolId: Dispatch<SetStateAction<string>>,
setContextMenuPosition: Dispatch<SetStateAction<IPoint>>
): void {
useEffect(() => {
function OnContextMenu(event: MouseEvent): void {
return HandleRightClick(
event,
setIsContextMenuOpen,
setOnClickSymbolId,
setContextMenuPosition
);
}
function OnLeftClick(): void {
return HandleLeftClick(
isContextMenuOpen,
setIsContextMenuOpen,
setOnClickSymbolId
);
}
elementRef.current?.addEventListener(
'contextmenu',
OnContextMenu
);
window.addEventListener(
'click',
OnLeftClick
);
return () => {
elementRef.current?.removeEventListener(
'contextmenu',
OnContextMenu
);
window.removeEventListener(
'click',
OnLeftClick
);
};
});
}
export function HandleRightClick(
event: MouseEvent,
setIsContextMenuOpen: React.Dispatch<React.SetStateAction<boolean>>,
setOnClickSymbolId: React.Dispatch<React.SetStateAction<string>>,
setContextMenuPosition: React.Dispatch<React.SetStateAction<IPoint>>
): void {
event.preventDefault();
if (!(event.target instanceof HTMLButtonElement)) {
setIsContextMenuOpen(false);
setOnClickSymbolId('');
return;
}
const contextMenuPosition: IPoint = { x: event.pageX, y: event.pageY };
setIsContextMenuOpen(true);
setOnClickSymbolId(event.target.id);
setContextMenuPosition(contextMenuPosition);
}
export function HandleLeftClick(
isContextMenuOpen: boolean,
setIsContextMenuOpen: React.Dispatch<React.SetStateAction<boolean>>,
setOnClickContainerId: React.Dispatch<React.SetStateAction<string>>
): void {
if (!isContextMenuOpen) {
return;
}
setIsContextMenuOpen(false);
setOnClickContainerId('');
}

View file

@ -1,9 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { FixedSizeList as List } from 'react-window'; import { FixedSizeList as List } from 'react-window';
import { Menu } from '../Menu/Menu';
import { MenuItem } from '../Menu/MenuItem';
import { UseMouseEvents } from './MouseEventHandlers';
import { IPoint } from '../../Interfaces/IPoint';
import { ISymbolModel } from '../../Interfaces/ISymbolModel'; import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import { SymbolProperties } from '../SymbolProperties/SymbolProperties'; import { SymbolProperties } from '../SymbolProperties/SymbolProperties';
@ -18,25 +14,6 @@ interface ISymbolsSidebarProps {
} }
export function SymbolsSidebar(props: ISymbolsSidebarProps): JSX.Element { export function SymbolsSidebar(props: ISymbolsSidebarProps): JSX.Element {
// States
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState<boolean>(false);
const [onClickSymbolId, setOnClickSymbolId] = React.useState<string>('');
const [contextMenuPosition, setContextMenuPosition] = React.useState<IPoint>({
x: 0,
y: 0
});
const elementRef = React.useRef<HTMLDivElement>(null);
// Event listeners
UseMouseEvents(
isContextMenuOpen,
elementRef,
setIsContextMenuOpen,
setOnClickSymbolId,
setContextMenuPosition
);
// Render // Render
let isOpenClasses = '-right-64'; let isOpenClasses = '-right-64';
if (props.isOpen) { if (props.isOpen) {
@ -57,7 +34,7 @@ export function SymbolsSidebar(props: ISymbolsSidebarProps): JSX.Element {
return ( return (
<button type="button" <button type="button"
className={`w-full border-blue-500 elements-sidebar-row whitespace-pre className={`w-full border-blue-500 symbols-sidebar-row whitespace-pre
text-left text-sm font-medium transition-all ${selectedClass}`} text-left text-sm font-medium transition-all ${selectedClass}`}
id={key} id={key}
key={key} key={key}
@ -74,7 +51,7 @@ export function SymbolsSidebar(props: ISymbolsSidebarProps): JSX.Element {
<div className='bg-slate-100 font-bold sidebar-title'> <div className='bg-slate-100 font-bold sidebar-title'>
Elements Elements
</div> </div>
<div ref={elementRef} className='h-96 text-gray-800'> <div className='h-96 text-gray-800'>
<List <List
className='List divide-y divide-black' className='List divide-y divide-black'
itemCount={containers.length} itemCount={containers.length}
@ -85,17 +62,6 @@ export function SymbolsSidebar(props: ISymbolsSidebarProps): JSX.Element {
{Row} {Row}
</List> </List>
</div> </div>
<Menu
className='transition-opacity rounded bg-slate-200 py-1 drop-shadow-xl'
x={contextMenuPosition.x}
y={contextMenuPosition.y}
isOpen={isContextMenuOpen}
>
<MenuItem className='contextmenu-item' text='Delete' onClick={() => {
setIsContextMenuOpen(false);
props.deleteSymbol(onClickSymbolId);
} } />
</Menu>
<SymbolProperties <SymbolProperties
symbol={props.symbols.get(props.selectedSymbolId)} symbol={props.symbols.get(props.selectedSymbolId)}
symbols={props.symbols} symbols={props.symbols}

View file

@ -19,6 +19,10 @@
@apply pl-6 pr-6 pt-2 pb-2 w-full @apply pl-6 pr-6 pt-2 pb-2 w-full
} }
.symbols-sidebar-row {
@apply elements-sidebar-row
}
.close-button { .close-button {
@apply transition-all w-full h-auto p-4 flex @apply transition-all w-full h-auto p-4 flex
} }