From 52662cd6935f18ce3982dedd5cd9bfc0f62917e1 Mon Sep 17 00:00:00 2001 From: lsabor Date: Wed, 25 Mar 2026 15:43:46 -0700 Subject: [PATCH 1/2] issue/3859/feat/copying-distributions closes #3859 implements copy and paste from arbitrary distributions support ctrl+c and ctrl+v --- front_end/messages/cs.json | 6 + front_end/messages/en.json | 6 + front_end/messages/es.json | 6 + front_end/messages/pt.json | 6 + front_end/messages/zh-TW.json | 6 + front_end/messages/zh.json | 6 + .../continuous_clipboard_menu.tsx | 151 ++++++++++++++++++ .../continuous_input_container.tsx | 40 ++++- .../forecast_maker/continuous_input/index.tsx | 7 +- .../continuous_input_wrapper.tsx | 30 ++++ .../forecast_maker_continuous.tsx | 29 +++- 11 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 front_end/src/components/forecast_maker/continuous_input/continuous_clipboard_menu.tsx 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, + }} /> ); From b143f4dc7b902de44a9289436f3948358c592d3d Mon Sep 17 00:00:00 2001 From: lsabor Date: Thu, 16 Jul 2026 13:32:50 -0700 Subject: [PATCH 2/2] Fix distribution clipboard copy/paste edge cases - Skip the Ctrl+C/Ctrl+V shortcut handling when focus is on a form field (e.g. a percentile input), so native copy/paste isn't hijacked there. - Skip the slider<->table mode-sync effect when a mode change comes from a paste, since the pasted components are already correct and shouldn't be overwritten by deriving them from the other (unrelated) input mode. - Store copied quantile values in a scale-independent internal [0,1] location (like slider components already do) instead of the source question's real-world units, and rescale on paste so distributions transfer correctly between questions with different ranges. - Reconcile pasted quantile values with the target question's own open/closed bounds: clamp bound-probability values to 0 on a closed bound (matching validation, since there's no UI to fix it otherwise) or to [0.1, 99.9] on an open bound, and nudge q1/q2/q3 strictly inside a closed bound. Co-Authored-By: Claude Sonnet 5 --- .../continuous_clipboard_menu.tsx | 119 ++++++++++++++++-- .../continuous_input_container.tsx | 11 +- .../forecast_maker/continuous_input/index.tsx | 22 +++- .../continuous_input_wrapper.tsx | 13 +- .../forecast_maker_continuous.tsx | 21 +++- 5 files changed, 171 insertions(+), 15 deletions(-) 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 index b2b1ac68ad..77b74c645b 100644 --- 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 @@ -9,7 +9,22 @@ 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"; @@ -21,9 +36,81 @@ type ClipboardDistribution = { const VALID_INPUT_TYPES: string[] = Object.values(ContinuousForecastInputType); -function isClipboardDistribution( - data: unknown -): data is ClipboardDistribution { +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 && @@ -39,6 +126,9 @@ type Props = { forecastInputMode: ContinuousForecastInputType; sliderComponents: DistributionSliderComponent[]; quantileComponents: DistributionQuantileComponent; + scaling: Scaling; + openLowerBound: boolean; + openUpperBound: boolean; onPaste: ( type: ContinuousForecastInputType, components: DistributionSliderComponent[] | DistributionQuantileComponent @@ -51,6 +141,9 @@ const ContinuousClipboardMenu: FC = ({ forecastInputMode, sliderComponents, quantileComponents, + scaling, + openLowerBound, + openUpperBound, onPaste, disabled, containerRef, @@ -67,7 +160,7 @@ const ContinuousClipboardMenu: FC = ({ components: forecastInputMode === ContinuousForecastInputType.Slider ? sliderComponents - : quantileComponents, + : toPortableQuantileComponents(quantileComponents, scaling), }; try { await navigator.clipboard.writeText(JSON.stringify(data)); @@ -75,7 +168,7 @@ const ContinuousClipboardMenu: FC = ({ } catch { toast.error(t("distributionCopyFailed")); } - }, [forecastInputMode, sliderComponents, quantileComponents, t]); + }, [forecastInputMode, sliderComponents, quantileComponents, scaling, t]); const handlePaste = useCallback(async () => { try { @@ -85,12 +178,21 @@ const ContinuousClipboardMenu: FC = ({ toast.error(t("distributionPasteInvalid")); return; } - onPasteRef.current(parsed.type, parsed.components); + 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")); } - }, [t]); + }, [scaling, openLowerBound, openUpperBound, t]); // Ctrl+C / Ctrl+V keyboard shortcuts useEffect(() => { @@ -99,6 +201,9 @@ const ContinuousClipboardMenu: FC = ({ 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 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 828f96c7e2..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 @@ -15,6 +15,7 @@ import { NumericUserForecast, DistributionSliderComponent, DistributionQuantileComponent, + Scaling, } from "@/types/question"; import cn from "@/utils/core/cn"; import { isForecastActive } from "@/utils/forecasts/helpers"; @@ -33,11 +34,12 @@ export type ContinuousInputContainerProps = { clipboardData?: { sliderComponents: DistributionSliderComponent[]; quantileComponents: DistributionQuantileComponent; + scaling: Scaling; + openLowerBound: boolean; + openUpperBound: boolean; onPaste: ( type: ContinuousForecastInputType, - components: - | DistributionSliderComponent[] - | DistributionQuantileComponent + components: DistributionSliderComponent[] | DistributionQuantileComponent ) => void; }; children?: ( @@ -151,6 +153,9 @@ const ContinuousInputContainer: FC = ({ forecastInputMode={forecastInputMode} sliderComponents={clipboardData.sliderComponents} quantileComponents={clipboardData.quantileComponents} + scaling={clipboardData.scaling} + openLowerBound={clipboardData.openLowerBound} + openUpperBound={clipboardData.openUpperBound} onPaste={clipboardData.onPaste} disabled={disabled} containerRef={containerRef} 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 4d4a70e88e..2f555730ec 100644 --- a/front_end/src/components/forecast_maker/continuous_input/index.tsx +++ b/front_end/src/components/forecast_maker/continuous_input/index.tsx @@ -57,7 +57,11 @@ type Props = { predictionMessage?: ReactNode; menu?: ReactNode; copyMenu?: ReactNode; - clipboardData?: ContinuousInputContainerProps["clipboardData"]; + clipboardData?: Omit< + NonNullable, + "scaling" | "openLowerBound" | "openUpperBound" + >; + skipModeSyncRef?: React.RefObject; userPreviousLabel?: string; userPreviousRowClassName?: string; hideCurrentUserRow?: boolean; @@ -87,6 +91,7 @@ const ContinuousInput: FC = ({ menu, copyMenu, clipboardData, + skipModeSyncRef, userPreviousLabel, userPreviousRowClassName, hideCurrentUserRow, @@ -106,6 +111,10 @@ const ContinuousInput: FC = ({ isMounted.current = true; return; } + if (skipModeSyncRef?.current) { + skipModeSyncRef.current = false; + return; + } if ( forecastInputMode === ContinuousForecastInputType.Quantile && (isDirty || @@ -147,7 +156,16 @@ const ContinuousInput: FC = ({ previousForecast={previousForecast} menu={menu} copyMenu={copyMenu} - clipboardData={clipboardData} + 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 0037f82ed0..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"; @@ -269,11 +270,20 @@ 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, @@ -290,7 +300,7 @@ const ContinuousInputWrapper: FC> = ({ } setForecastInputMode(type); }, - [handleChange, option.id, setForecastInputMode] + [handleChange, option.id, setForecastInputMode, forecastInputMode] ); let SubmitControls: ReactNode = null; @@ -462,6 +472,7 @@ const ContinuousInputWrapper: FC> = ({ 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 c6c95683c5..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, useCallback, useEffect, useMemo, useState } from "react"; +import React, { + FC, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { createForecasts, @@ -84,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 @@ -469,6 +478,13 @@ const ForecastMakerContinuous: FC = ({ 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[] @@ -482,7 +498,7 @@ const ForecastMakerContinuous: FC = ({ setIsDirty(true); setShowSuccessBox(false); }, - [] + [forecastInputMode] ); return ( @@ -544,6 +560,7 @@ const ForecastMakerContinuous: FC = ({ quantileComponents: quantileDistributionComponents, onPaste: handleClipboardPaste, }} + skipModeSyncRef={skipModeSyncRef} /> );