diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index ec393100b4..192877c538 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2193,6 +2193,12 @@ "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", "maxAttainableCoverageExplanation": "Otázky, které se vyřeší dříve, mají své skóre a pokrytí upravené, aby se zachovala správnost skóre. Více se dozvíte zde.", "viewComment": "Zobrazit komentář", "viewArticle": "Zobrazit článek", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index d6f5699b01..d07dd0c018 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1513,6 +1513,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 de4bb4ac7f..d683afc8c8 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2193,6 +2193,12 @@ "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", "maxAttainableCoverageExplanation": "Las preguntas que se resuelven pronto tienen sus puntuaciones y cobertura truncadas para preservar la propiedad adecuada de la puntuación. Aprende más aquí.", "viewComment": "Ver comentario", "viewArticle": "Ver artículo", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 307662230c..5242875f8d 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2191,6 +2191,12 @@ "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", "maxAttainableCoverageExplanation": "Questões que são resolvidas cedo têm suas pontuações e coberturas truncadas para preservar a correção da pontuação. Saiba mais aqui.", "viewComment": "Ver comentário", "viewArticle": "Ver artigo", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index e7ad8190f0..6feea0ab8c 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2190,6 +2190,12 @@ "tournamentUnfollowModalSubmit": "取消關注", "switchBackToSlidersHint": "切換回滑桿以平滑調整", "view": "檢視", + "distributionCopied": "分配已複製到剪貼簿", + "distributionCopyFailed": "複製分配失敗", + "distributionPasted": "從剪貼簿貼上了分配", + "distributionPasteInvalid": "剪貼簿中未找到有效的分配", + "copyDistribution": "將分配複製到剪貼簿", + "pasteDistribution": "從剪貼簿貼上分配", "maxAttainableCoverageExplanation": "為了保持分數的正確性,早期解決的問題其分數和覆蓋率會被截斷。在這裡了解更多。", "viewComment": "查看評論", "viewArticle": "查看文章", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 9199857908..b424a54c27 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2195,6 +2195,12 @@ "tournamentUnfollowModalSubmit": "取消关注", "switchBackToSlidersHint": "切回滑块以进行更精细的调整", "view": "查看", + "distributionCopied": "分布已复制到剪贴板", + "distributionCopyFailed": "复制分布失败", + "distributionPasted": "分布已从剪贴板粘贴", + "distributionPasteInvalid": "剪贴板中没有找到有效的分布", + "copyDistribution": "复制分布到剪贴板", + "pasteDistribution": "从剪贴板粘贴分布", "maxAttainableCoverageExplanation": "为确保分数的准确性,提前解决的问题,其分数和覆盖率会被截断。在此了解更多信息。", "viewArticle": "查看文章", "createdTimeAgoBy": "由 @{author} 创建于 {timeAgo}", @@ -2375,4 +2381,4 @@ "laborHubJobsChartBaseline": "基准", "predictorsMentionWarning": "只有策展人和管理员可以通知 @predictors。您的提及不会发送通知。", "feedTileQuestionsRecentlyResolved": "{count, plural, one {# 个问题最近已解决} other {# 个问题最近已解决}}" -} \ No newline at end of file +} 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..77b74c645b --- /dev/null +++ b/front_end/src/components/forecast_maker/continuous_input/continuous_clipboard_menu.tsx @@ -0,0 +1,256 @@ +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, + Quantile, + Scaling, +} from "@/types/question"; +import { scaleInternalLocation, unscaleNominalLocation } from "@/utils/math"; + +// Matches the tolerance validateQuantileInput (forecast_maker/helpers.ts) +// enforces for open-bound probabilities: [0.1, 99.9]. +const OPEN_BOUND_PROBABILITY_MIN = 0.1; +const OPEN_BOUND_PROBABILITY_MAX = 99.9; +// Small nudge off 0/1 so a rescaled quantile lands strictly inside a closed +// bound, matching validateQuantileInput's strict `<=`/`>=` range checks. +const CLOSED_BOUND_LOCATION_EPSILON = 0.001; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +const CLIPBOARD_MARKER = "_metaculus_distribution"; + +type ClipboardDistribution = { + [CLIPBOARD_MARKER]: true; + type: ContinuousForecastInputType; + components: DistributionSliderComponent[] | DistributionQuantileComponent; +}; + +const VALID_INPUT_TYPES: string[] = Object.values(ContinuousForecastInputType); + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable; +} + +// Quantile values are in the question's real-world units, which aren't +// meaningful on a different question. `quantile` is a number (25/50/75) for +// the actual value entries; `below_lower_bound`/`above_upper_bound` are +// already scale-independent percentages, so they're left as-is. +function toPortableQuantileComponents( + components: DistributionQuantileComponent, + scaling: Scaling +): DistributionQuantileComponent { + return components.map((c) => + typeof c.quantile === "number" && c.value !== undefined + ? { ...c, value: unscaleNominalLocation(c.value, scaling) } + : c + ); +} + +// Rescales a pasted (portable) quantile distribution into the target +// question's units, and reconciles it with the target's own open/closed +// bounds — which may differ from the question it was copied from: +// - below_lower_bound/above_upper_bound (probability mass outside the +// range) must be exactly 0 on a closed bound (there's no UI to edit that +// cell once it's not 0 — see continuous_table/index.tsx) and within +// [0.1, 99.9] on an open bound. +// - q1/q2/q3 must land strictly inside a closed bound; an open bound has no +// such constraint. +function fromPortableQuantileComponents( + components: DistributionQuantileComponent, + scaling: Scaling, + openLowerBound: boolean, + openUpperBound: boolean +): DistributionQuantileComponent { + return components.map((c) => { + if (c.value === undefined) return c; + if (c.quantile === Quantile.lower) { + return { + ...c, + value: openLowerBound + ? clamp( + c.value, + OPEN_BOUND_PROBABILITY_MIN, + OPEN_BOUND_PROBABILITY_MAX + ) + : 0, + }; + } + if (c.quantile === Quantile.upper) { + return { + ...c, + value: openUpperBound + ? clamp( + c.value, + OPEN_BOUND_PROBABILITY_MIN, + OPEN_BOUND_PROBABILITY_MAX + ) + : 0, + }; + } + // c.value is still the portable internal [0, 1] location at this point + let location = c.value; + if (!openLowerBound && location <= 0) { + location = CLOSED_BOUND_LOCATION_EPSILON; + } + if (!openUpperBound && location >= 1) { + location = 1 - CLOSED_BOUND_LOCATION_EPSILON; + } + return { ...c, value: scaleInternalLocation(location, scaling) }; + }); +} + +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; + scaling: Scaling; + openLowerBound: boolean; + openUpperBound: boolean; + onPaste: ( + type: ContinuousForecastInputType, + components: DistributionSliderComponent[] | DistributionQuantileComponent + ) => void; + disabled?: boolean; + containerRef?: React.RefObject; +}; + +const ContinuousClipboardMenu: FC = ({ + forecastInputMode, + sliderComponents, + quantileComponents, + scaling, + openLowerBound, + openUpperBound, + 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 + : toPortableQuantileComponents(quantileComponents, scaling), + }; + try { + await navigator.clipboard.writeText(JSON.stringify(data)); + toast(t("distributionCopied")); + } catch { + toast.error(t("distributionCopyFailed")); + } + }, [forecastInputMode, sliderComponents, quantileComponents, scaling, 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; + } + const components = + parsed.type === ContinuousForecastInputType.Quantile + ? fromPortableQuantileComponents( + parsed.components as DistributionQuantileComponent, + scaling, + openLowerBound, + openUpperBound + ) + : parsed.components; + onPasteRef.current(parsed.type, components); + toast(t("distributionPasted")); + } catch { + toast.error(t("distributionPasteInvalid")); + } + }, [scaling, openLowerBound, openUpperBound, 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; + // Let native copy/paste happen when the user is focused on a form + // field (e.g. a percentile input) instead of hijacking the shortcut + if (isEditableTarget(e.target)) 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 15689f91a4..9a8c85047b 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,17 @@ import { ContinuousAreaGraphType, ContinuousForecastInputType, } from "@/types/charts"; -import { QuestionType, NumericUserForecast } from "@/types/question"; +import { + QuestionType, + NumericUserForecast, + DistributionSliderComponent, + DistributionQuantileComponent, + Scaling, +} 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 +31,17 @@ export type ContinuousInputContainerProps = { previousForecast?: NumericUserForecast; menu?: ReactNode; copyMenu?: ReactNode; + clipboardData?: { + sliderComponents: DistributionSliderComponent[]; + quantileComponents: DistributionQuantileComponent; + scaling: Scaling; + openLowerBound: boolean; + openUpperBound: boolean; + onPaste: ( + type: ContinuousForecastInputType, + components: DistributionSliderComponent[] | DistributionQuantileComponent + ) => void; + }; children?: ( sliderGraphType: ContinuousAreaGraphType, tableGraphType: ContinuousAreaGraphType @@ -40,11 +58,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 +88,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..2f555730ec 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,11 @@ type Props = { predictionMessage?: ReactNode; menu?: ReactNode; copyMenu?: ReactNode; + clipboardData?: Omit< + NonNullable, + "scaling" | "openLowerBound" | "openUpperBound" + >; + skipModeSyncRef?: React.RefObject; userPreviousLabel?: string; userPreviousRowClassName?: string; hideCurrentUserRow?: boolean; @@ -83,6 +90,8 @@ const ContinuousInput: FC = ({ predictionMessage, menu, copyMenu, + clipboardData, + skipModeSyncRef, userPreviousLabel, userPreviousRowClassName, hideCurrentUserRow, @@ -102,6 +111,10 @@ const ContinuousInput: FC = ({ isMounted.current = true; return; } + if (skipModeSyncRef?.current) { + skipModeSyncRef.current = false; + return; + } if ( forecastInputMode === ContinuousForecastInputType.Quantile && (isDirty || @@ -143,6 +156,16 @@ const ContinuousInput: FC = ({ previousForecast={previousForecast} menu={menu} copyMenu={copyMenu} + clipboardData={ + clipboardData + ? { + ...clipboardData, + scaling: question.scaling, + openLowerBound: !!question.open_lower_bound, + openUpperBound: !!question.open_upper_bound, + } + : undefined + } 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 91c9b83d44..32f07ca40c 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 @@ -7,6 +7,7 @@ import React, { useCallback, useEffect, useMemo, + useRef, useState, } from "react"; @@ -21,6 +22,7 @@ import { DistributionQuantileComponent, DistributionSlider, DistributionSliderComponent, + QuantileValue, } from "@/types/question"; import { TranslationKey } from "@/types/translations"; import cn from "@/utils/core/cn"; @@ -268,6 +270,39 @@ const ContinuousInputWrapper: FC> = ({ handleForecastExpiration(option.id, modalSavedState.forecastExpiration); }, [handleForecastExpiration, option.id, modalSavedState.forecastExpiration]); + const skipModeSyncRef = useRef(false); + + const handleClipboardPaste = useCallback( + ( + type: ContinuousForecastInputType, + components: DistributionSliderComponent[] | DistributionQuantileComponent + ) => { + // Only skip the mode-sync effect if this paste actually changes the + // mode — if it fires while already on the target mode, the effect + // never runs to consume the flag, and it would wrongly suppress the + // next genuine manual mode toggle. + if (type !== forecastInputMode) { + skipModeSyncRef.current = true; + } + 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, forecastInputMode] + ); + let SubmitControls: ReactNode = null; const predictButtonIsDirty = @@ -432,6 +467,12 @@ const ContinuousInputWrapper: FC> = ({ } menu={option.menu} copyMenu={copyMenu} + clipboardData={{ + sliderComponents: option.userSliderForecast, + quantileComponents: option.userQuantileForecast, + onPaste: handleClipboardPaste, + }} + skipModeSyncRef={skipModeSyncRef} 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 ac1b74675f..5a28f3564e 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,15 @@ 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, + useRef, + useState, +} from "react"; import { createForecasts, @@ -22,6 +30,7 @@ import { DistributionQuantileComponent, DistributionSlider, DistributionSliderComponent, + QuantileValue, QuestionWithNumericForecasts, } from "@/types/question"; import { sendPredictEvent } from "@/utils/analytics"; @@ -83,6 +92,7 @@ const ForecastMakerContinuous: FC = ({ const [isDirty, setIsDirty] = useState(false); const [submitError, setSubmitError] = useState(); const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); + const skipModeSyncRef = useRef(false); const previousForecast = question.my_forecasts?.latest; const activeForecast = isOpenQuestionPredicted(question) ? previousForecast @@ -463,6 +473,34 @@ const ForecastMakerContinuous: FC = ({ ); } + const handleClipboardPaste = useCallback( + ( + type: ContinuousForecastInputType, + components: DistributionSliderComponent[] | DistributionQuantileComponent + ) => { + // Only skip the mode-sync effect if this paste actually changes the + // mode — if it fires while already on the target mode, the effect + // never runs to consume the flag, and it would wrongly suppress the + // next genuine manual mode toggle. + if (type !== forecastInputMode) { + skipModeSyncRef.current = true; + } + if (type === ContinuousForecastInputType.Slider) { + setSliderDistributionComponents( + components as DistributionSliderComponent[] + ); + } else { + setQuantileDistributionComponents( + (components as QuantileValue[]).map((c) => ({ ...c, isDirty: true })) + ); + } + setForecastInputMode(type); + setIsDirty(true); + setShowSuccessBox(false); + }, + [forecastInputMode] + ); + return ( <> = ({ submitControls={SubmitControls} disabled={!canPredict} predictionMessage={predictionMessage} + clipboardData={{ + sliderComponents: sliderDistributionComponents, + quantileComponents: quantileDistributionComponents, + onPaste: handleClipboardPaste, + }} + skipModeSyncRef={skipModeSyncRef} /> );