feat: import/export current fit as EFT string (#51)

Sadly, due to browser protection, we cannot just read the clipboard at
will. So instead, you get a textarea where you have to paste your fit
in.
This commit is contained in:
Patric Stout
2023-12-22 20:03:11 +01:00
committed by GitHub
parent bbfd275f1d
commit ac6806f4db
7 changed files with 232 additions and 4 deletions

View File

@@ -25,6 +25,10 @@
height: 20px;
line-height: 20px;
}
.buttonMax {
text-align: center;
width: calc(100% - 40px - 2px);
}
.button:hover {
background-color: #864735;
@@ -67,21 +71,41 @@
padding: 10px;
}
.popup > div > .button {
margin-top: 10px;
}
.popup > div > .button:first-child {
margin-top: 0;
}
.renameEdit {
margin-right: 10px;
}
.alreadyExists {
text-align: center;
max-width: 50%;
}
.alreadyExistsButtons {
margin-top: 10px;
text-align: center;
}
.alreadyExistsButtons .button {
margin-right: 10px;
width: 35%;
text-align: center;
width: calc(50% - 48px);
}
.alreadyExistsButtons .button:last-child {
margin-right: 0;
}
.paste .button {
text-align: center;
width: calc(100% - 42px);
}
.pasteTextarea {
height: 60px;
margin-bottom: 10px;
margin-top: 4px;
width: 300px;
}

View File

@@ -26,7 +26,7 @@ const withEveDataProvider: Decorator<Record<string, never>> = (Story, context) =
<DogmaEngineProvider>
<LocalFitProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<div style={{marginTop: "50px"}}>
<div style={{marginTop: "100px"}}>
<ModalDialogAnchor />
<Story />
</div>

View File

