Add selection copy and paste
This commit is contained in:
parent
1b248c16a5
commit
5546bf525e
@ -39,6 +39,7 @@ import {
|
||||
NoteCreateEventHander,
|
||||
SelectionItemsChangeEventHandler,
|
||||
SelectionItemsRemoveEventHandler,
|
||||
SelectionItemsCreateEventHandler,
|
||||
} from "../../types/Events";
|
||||
|
||||
import useMapTokens from "../../hooks/useMapTokens";
|
||||
@ -54,6 +55,7 @@ type MapProps = {
|
||||
onMapTokenStateRemove: TokenStateRemoveHandler;
|
||||
onSelectionItemsChange: SelectionItemsChangeEventHandler;
|
||||
onSelectionItemsRemove: SelectionItemsRemoveEventHandler;
|
||||
onSelectionItemsCreate: SelectionItemsCreateEventHandler;
|
||||
onMapChange: MapChangeEventHandler;
|
||||
onMapReset: MapResetEventHandler;
|
||||
onMapDraw: (action: Action<DrawingState>) => void;
|
||||
@ -75,6 +77,7 @@ function Map({
|
||||
onMapTokenStateRemove,
|
||||
onSelectionItemsChange,
|
||||
onSelectionItemsRemove,
|
||||
onSelectionItemsCreate,
|
||||
onMapChange,
|
||||
onMapReset,
|
||||
onMapDraw,
|
||||
@ -158,6 +161,7 @@ function Map({
|
||||
mapState,
|
||||
onSelectionItemsChange,
|
||||
onSelectionItemsRemove,
|
||||
onSelectionItemsCreate,
|
||||
selectedToolId,
|
||||
settings.select
|
||||
);
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Box, Flex, IconButton } from "theme-ui";
|
||||
import { useToasts } from "react-toast-notifications";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import MapMenu from "../map/MapMenu";
|
||||
|
||||
@ -9,39 +11,55 @@ import LockIcon from "../../icons/TokenLockIcon";
|
||||
import UnlockIcon from "../../icons/TokenUnlockIcon";
|
||||
import ShowIcon from "../../icons/TokenShowIcon";
|
||||
import HideIcon from "../../icons/TokenHideIcon";
|
||||
import CopyIcon from "../../icons/CopyIcon";
|
||||
import PasteIcon from "../../icons/PasteIcon";
|
||||
|
||||
import { useUserId } from "../../contexts/UserIdContext";
|
||||
|
||||
import {
|
||||
SelectionItemsChangeEventHandler,
|
||||
RequestCloseEventHandler,
|
||||
SelectionItemsCreateEventHandler,
|
||||
} from "../../types/Events";
|
||||
import { Map } from "../../types/Map";
|
||||
import { Selection } from "../../types/Select";
|
||||
import { TokenState } from "../../types/TokenState";
|
||||
import { Note } from "../../types/Note";
|
||||
import { Selection, SelectionItem } from "../../types/Select";
|
||||
import { TokenState, TokenStates } from "../../types/TokenState";
|
||||
import { Note, Notes } from "../../types/Note";
|
||||
import { getSelectionPoints } from "../../helpers/selection";
|
||||
import Vector2 from "../../helpers/Vector2";
|
||||
import { useMapStage } from "../../contexts/MapStageContext";
|
||||
import { MapState } from "../../types/MapState";
|
||||
import { isMapState } from "../../validators/MapState";
|
||||
import { isSelection } from "../../validators/Selection";
|
||||
import { getRelativePointerPosition } from "../../helpers/konva";
|
||||
|
||||
type SelectionMenuProps = {
|
||||
isOpen: boolean;
|
||||
active: boolean;
|
||||
onRequestClose: RequestCloseEventHandler;
|
||||
onRequestOpen: () => void;
|
||||
selection: Selection | null;
|
||||
onSelectionChange: React.Dispatch<React.SetStateAction<Selection | null>>;
|
||||
onSelectionItemsChange: SelectionItemsChangeEventHandler;
|
||||
onSelectionItemsCreate: SelectionItemsCreateEventHandler;
|
||||
map: Map | null;
|
||||
mapState: MapState | null;
|
||||
};
|
||||
|
||||
function SelectionMenu({
|
||||
isOpen,
|
||||
active,
|
||||
onRequestClose,
|
||||
onRequestOpen,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
onSelectionItemsChange,
|
||||
onSelectionItemsCreate,
|
||||
map,
|
||||
mapState,
|
||||
}: SelectionMenuProps) {
|
||||
const { addToast } = useToasts();
|
||||
|
||||
const userId = useUserId();
|
||||
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
@ -50,35 +68,102 @@ function SelectionMenu({
|
||||
|
||||
const [menuLeft, setMenuLeft] = useState(0);
|
||||
const [menuTop, setMenuTop] = useState(0);
|
||||
|
||||
const selectionMenuWidth = selection === null ? 48 : 156;
|
||||
|
||||
useEffect(() => {
|
||||
const mapStage = mapStageRef.current;
|
||||
if (isOpen && !wasOpen && selection && mapStage) {
|
||||
const points = getSelectionPoints(selection);
|
||||
const bounds = Vector2.getBoundingBox(points);
|
||||
|
||||
let menuPosition = new Vector2(
|
||||
bounds.center.x + selection.x,
|
||||
bounds.max.y + selection.y
|
||||
);
|
||||
if (isOpen && !wasOpen && mapStage) {
|
||||
const mapImage = mapStage.findOne("#mapImage");
|
||||
if (!mapImage) {
|
||||
return;
|
||||
}
|
||||
menuPosition = Vector2.multiply(menuPosition, {
|
||||
x: mapImage.width(),
|
||||
y: mapImage.height(),
|
||||
});
|
||||
let menuPosition = { x: 0, y: 0 };
|
||||
if (selection) {
|
||||
const points = getSelectionPoints(selection);
|
||||
const bounds = Vector2.getBoundingBox(points);
|
||||
|
||||
menuPosition = new Vector2(
|
||||
bounds.center.x + selection.x,
|
||||
bounds.max.y + selection.y
|
||||
);
|
||||
menuPosition = Vector2.multiply(menuPosition, {
|
||||
x: mapImage.width(),
|
||||
y: mapImage.height(),
|
||||
});
|
||||
} else {
|
||||
const position = getRelativePointerPosition(mapImage);
|
||||
if (position) {
|
||||
menuPosition = position;
|
||||
}
|
||||
}
|
||||
|
||||
const transform = mapImage.getAbsoluteTransform().copy();
|
||||
const absolutePosition = transform.point(menuPosition);
|
||||
const mapElement = document.querySelector(".map");
|
||||
if (mapElement) {
|
||||
const mapRect = mapElement.getBoundingClientRect();
|
||||
setMenuLeft(mapRect.left + absolutePosition.x - 156 / 2);
|
||||
setMenuLeft(mapRect.left + absolutePosition.x - selectionMenuWidth / 2);
|
||||
setMenuTop(mapRect.top + absolutePosition.y + 12);
|
||||
}
|
||||
}
|
||||
}, [isOpen, selection, wasOpen, mapStageRef]);
|
||||
}, [isOpen, selection, wasOpen, mapStageRef, selectionMenuWidth]);
|
||||
|
||||
// Open paste menu if clicking without a selection
|
||||
// Ensure that paste menu is closed when clicking and that the click hasn't moved far
|
||||
const openOnDownRef = useRef(false);
|
||||
const downPositionRef = useRef({ x: 0, y: 0 });
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
openOnDownRef.current = isOpen || !!selection;
|
||||
downPositionRef.current = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
function handleMapElementClick(event: MouseEvent) {
|
||||
const deltaPosition = Vector2.distance(
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
downPositionRef.current
|
||||
);
|
||||
if (
|
||||
!openOnDownRef.current &&
|
||||
!selection &&
|
||||
deltaPosition < 10 &&
|
||||
event.target instanceof HTMLCanvasElement
|
||||
) {
|
||||
onRequestOpen();
|
||||
}
|
||||
}
|
||||
const mapElement = document.querySelector<HTMLElement>(".map");
|
||||
mapElement?.addEventListener("pointerdown", handlePointerDown);
|
||||
mapElement?.addEventListener("click", handleMapElementClick);
|
||||
|
||||
return () => {
|
||||
mapElement?.removeEventListener("pointerdown", handlePointerDown);
|
||||
mapElement?.removeEventListener("click", handleMapElementClick);
|
||||
};
|
||||
}, [isOpen, active, selection, onRequestOpen]);
|
||||
|
||||
function handleModalContent(node: HTMLElement) {
|
||||
if (node) {
|
||||
// Ensure menu is in bounds
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const mapElement = document.querySelector(".map");
|
||||
if (mapElement) {
|
||||
const mapRect = mapElement.getBoundingClientRect();
|
||||
setMenuLeft((prevLeft) =>
|
||||
Math.min(
|
||||
mapRect.right - nodeRect.width,
|
||||
Math.max(mapRect.left, prevLeft)
|
||||
)
|
||||
);
|
||||
setMenuTop((prevTop) =>
|
||||
Math.min(mapRect.bottom - nodeRect.height, prevTop)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectedItems(change: Partial<TokenState> | Partial<Note>) {
|
||||
if (selection) {
|
||||
@ -107,6 +192,7 @@ function SelectionMenu({
|
||||
setItemsLocked(!itemsLocked);
|
||||
}
|
||||
|
||||
// Update lock and visible state depending on selected items
|
||||
useEffect(() => {
|
||||
if (isOpen && selection && mapState) {
|
||||
let allVisible = true;
|
||||
@ -135,32 +221,129 @@ function SelectionMenu({
|
||||
}
|
||||
}, [mapState, selection, isOpen]);
|
||||
|
||||
function handleModalContent(node: HTMLElement) {
|
||||
if (node) {
|
||||
// Focus input
|
||||
const tokenLabelInput =
|
||||
node.querySelector<HTMLInputElement>("#changeNoteText");
|
||||
if (tokenLabelInput) {
|
||||
tokenLabelInput.focus();
|
||||
tokenLabelInput.select();
|
||||
}
|
||||
function addSuccessToast(
|
||||
message: string,
|
||||
tokens?: TokenStates,
|
||||
notes?: Notes
|
||||
) {
|
||||
const numTokens = tokens ? Object.keys(tokens).length : 0;
|
||||
const numNotes = notes ? Object.keys(notes).length : 0;
|
||||
const tokenText = `${numTokens} token${numTokens > 1 ? "s" : ""}`;
|
||||
const noteText = `${numNotes} note${numNotes > 1 ? "s" : ""}`;
|
||||
if (numTokens > 0 && numNotes > 0) {
|
||||
addToast(`${message} ${tokenText} and ${noteText}`);
|
||||
} else if (numTokens > 0) {
|
||||
addToast(`${message} ${tokenText}`);
|
||||
} else if (numNotes > 0) {
|
||||
addToast(`${message} ${noteText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure menu is in bounds
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const mapElement = document.querySelector(".map");
|
||||
if (mapElement) {
|
||||
const mapRect = mapElement.getBoundingClientRect();
|
||||
setMenuLeft((prevLeft) =>
|
||||
Math.min(
|
||||
mapRect.right - nodeRect.width,
|
||||
Math.max(mapRect.left, prevLeft)
|
||||
)
|
||||
);
|
||||
setMenuTop((prevTop) =>
|
||||
Math.min(mapRect.bottom - nodeRect.height, prevTop)
|
||||
);
|
||||
async function handleCopy() {
|
||||
let version = process.env.REACT_APP_VERSION;
|
||||
if (!version || !selection || !mapState) {
|
||||
return;
|
||||
}
|
||||
let clipboard: { version: string; data: MapState; selection: Selection } = {
|
||||
version,
|
||||
selection,
|
||||
data: {
|
||||
tokens: {},
|
||||
notes: {},
|
||||
drawShapes: {},
|
||||
editFlags: [],
|
||||
fogShapes: {},
|
||||
mapId: mapState.mapId,
|
||||
},
|
||||
};
|
||||
for (let item of selection.items) {
|
||||
if (item.type === "token" && clipboard.data.tokens) {
|
||||
clipboard.data.tokens[item.id] = mapState.tokens[item.id];
|
||||
} else if (item.type === "note" && clipboard.data.notes) {
|
||||
clipboard.data.notes[item.id] = mapState.notes[item.id];
|
||||
}
|
||||
}
|
||||
await navigator.clipboard.writeText(JSON.stringify(clipboard));
|
||||
addSuccessToast("Copied", clipboard.data.tokens, clipboard.data.notes);
|
||||
}
|
||||
|
||||
async function handlePaste() {
|
||||
let version = process.env.REACT_APP_VERSION;
|
||||
|
||||
if (!version || !mapState) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
const clipboard = JSON.parse(clipboardText);
|
||||
if (
|
||||
clipboard.version === version &&
|
||||
isMapState(clipboard.data) &&
|
||||
isSelection(clipboard.selection)
|
||||
) {
|
||||
// Find new selection position
|
||||
const mapStage = mapStageRef.current;
|
||||
const mapImage = mapStage?.findOne("#mapImage");
|
||||
if (!mapImage) {
|
||||
return;
|
||||
}
|
||||
const position = getRelativePointerPosition(mapImage);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
const normalizedPosition = Vector2.divide(position, {
|
||||
x: mapImage.width(),
|
||||
y: mapImage.height(),
|
||||
});
|
||||
|
||||
const clipboardSelection = clipboard.selection as Selection;
|
||||
const points = getSelectionPoints(clipboardSelection);
|
||||
const center = Vector2.centroid(points);
|
||||
|
||||
let selectionDelta = Vector2.subtract(normalizedPosition, center);
|
||||
|
||||
const clipboardState = clipboard.data as MapState;
|
||||
let tokenStates: TokenState[] = [];
|
||||
let notes: Note[] = [];
|
||||
let selectionItems: SelectionItem[] = [];
|
||||
for (let token of Object.values(clipboardState.tokens)) {
|
||||
const newId = uuid();
|
||||
tokenStates.push({
|
||||
...token,
|
||||
x: token.x + selectionDelta.x,
|
||||
y: token.y + selectionDelta.y,
|
||||
id: newId,
|
||||
});
|
||||
selectionItems.push({ id: newId, type: "token" });
|
||||
}
|
||||
for (let note of Object.values(clipboardState.notes)) {
|
||||
const newId = uuid();
|
||||
notes.push({
|
||||
...note,
|
||||
x: note.x + selectionDelta.x,
|
||||
y: note.y + selectionDelta.y,
|
||||
id: newId,
|
||||
});
|
||||
selectionItems.push({ id: newId, type: "note" });
|
||||
}
|
||||
onSelectionItemsCreate(tokenStates, notes);
|
||||
|
||||
onSelectionChange({
|
||||
...clipboardSelection,
|
||||
items: selectionItems,
|
||||
x: clipboardSelection.x + selectionDelta.x,
|
||||
y: clipboardSelection.y + selectionDelta.y,
|
||||
});
|
||||
|
||||
onRequestClose();
|
||||
|
||||
addSuccessToast("Pasted", clipboard.data.tokens, clipboard.data.notes);
|
||||
} else {
|
||||
addToast("Invalid data");
|
||||
}
|
||||
} catch {
|
||||
addToast("Unable to paste");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -171,26 +354,47 @@ function SelectionMenu({
|
||||
left={`${menuLeft}px`}
|
||||
onModalContent={handleModalContent}
|
||||
>
|
||||
<Box sx={{ width: "156px", overflow: "hidden" }} p={1}>
|
||||
{/* Only show hide and lock token actions to map owners */}
|
||||
{map && map.owner === userId && (
|
||||
<Flex sx={{ alignItems: "center", justifyContent: "space-around" }}>
|
||||
<Box sx={{ width: `${selectionMenuWidth}px`, overflow: "hidden" }} p={1}>
|
||||
<Flex sx={{ alignItems: "center", justifyContent: "space-around" }}>
|
||||
{selection ? (
|
||||
<>
|
||||
{/* Only show hide and lock token actions to map owners */}
|
||||
{map && map.owner === userId && (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handleVisibleChange}
|
||||
title={itemsVisible ? "Hide Items" : "Show Items"}
|
||||
aria-label={itemsVisible ? "Hide Items" : "Show Items"}
|
||||
>
|
||||
{itemsVisible ? <ShowIcon /> : <HideIcon />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleLockChange}
|
||||
title={itemsLocked ? "Unlock Items" : "Lock Items"}
|
||||
aria-label={itemsLocked ? "Unlock Items" : "Lock Items"}
|
||||
>
|
||||
{itemsLocked ? <LockIcon /> : <UnlockIcon />}
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
title="Copy Items"
|
||||
aria-label="Copy Items"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={handleVisibleChange}
|
||||
title={itemsVisible ? "Hide Items" : "Show Items"}
|
||||
aria-label={itemsVisible ? "Hide Items" : "Show Items"}
|
||||
title="Paste Items"
|
||||
aria-label="Paste Items"
|
||||
onClick={handlePaste}
|
||||
>
|
||||
{itemsVisible ? <ShowIcon /> : <HideIcon />}
|
||||
<PasteIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleLockChange}
|
||||
title={itemsLocked ? "Unlock Items" : "Lock Items"}
|
||||
aria-label={itemsLocked ? "Unlock Items" : "Lock Items"}
|
||||
>
|
||||
{itemsLocked ? <LockIcon /> : <UnlockIcon />}
|
||||
</IconButton>
|
||||
</Flex>
|
||||
)}
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</MapMenu>
|
||||
);
|
||||
|
@ -4,6 +4,7 @@ import SelectionMenu from "../components/selection/SelectionMenu";
|
||||
import SelectTool from "../components/tools/SelectTool";
|
||||
import {
|
||||
SelectionItemsChangeEventHandler,
|
||||
SelectionItemsCreateEventHandler,
|
||||
SelectionItemsRemoveEventHandler,
|
||||
} from "../types/Events";
|
||||
import { Map, MapToolId } from "../types/Map";
|
||||
@ -16,6 +17,7 @@ function useMapSelection(
|
||||
mapState: MapState | null,
|
||||
onSelectionItemsChange: SelectionItemsChangeEventHandler,
|
||||
onSelectionItemsRemove: SelectionItemsRemoveEventHandler,
|
||||
onSelectionItemsCreate: SelectionItemsCreateEventHandler,
|
||||
selectedToolId: MapToolId,
|
||||
settings: SelectToolSettings
|
||||
) {
|
||||
@ -69,9 +71,13 @@ function useMapSelection(
|
||||
const selectionMenu = (
|
||||
<SelectionMenu
|
||||
isOpen={isSelectionMenuOpen}
|
||||
active={active}
|
||||
onRequestClose={() => setIsSelectionMenuOpen(false)}
|
||||
onRequestOpen={() => setIsSelectionMenuOpen(true)}
|
||||
selection={selection}
|
||||
onSelectionChange={setSelection}
|
||||
onSelectionItemsChange={onSelectionItemsChange}
|
||||
onSelectionItemsCreate={onSelectionItemsCreate}
|
||||
map={map}
|
||||
mapState={mapState}
|
||||
/>
|
||||
|
16
src/icons/CopyIcon.tsx
Normal file
16
src/icons/CopyIcon.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M15,20H5V7c0-0.55-0.45-1-1-1h0C3.45,6,3,6.45,3,7v13c0,1.1,0.9,2,2,2h10c0.55,0,1-0.45,1-1v0C16,20.45,15.55,20,15,20z M20,16V4c0-1.1-0.9-2-2-2H9C7.9,2,7,2.9,7,4v12c0,1.1,0.9,2,2,2h9C19.1,18,20,17.1,20,16z M18,16H9V4h9V16z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyIcon;
|
16
src/icons/PasteIcon.tsx
Normal file
16
src/icons/PasteIcon.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
function PasteIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm6 18H6c-.55 0-1-.45-1-1V5c0-.55.45-1 1-1h1v1c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V4h1c.55 0 1 .45 1 1v14c0 .55-.45 1-1 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasteIcon;
|
@ -342,6 +342,18 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
|
||||
]);
|
||||
}
|
||||
|
||||
function handleSelectionItemsCreate(
|
||||
tokenStates: TokenState[],
|
||||
notes: Note[]
|
||||
) {
|
||||
const tokenAction = new AddStatesAction(tokenStates);
|
||||
const noteAction = new AddStatesAction(notes);
|
||||
addActions([
|
||||
{ type: "tokens", action: tokenAction },
|
||||
{ type: "notes", action: noteAction },
|
||||
]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function handlePeerData({ id, data, reply }: PeerDataEvent) {
|
||||
if (id === "assetRequest") {
|
||||
@ -407,6 +419,7 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
|
||||
onMapTokenStateRemove={handleMapTokenStateRemove}
|
||||
onSelectionItemsChange={handleSelectionItemsChange}
|
||||
onSelectionItemsRemove={handleSelectionItemsRemove}
|
||||
onSelectionItemsCreate={handleSelectionItemsCreate}
|
||||
onMapChange={handleMapChange}
|
||||
onMapReset={handleMapReset}
|
||||
onMapDraw={handleMapDraw}
|
||||
|
@ -67,3 +67,7 @@ export type SelectionItemsRemoveEventHandler = (
|
||||
tokenStateIds: string[],
|
||||
noteIds: string[]
|
||||
) => void;
|
||||
export type SelectionItemsCreateEventHandler = (
|
||||
tokenStates: TokenState[],
|
||||
notes: Note[]
|
||||
) => void;
|
||||
|
Loading…
Reference in New Issue
Block a user