diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 92001528d7..b9804dc922 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2081,5 +2081,11 @@ "tournamentUnfollowModalSubmit": "Zrušit sledování", "switchBackToSlidersHint": "přepněte zpět na posuvníky pro plynulé přizpůsobení", "view": "Zobrazit", + "distributionCopied": "Distribuce zkopírována do schránky", + "distributionCopyFailed": "Nepodařilo se zkopírovat distribuci", + "distributionPasted": "Distribuce vložena ze schránky", + "distributionPasteInvalid": "Ve schránce nebyla nalezena platná distribuce", + "copyDistribution": "Kopírovat distribuci do schránky", + "pasteDistribution": "Vložit distribuci ze schránky", "thousandsOfOpenQuestions": "20 000+ otevřených otázek" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 62c2f0a2ec..40aa08c788 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1417,6 +1417,12 @@ "forecastCopyToAllToastMessage": "Distribution copied to all other questions", "forecastCopyFromToastMessage": "Distribution copied from ''{name}''", "forecastCopyToToastMessage": "Distribution copied from ''{from_name}'' to ''{to_name}''", + "distributionCopied": "Distribution copied to clipboard", + "distributionCopyFailed": "Failed to copy distribution", + "distributionPasted": "Distribution pasted from clipboard", + "distributionPasteInvalid": "No valid distribution found in clipboard", + "copyDistribution": "Copy distribution to clipboard", + "pasteDistribution": "Paste distribution from clipboard", "result": "result", "communityDisclaimer": "Contributed by the community. Learn more", "conditionalBranchResolutionAnnulled": "This branch was annulled because the parent question resolved .", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 141ecec500..5e7adf5c79 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2081,5 +2081,11 @@ "tournamentUnfollowModalSubmit": "Dejar de Seguir", "switchBackToSlidersHint": "vuelve a los deslizadores para un ajuste suave", "view": "Ver", + "distributionCopied": "Distribución copiada al portapapeles", + "distributionCopyFailed": "Error al copiar la distribución", + "distributionPasted": "Distribución pegada desde el portapapeles", + "distributionPasteInvalid": "No se encontró una distribución válida en el portapapeles", + "copyDistribution": "Copiar distribución al portapapeles", + "pasteDistribution": "Pegar distribución desde el portapapeles", "thousandsOfOpenQuestions": "20,000+ preguntas abiertas" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 86c24baee1..dc06fb6947 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2079,5 +2079,11 @@ "tournamentUnfollowModalSubmit": "Deixar de Seguir", "switchBackToSlidersHint": "volte para os controles deslizantes para um ajuste suave", "view": "Visualizar", + "distributionCopied": "Distribuição copiada para a área de transferência", + "distributionCopyFailed": "Falha ao copiar distribuição", + "distributionPasted": "Distribuição colada da área de transferência", + "distributionPasteInvalid": "Nenhuma distribuição válida encontrada na área de transferência", + "copyDistribution": "Copiar distribuição para a área de transferência", + "pasteDistribution": "Colar distribuição da área de transferência", "thousandsOfOpenQuestions": "20.000+ perguntas abertas" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 904b23721d..f605457d83 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2078,5 +2078,11 @@ "tournamentUnfollowModalSubmit": "取消關注", "switchBackToSlidersHint": "切換回滑桿以平滑調整", "view": "檢視", + "distributionCopied": "分配已複製到剪貼簿", + "distributionCopyFailed": "複製分配失敗", + "distributionPasted": "從剪貼簿貼上了分配", + "distributionPasteInvalid": "剪貼簿中未找到有效的分配", + "copyDistribution": "將分配複製到剪貼簿", + "pasteDistribution": "從剪貼簿貼上分配", "thousandsOfOpenQuestions": "20,000+ 開放問題" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 1713c9e194..e37e91a589 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2083,5 +2083,11 @@ "tournamentUnfollowModalSubmit": "取消关注", "switchBackToSlidersHint": "切回滑块以进行更精细的调整", "view": "查看", + "distributionCopied": "分布已复制到剪贴板", + "distributionCopyFailed": "复制分布失败", + "distributionPasted": "分布已从剪贴板粘贴", + "distributionPasteInvalid": "剪贴板中没有找到有效的分布", + "copyDistribution": "复制分布到剪贴板", + "pasteDistribution": "从剪贴板粘贴分布", "thousandsOfOpenQuestions": "20,000+ 开放问题" } diff --git a/front_end/src/components/forecast_maker/continuous_input/continuous_clipboard_menu.tsx b/front_end/src/components/forecast_maker/continuous_input/continuous_clipboard_menu.tsx new file mode 100644 index 0000000000..b2b1ac68ad --- /dev/null +++ b/front_end/src/components/forecast_maker/continuous_input/continuous_clipboard_menu.tsx @@ -0,0 +1,151 @@ +import { faCopy, faPaste } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import { FC, useCallback, useEffect, useRef } from "react"; +import toast from "react-hot-toast"; + +import Button from "@/components/ui/button"; +import { ContinuousForecastInputType } from "@/types/charts"; +import { + DistributionQuantileComponent, + DistributionSliderComponent, +} from "@/types/question"; + +const CLIPBOARD_MARKER = "_metaculus_distribution"; + +type ClipboardDistribution = { + [CLIPBOARD_MARKER]: true; + type: ContinuousForecastInputType; + components: DistributionSliderComponent[] | DistributionQuantileComponent; +}; + +const VALID_INPUT_TYPES: string[] = Object.values(ContinuousForecastInputType); + +function isClipboardDistribution( + data: unknown +): data is ClipboardDistribution { + return ( + typeof data === "object" && + data !== null && + CLIPBOARD_MARKER in data && + (data as ClipboardDistribution)[CLIPBOARD_MARKER] === true && + "type" in data && + "components" in data && + VALID_INPUT_TYPES.includes((data as ClipboardDistribution).type) + ); +} + +type Props = { + forecastInputMode: ContinuousForecastInputType; + sliderComponents: DistributionSliderComponent[]; + quantileComponents: DistributionQuantileComponent; + onPaste: ( + type: ContinuousForecastInputType, + components: DistributionSliderComponent[] | DistributionQuantileComponent + ) => void; + disabled?: boolean; + containerRef?: React.RefObject; +}; + +const ContinuousClipboardMenu: FC = ({ + forecastInputMode, + sliderComponents, + quantileComponents, + onPaste, + disabled, + containerRef, +}) => { + const t = useTranslations(); + + const onPasteRef = useRef(onPaste); + onPasteRef.current = onPaste; + + const handleCopy = useCallback(async () => { + const data: ClipboardDistribution = { + [CLIPBOARD_MARKER]: true, + type: forecastInputMode, + components: + forecastInputMode === ContinuousForecastInputType.Slider + ? sliderComponents + : quantileComponents, + }; + try { + await navigator.clipboard.writeText(JSON.stringify(data)); + toast(t("distributionCopied")); + } catch { + toast.error(t("distributionCopyFailed")); + } + }, [forecastInputMode, sliderComponents, quantileComponents, t]); + + const handlePaste = useCallback(async () => { + try { + const text = await navigator.clipboard.readText(); + const parsed: unknown = JSON.parse(text); + if (!isClipboardDistribution(parsed)) { + toast.error(t("distributionPasteInvalid")); + return; + } + onPasteRef.current(parsed.type, parsed.components); + toast(t("distributionPasted")); + } catch { + toast.error(t("distributionPasteInvalid")); + } + }, [t]); + + // Ctrl+C / Ctrl+V keyboard shortcuts + useEffect(() => { + const container = containerRef?.current; + if (!container) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (!e.ctrlKey && !e.metaKey) return; + + if (e.key === "c") { + // Only intercept if no text is selected + const selection = window.getSelection()?.toString(); + if (selection) return; + e.preventDefault(); + handleCopy(); + } else if (e.key === "v" && !disabled) { + e.preventDefault(); + handlePaste(); + } + }; + + container.addEventListener("keydown", handleKeyDown); + return () => container.removeEventListener("keydown", handleKeyDown); + }, [containerRef, handleCopy, handlePaste, disabled]); + + return ( +
+ + {!disabled && ( + + )} +
+ ); +}; + +export default ContinuousClipboardMenu; diff --git a/front_end/src/components/forecast_maker/continuous_input/continuous_input_container.tsx b/front_end/src/components/forecast_maker/continuous_input/continuous_input_container.tsx index 513fc76e3b..f8983408cf 100644 --- a/front_end/src/components/forecast_maker/continuous_input/continuous_input_container.tsx +++ b/front_end/src/components/forecast_maker/continuous_input/continuous_input_container.tsx @@ -1,7 +1,7 @@ import { faCircleQuestion } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useTranslations } from "next-intl"; -import { FC, ReactNode, useCallback, useMemo, useState } from "react"; +import { FC, ReactNode, useCallback, useMemo, useRef, useState } from "react"; import Checkbox from "@/components/ui/checkbox"; import Switch from "@/components/ui/switch"; @@ -10,10 +10,16 @@ import { ContinuousAreaGraphType, ContinuousForecastInputType, } from "@/types/charts"; -import { QuestionType, NumericUserForecast } from "@/types/question"; +import { + QuestionType, + NumericUserForecast, + DistributionSliderComponent, + DistributionQuantileComponent, +} from "@/types/question"; import cn from "@/utils/core/cn"; import { isForecastActive } from "@/utils/forecasts/helpers"; +import ContinuousClipboardMenu from "./continuous_clipboard_menu"; import ContinuousInputModeSwitcher from "./continuous_input_mode_switcher"; export type ContinuousInputContainerProps = { @@ -24,6 +30,16 @@ export type ContinuousInputContainerProps = { previousForecast?: NumericUserForecast; menu?: ReactNode; copyMenu?: ReactNode; + clipboardData?: { + sliderComponents: DistributionSliderComponent[]; + quantileComponents: DistributionQuantileComponent; + onPaste: ( + type: ContinuousForecastInputType, + components: + | DistributionSliderComponent[] + | DistributionQuantileComponent + ) => void; + }; children?: ( sliderGraphType: ContinuousAreaGraphType, tableGraphType: ContinuousAreaGraphType @@ -40,11 +56,13 @@ const ContinuousInputContainer: FC = ({ onOverlayPreviousForecastChange, menu, copyMenu, + clipboardData, children, disabled, questionType, }) => { const t = useTranslations(); + const containerRef = useRef(null); const [sliderGraphType, setSliderGraphType] = useState("pmf"); @@ -68,7 +86,11 @@ const ContinuousInputContainer: FC = ({ ); return ( -
+
{!disabled && ( = ({ />
+ {clipboardData && ( +
+ +
+ )} {copyMenu && (
{copyMenu} diff --git a/front_end/src/components/forecast_maker/continuous_input/index.tsx b/front_end/src/components/forecast_maker/continuous_input/index.tsx index bf69309977..4d4a70e88e 100644 --- a/front_end/src/components/forecast_maker/continuous_input/index.tsx +++ b/front_end/src/components/forecast_maker/continuous_input/index.tsx @@ -23,7 +23,9 @@ import { } from "@/utils/forecasts/switch_forecast_type"; import { computeQuartilesFromCDF, getCdfBounds } from "@/utils/math"; -import ContinuousInputContainer from "./continuous_input_container"; +import ContinuousInputContainer, { + ContinuousInputContainerProps, +} from "./continuous_input_container"; import ContinuousPredictionChart from "./continuous_prediction_chart"; import ContinuousSlider from "./continuous_slider"; import { validateAllQuantileInputs } from "../helpers"; @@ -55,6 +57,7 @@ type Props = { predictionMessage?: ReactNode; menu?: ReactNode; copyMenu?: ReactNode; + clipboardData?: ContinuousInputContainerProps["clipboardData"]; userPreviousLabel?: string; userPreviousRowClassName?: string; hideCurrentUserRow?: boolean; @@ -83,6 +86,7 @@ const ContinuousInput: FC = ({ predictionMessage, menu, copyMenu, + clipboardData, userPreviousLabel, userPreviousRowClassName, hideCurrentUserRow, @@ -143,6 +147,7 @@ const ContinuousInput: FC = ({ previousForecast={previousForecast} menu={menu} copyMenu={copyMenu} + clipboardData={clipboardData} disabled={disabled || disableInputModeSwitch} questionType={question.type} > diff --git a/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx index a6fb868aa4..e8439df375 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx @@ -21,6 +21,7 @@ import { DistributionQuantileComponent, DistributionSlider, DistributionSliderComponent, + QuantileValue, } from "@/types/question"; import { TranslationKey } from "@/types/translations"; import cn from "@/utils/core/cn"; @@ -261,6 +262,30 @@ const ContinuousInputWrapper: FC> = ({ handleForecastExpiration(option.id, modalSavedState.forecastExpiration); }, [handleForecastExpiration, option.id, modalSavedState.forecastExpiration]); + const handleClipboardPaste = useCallback( + ( + type: ContinuousForecastInputType, + components: DistributionSliderComponent[] | DistributionQuantileComponent + ) => { + if (type === ContinuousForecastInputType.Slider) { + handleChange(option.id, { + type: ContinuousForecastInputType.Slider, + components: components as DistributionSliderComponent[], + }); + } else { + handleChange(option.id, { + type: ContinuousForecastInputType.Quantile, + components: (components as QuantileValue[]).map((c) => ({ + ...c, + isDirty: true, + })), + }); + } + setForecastInputMode(type); + }, + [handleChange, option.id, setForecastInputMode] + ); + let SubmitControls: ReactNode = null; const predictButtonIsDirty = @@ -418,6 +443,11 @@ const ContinuousInputWrapper: FC> = ({ } menu={option.menu} copyMenu={copyMenu} + clipboardData={{ + sliderComponents: option.userSliderForecast, + quantileComponents: option.userQuantileForecast, + onPaste: handleClipboardPaste, + }} userPreviousLabel={showWithdrawnRow ? "(Withdrawn)" : undefined} userPreviousRowClassName={showWithdrawnRow ? "text-xs" : undefined} hideCurrentUserRow={showWithdrawnRow} diff --git a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_continuous.tsx b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_continuous.tsx index bb07cdd392..1fd2b27736 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_continuous.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_continuous.tsx @@ -2,7 +2,7 @@ import { isNil } from "lodash"; import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import React, { FC, ReactNode, useEffect, useMemo, useState } from "react"; +import React, { FC, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { createForecasts, @@ -22,6 +22,7 @@ import { DistributionQuantileComponent, DistributionSlider, DistributionSliderComponent, + QuantileValue, QuestionWithNumericForecasts, } from "@/types/question"; import { sendPredictEvent } from "@/utils/analytics"; @@ -451,6 +452,27 @@ const ForecastMakerContinuous: FC = ({ ); } + const handleClipboardPaste = useCallback( + ( + type: ContinuousForecastInputType, + components: DistributionSliderComponent[] | DistributionQuantileComponent + ) => { + if (type === ContinuousForecastInputType.Slider) { + setSliderDistributionComponents( + components as DistributionSliderComponent[] + ); + } else { + setQuantileDistributionComponents( + (components as QuantileValue[]).map((c) => ({ ...c, isDirty: true })) + ); + } + setForecastInputMode(type); + setIsDirty(true); + setShowSuccessBox(false); + }, + [] + ); + return ( <> = ({ submitControls={SubmitControls} disabled={!canPredict} predictionMessage={predictionMessage} + clipboardData={{ + sliderComponents: sliderDistributionComponents, + quantileComponents: quantileDistributionComponents, + onPaste: handleClipboardPaste, + }} /> );