@@ -6,6 +6,8 @@ import { ModalDialog } from "../ModalDialog";
import { ShipSnapshotContext } from "../ShipSnapshotProvider";
import styles from "./FitButtonBar.module.css";
import { useFormatAsEft } from "../FormatAsEft";
import { useFormatEftToEsi } from "../FormatEftToEsi";
const SaveButton = () => {
const shipSnapshot = React.useContext(ShipSnapshotContext);
@@ -66,6 +68,75 @@ const SaveButton = () => {
</>
}
const ClipboardButton = () => {
const shipSnapshot = React.useContext(ShipSnapshotContext);
const toEft = useFormatAsEft();
const eftToEsiFit = useFormatEftToEsi();
const [isPopupOpen, setIsPopupOpen] = React.useState(false);
const [isPasteOpen, setIsPasteOpen] = React.useState(false);
const textAreaRef = React.useRef<HTMLTextAreaElement>(null);
const copyToClipboard = React.useCallback(() => {
const eft = toEft();
if (eft === undefined) return;
navigator.clipboard.writeText(eft);
setIsPopupOpen(false);
}, [toEft]);
const importFromClipboard = React.useCallback(() => {
if (!shipSnapshot.loaded) return;
const textArea = textAreaRef.current;
if (textArea === null) return;
const eft = textArea.value;
if (eft === "") return;
const fit = eftToEsiFit(eft);
if (fit === undefined) return;
shipSnapshot.changeFit(fit);
setIsPasteOpen(false);
setIsPopupOpen(false);
}, [eftToEsiFit, shipSnapshot]);
return <>
<div className={styles.popupButton} onMouseOver={() => setIsPopupOpen(true)} onMouseOut={() => setIsPopupOpen(false)}>
<div className={styles.button}>
Clipboard
</div>
<div className={clsx(styles.popup, {[styles.collapsed]: !isPopupOpen})}>
<div>
<div className={styles.button} onClick={() => setIsPasteOpen(true)}>
Import from Clipboard
</div>
<div className={clsx(styles.button, styles.buttonMax)} onClick={() => copyToClipboard()}>
Copy to Clipboard
</div>
</div>
</div>
</div>
<ModalDialog visible={isPasteOpen} onClose={() => setIsPasteOpen(false)} className={styles.paste} title="Import from Clipboard">
<div>
<div>
Paste the EFT fit here
</div>
<div>
<textarea autoFocus className={styles.pasteTextarea} ref={textAreaRef} />
</div>
<span className={clsx(styles.button, styles.buttonSmall)} onClick={() => importFromClipboard()}>
Import
</span>
</div>
</ModalDialog>
</>
}
const RenameButton = () => {
const shipSnapshot = React.useContext(ShipSnapshotContext);
@@ -106,6 +177,7 @@ const RenameButton = () => {
export const FitButtonBar = () => {
return <div className={styles.fitButtonBar}>
<SaveButton />
<ClipboardButton />
<RenameButton />
</div>
};

View File

@@ -0,0 +1,40 @@
import type { Decorator, Meta, StoryObj } from '@storybook/react';
import React from "react";
import { fullFit } from '../../.storybook/fits';
import { EveDataProvider } from '../EveDataProvider';
import { FormatAsEft } from './FormatAsEft';
import { ShipSnapshotProvider } from '../ShipSnapshotProvider';
import { DogmaEngineProvider } from '../DogmaEngineProvider';
const meta: Meta<typeof FormatAsEft> = {
component: FormatAsEft,
tags: ['autodocs'],
title: 'Function/FormatAsEft',
};
const withEveDataProvider: Decorator<Record<string, never>> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<Story />
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
}
export default meta;
type Story = StoryObj<typeof FormatAsEft>;
export const Default: Story = {
decorators: [withEveDataProvider],
parameters: {
snapshot: {
fit: fullFit,
skills: {},
}
},
};

View File

@@ -0,0 +1,90 @@
import React from "react";
import { EveDataContext } from '../EveDataProvider';
import { ShipSnapshotContext, ShipSnapshotSlotsType } from "../ShipSnapshotProvider";
/** Mapping between slot types and ESI flags (for first slot in the type). */
const esiFlagMapping: Record<ShipSnapshotSlotsType, number[]> = {
"lowslot": [
11, 12, 13, 14, 15, 16, 17, 18
],
"medslot": [
19, 20, 21, 22, 23, 24, 25, 26
],
"hislot": [
27, 28, 29, 30, 31, 32, 33, 34
],
"rig": [
92, 93, 94
],
"subsystem": [
125, 126, 127, 128
],
};
/** Mapping between slot-type and the EFT string name. */
const slotToEft: Record<ShipSnapshotSlotsType, string> = {
"lowslot": "Low Slot",
"medslot": "Mid Slot",
"hislot": "High Slot",
"rig": "Rig Slot",
"subsystem": "Subsystem Slot",
};
/**
* Convert current fit to an EFT string.
*/
export function useFormatAsEft() {
const eveData = React.useContext(EveDataContext);
const shipSnapshot = React.useContext(ShipSnapshotContext);
return (): string | undefined => {
if (!eveData?.loaded) return undefined;
if (!shipSnapshot?.loaded || shipSnapshot.fit == undefined) return undefined;
let eft = "";
const shipType = eveData.typeIDs?.[shipSnapshot.fit.ship_type_id];
if (!shipType) return undefined;
eft += `[${shipType.name}, ${shipSnapshot.fit.name}]\n`;
for (const slotType of Object.keys(esiFlagMapping) as ShipSnapshotSlotsType[]) {
let index = 1;
for (const flag of esiFlagMapping[slotType]) {
if (index > shipSnapshot.slots[slotType]) break;
index += 1;
const module = shipSnapshot.fit.items.find((item) => item.flag === flag);
if (module === undefined) {
eft += "[Empty " + slotToEft[slotType] + "]\n";
continue;
}
const moduleType = eveData.typeIDs?.[module.type_id];
if (moduleType === undefined) {
eft += "[Empty " + slotToEft[slotType] + "]\n";
continue;
}
eft += `${moduleType.name}\n`;
}
eft += "\n";
}
return eft;
};
};
/**
* useFormatAsEft() converts the current fit to an EFT string.
*
* Note: do not use this React component itself, but the useFormatAsEft() React hook instead.
*/
export const FormatAsEft = () => {
const toEft = useFormatAsEft();
return <pre>{toEft()}</pre>
};

1
src/FormatAsEft/index.ts Normal file
View File

@@ -0,0 +1 @@
export { useFormatAsEft } from "./FormatAsEft";

View File

@@ -7,6 +7,7 @@ export * from './EveShipFitHash';
export * from './EveShipFitLink';
export * from './FitButtonBar';
export * from './FormatEftToEsi';
export * from './FormatAsEft';
export * from './HardwareListing';
export * from './HullListing';
export * from './Icon';