From 863bb6d77f5fd2ac50091959cc9f510bffc5b115 Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 27 Nov 2025 10:42:28 +0100 Subject: [PATCH 01/10] Add Interactive Video view Adds a new view to the editor. In this view users can create and edit interactive elements such as textboxes and quizzes. This view is disabled by default. Backend changes are required to load interactive elements from and save them to Opencast. --- editor-settings.toml | 11 + src/config.ts | 18 ++ src/cssStyles.tsx | 12 + src/i18n/locales/en-US.json | 34 +++ src/main/CuttingActions.tsx | 4 +- src/main/InteractiveElementEditor.tsx | 317 +++++++++++++++++++++++ src/main/InteractiveElements.tsx | 161 ++++++++++++ src/main/InteractiveElementsActions.tsx | 134 ++++++++++ src/main/InteractiveElementsList.tsx | 254 ++++++++++++++++++ src/main/InteractiveElementsTimeline.tsx | 129 +++++++++ src/main/MainContent.tsx | 7 + src/main/MainMenu.tsx | 8 +- src/main/Save.tsx | 27 ++ src/main/SubtitleListEditor.tsx | 31 +-- src/main/SubtitleVideoArea.tsx | 16 +- src/main/Timeline.tsx | 11 +- src/main/VideoPlayers.tsx | 2 +- src/redux/interactiveElementsSlice.ts | 101 ++++++++ src/redux/store.ts | 2 + src/redux/videoSlice.ts | 13 + src/redux/workflowPostSlice.ts | 2 + src/types.ts | 6 +- 22 files changed, 1264 insertions(+), 36 deletions(-) create mode 100644 src/main/InteractiveElementEditor.tsx create mode 100644 src/main/InteractiveElements.tsx create mode 100644 src/main/InteractiveElementsActions.tsx create mode 100644 src/main/InteractiveElementsList.tsx create mode 100644 src/main/InteractiveElementsTimeline.tsx create mode 100644 src/redux/interactiveElementsSlice.ts diff --git a/editor-settings.toml b/editor-settings.toml index 5777d3380..307f3ddc2 100644 --- a/editor-settings.toml +++ b/editor-settings.toml @@ -179,6 +179,17 @@ spanish = { lang = "es" } # Default: false #simpleMode = false +#### +# Interactive Elements +## + +[interactiveElements] + +# If the interactive elements editor appears in the main menu +# Type: boolean +# Default: false +#show = false + ############################################################ diff --git a/src/config.ts b/src/config.ts index be0de814c..ce01a3fb2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,6 +74,12 @@ interface iSettings { mainFlavor: string, defaultVideoFlavor: Flavor | undefined, }; + interactiveElements: { + show: boolean, + textboxesMainFlavor: string, + quizzesMainFlavor: string, + defaultVideoFlavor: Flavor | undefined, + }; } /** @@ -119,6 +125,12 @@ const defaultSettings: iSettings = { mainFlavor: "chapters", defaultVideoFlavor: undefined, }, + interactiveElements: { + show: false, + textboxesMainFlavor: "textboxes", + quizzesMainFlavor: "quizzes", + defaultVideoFlavor: undefined, + }, }; let configFileSettings: iSettings; let urlParameterSettings: iSettings; @@ -433,6 +445,12 @@ const SCHEMA = { mainFlavor: types.string, defaultVideoFlavor: types.map, }, + interactiveElements: { + show: types.boolean, + textboxesMainFlavor: types.string, + quizzesMainFlavor: types.string, + defaultVideoFlavor: types.map, + }, thumbnail: { show: types.boolean, simpleMode: types.boolean, diff --git a/src/cssStyles.tsx b/src/cssStyles.tsx index d4d4f92fa..5e06378f3 100644 --- a/src/cssStyles.tsx +++ b/src/cssStyles.tsx @@ -428,3 +428,15 @@ export const undisplay = (maxWidth: number) => css({ display: "none", }, }); + +export const timeInputStyle = (theme: Theme) => css({ + fontSize: "1em", + marginLeft: "15px", + marginRight: "2px", + borderRadius: "5px", + borderWidth: "1px", + padding: "10px 10px", + background: `${theme.element_bg}`, + border: "1px solid #ccc", + color: `${theme.text}`, +}); diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 88087ea87..29ff94726 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -6,6 +6,7 @@ "subtitles-button": "Subtitles", "chapters-button": "Chapters", "thumbnail-button": "Thumbnail", + "interactiveElements-button": "Interactive Elements", "metadata-button": "Metadata", "keyboard-controls-button": "Keyboard Controls", "tooltip-aria": "Main Navigation" @@ -331,6 +332,39 @@ "editTitle": "Chapter Editor" }, + "interactiveElements" : { + "title": "Interactive Elements Editor", + "addTextbox": "Add Textbox", + "addTextbox-tooltip": "Add a textbox at the current timeline marker position.", + "addTextbox-tooltip-aria": "Add. Add a textbox at the current timeline marker position.", + "addQuiz": "Add Quiz", + "addQuiz-tooltip": "Add a quiz at the current timeline marker position.", + "addQuiz-tooltip-aria": "Add. Add a quiz at the current timeline marker position.", + "editElement-tooltip": "Edit the current element", + "editElement-tooltip-aria": "Edit the current element", + "deleteElement-tooltip": "Delete the current element", + "deleteElement-tooltip-aria": "Delete the current element" + }, + + "interactiveElementsEditor": { + "title": { + "textbox": "Textbox Editor", + "quiz": "Quiz Editor" + }, + "start": "Start", + "submit": "Submit", + "textbox": { + "text": "Text", + "link": "Link" + }, + "quiz": { + "question": "Question", + "answers": "Answers", + "answerCorrect": "Correct", + "answerIncorrect": "Incorrect" + } + }, + "keyboardControls": { "header": "Shortcuts", "defaultGroupName": "General", diff --git a/src/main/CuttingActions.tsx b/src/main/CuttingActions.tsx index 25b83c69d..2a1a4e0b5 100644 --- a/src/main/CuttingActions.tsx +++ b/src/main/CuttingActions.tsx @@ -297,7 +297,7 @@ interface cuttingActionsButtonInterface { * A button representing a single action a user can take while cutting * @param param0 */ -const CuttingActionsButton: React.FC = ({ +export const CuttingActionsButton: React.FC = ({ Icon, actionName, actionHandler, @@ -380,7 +380,7 @@ interface ZoomSliderInterface { ariaLabelText: string, } -const ZoomSlider : React.FC = ({ +export const ZoomSlider : React.FC = ({ actionHandler, tooltip, ariaLabelText, diff --git a/src/main/InteractiveElementEditor.tsx b/src/main/InteractiveElementEditor.tsx new file mode 100644 index 000000000..6c160ffc6 --- /dev/null +++ b/src/main/InteractiveElementEditor.tsx @@ -0,0 +1,317 @@ +import { useTranslation } from "react-i18next"; +import { useAppDispatch } from "../redux/store"; +import { TimeInput } from "./SubtitleListEditor"; +import { useState } from "react"; +import { css } from "@emotion/react"; +import { useTheme } from "../themes"; +import { timeInputStyle } from "../cssStyles"; +import { convertMsToReadableString } from "../util/utilityFunctions"; +import { + addInteractiveElement, + InteractiveElement, + Quiz, + Textbox, +} from "../redux/interactiveElementsSlice"; +import { Modal, ModalHandle, ProtoButton } from "@opencast/appkit"; +import { nanoid } from "@reduxjs/toolkit"; +import { LuPlus, LuTrash } from "react-icons/lu"; +import { basicButtonStyle } from "../cssStyles"; + +/** + * Displays an editor view for a selected interactive element + */ +const InteractiveElementsEditor: React.FC<{ + element: Partial + modalRef: React.RefObject +}> = ({ + element, + modalRef, +}) => { + const { t } = useTranslation(); + + const title = element.type === "Quiz" + ? t("interactiveElementsEditor.title.quiz") + : t("interactiveElementsEditor.title.textbox"); + + return ( + + { element.type === "Textbox" && + + } + { element.type === "Quiz" && + + } + + ); +}; + +const TextboxEditor: React.FC<{ + element: Partial, + modalRef: React.RefObject, +}> = ({ + element, + modalRef, +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const dispatch = useAppDispatch(); + + const [textbox, setTextbox] = useState({ + idInternal: nanoid(), + start: 0, + text: "", + type: "Textbox", + ...element, + }); + + + const updateStartTime = (value: number) => { + setTextbox({ + ...textbox, + start: value, + }); + }; + + const updateText = (value: string) => { + setTextbox({ + ...textbox, + text: value, + }); + }; + + const submit = () => { + dispatch(addInteractiveElement(textbox)); + modalRef.current?.close?.(); + }; + + const modalStyle = css({ + display: "flex", + flexDirection: "column", + }); + + const fieldsStyle = css({ + display: "grid", + gridTemplateColumns: "1fr 4fr", + justifyContent: "center", + alignItems: "center", + gap: "10px", + }); + + return ( +
+
+ + + + updateText(e.target.value)} + /> + + updateText(e.target.value)} + /> +
+ + {t("interactiveElementsEditor.submit")} + +
+ ); +}; + +const QuizEditor: React.FC<{ + element: Partial, + modalRef: React.RefObject +}> = ({ + element, + modalRef, +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const dispatch = useAppDispatch(); + + const [quiz, setQuiz] = useState({ + idInternal: nanoid(), + start: 0, + question: "", + answers: [], + type: "Quiz", + ...element, + }); + + const updateStartTime = (value: number) => { + setQuiz({ + ...quiz, + start: value, + }); + }; + + const updateQuestion = (value: string) => { + setQuiz({ + ...quiz, + question: value, + }); + }; + + const updateAnswerText = (index: number, value: string) => { + setQuiz({ + ...quiz, + answers: quiz.answers.map((a, i) => + i === index ? { ...a, text: value } : a, + ), + }); + }; + + const updateAnswerCorrect = (index: number, value: boolean) => { + setQuiz({ + ...quiz, + answers: quiz.answers.map((a, i) => + i === index ? { ...a, correct: value } : a, + ), + }); + }; + + const removeAnswer = (index: number) => { + setQuiz({ + ...quiz, + answers: quiz.answers.filter((_, i) => i !== index), + }); + }; + + const newAnswer = () => { + setQuiz({ + ...quiz, + answers: [ + ...quiz.answers, + { + text: "", + correct: false, + }, + ], + }); + }; + + const submit = () => { + dispatch(addInteractiveElement(quiz)); + modalRef.current?.close?.(); + }; + + const modalStyle = css({ + display: "flex", + flexDirection: "column", + }); + + const fieldsStyle = css({ + display: "grid", + gridTemplateColumns: "1fr 4fr", + justifyContent: "center", + alignItems: "center", + gap: "10px", + }); + + const segmentButtonStyle = css({ + height: "32px", + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, + }); + + const answersStyle = css({ + display: "flex", + flexDirection: "column", + gap: "10px", + }); + + const answerStyle = css({ + display: "grid", + gridTemplateColumns: "4fr 1fr 1fr", + gap: "10px", + }); + + return ( +
+
+ + + + updateQuestion(e.target.value)} + /> + +
+ { quiz.answers.map((answer, i) => { + return ( +
+ updateAnswerText(i, e.target.value)} + /> + updateAnswerCorrect(i, !answer.correct)} + > + {answer.correct + ? t("interactiveElementsEditor.quiz.answerCorrect") + : t("interactiveElementsEditor.quiz.answerIncorrect")} + + removeAnswer(i)} + > + + +
+ ); + })} + newAnswer()} + > + + +
+
+ submit()} + > + {t("interactiveElementsEditor.submit")} + +
+ ); +}; + + + +export default InteractiveElementsEditor; diff --git a/src/main/InteractiveElements.tsx b/src/main/InteractiveElements.tsx new file mode 100644 index 000000000..93a6ab76d --- /dev/null +++ b/src/main/InteractiveElements.tsx @@ -0,0 +1,161 @@ +import { useTranslation } from "react-i18next"; +import { useAppDispatch, useAppSelector } from "../redux/store"; +import { useEffect } from "react"; +import SubtitleVideoArea from "./SubtitleVideoArea"; +import Timeline from "./Timeline"; +import { css } from "@emotion/react"; +import { useTheme } from "../themes"; +import { titleStyle, titleStyleBold } from "../cssStyles"; +import { + dummy, + selectAspectRatio, + selectClickTriggered, + selectCurrentlyAt, + selectCurrentlyAtInSeconds, + selectIsPlaying, + selectIsPlayPreview, + selectPreviewTriggered, + selectQuizzesFromOpencast, + selectTextBoxesFromOpencast, + setAspectRatio, + setClickTriggered, + setCurrentlyAt, + setIsPlaying, + setIsPlayPreview, + setPreviewTriggered, +} from "../redux/videoSlice"; +import InteractiveElementsList from "./InteractiveElementsList"; +import { + InteractiveElement, + Quiz, + selectInteractiveElements, + setInteractiveElements, + Textbox, +} from "../redux/interactiveElementsSlice"; +import InteractiveElementsActions from "./InteractiveElementsActions"; +import { nanoid } from "@reduxjs/toolkit"; + +/** + * Displays an editor view for a selected subtitle file + */ +const InteractiveElements: React.FC = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const theme = useTheme(); + + const textboxTrack = useAppSelector(state => selectTextBoxesFromOpencast(state)); + const quizTrack = useAppSelector(state => selectQuizzesFromOpencast(state)); + const interactiveElements = useAppSelector(state => selectInteractiveElements(state)); + + // Prepare subtitle in redux + useEffect(() => { + // Parse subtitle data from Opencast + if (interactiveElements.length === 0) { + const parsedElements: InteractiveElement[] = []; + if (textboxTrack) { + const textboxes: Textbox[] = JSON.parse(textboxTrack.elementsJSON); + for (const textbox of textboxes) { + parsedElements.push({ + ...textbox, + type: "Textbox", + idInternal: nanoid(), + }); + } + } + if (quizTrack) { + const quizzes: Quiz[] = JSON.parse(quizTrack.elementsJSON); + for (const quiz of quizzes) { + parsedElements.push({ + ...quiz, + type: "Quiz", + idInternal: nanoid(), + }); + } + } + parsedElements.sort((a, b) => a.start - b.start); + + dispatch(setInteractiveElements(parsedElements)); + } + }, [dispatch, interactiveElements.length, textboxTrack, quizTrack]); + + const subtitleEditorStyle = css({ + display: "flex", + flexDirection: "column", + paddingRight: "20px", + paddingLeft: "20px", + gap: "20px", + height: "100%", + }); + + const headerRowStyle = css({ + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + width: "100%", + gap: "10px", + padding: "15px", + }); + + const subAreaStyle = css({ + display: "flex", + flexDirection: "row", + flexGrow: 1, // No fixed height, fill available space + justifyContent: "space-between", + alignItems: "top", + width: "100%", + paddingTop: "10px", + paddingBottom: "10px", + gap: "30px", + borderBottom: `${theme.menuBorder}`, + }); + + const render = () => { + return ( + <> +
+
+ {t("interactiveElements.title")} +
+
+
+ + +
+ + + + ); + }; + + return ( +
+ {render()} +
+ ); +}; + +export default InteractiveElements; diff --git a/src/main/InteractiveElementsActions.tsx b/src/main/InteractiveElementsActions.tsx new file mode 100644 index 000000000..dbfbaafdd --- /dev/null +++ b/src/main/InteractiveElementsActions.tsx @@ -0,0 +1,134 @@ +import React, { useRef, useState } from "react"; + +import { BREAKPOINTS, basicButtonStyle, undisplay } from "../cssStyles"; + +import { LuFileQuestion, LuSquareSigma } from "react-icons/lu"; + +import { css } from "@emotion/react"; + +import { useAppDispatch, useAppSelector } from "../redux/store"; +import { + selectCurrentlyAt, +} from "../redux/videoSlice"; +import { KEYMAP, rewriteKeys } from "../globalKeys"; +import { ActionCreatorWithoutPayload, ActionCreatorWithPayload } from "@reduxjs/toolkit"; + +import { useTranslation } from "react-i18next"; +import { useTheme } from "../themes"; +import { ThemedTooltip } from "./Tooltip"; +import { ModalHandle, ProtoButton } from "@opencast/appkit"; +import { ZoomSlider } from "./CuttingActions"; +import InteractiveElementsEditor from "./InteractiveElementEditor"; +import { InteractiveElement } from "../redux/interactiveElementsSlice"; + +/** + * Defines the different actions a user can perform while in cutting mode + */ +const InteractiveElementsActions: React.FC = () => { + + const { t } = useTranslation(); + + // Init redux variables + const dispatch = useAppDispatch(); + const theme = useTheme(); + + const modalRef = useRef(null); + const [type, setType] = useState("Textbox"); + + const currentlyAt = useAppSelector(selectCurrentlyAt); + + /** + * General action callback for cutting actions + * @param event event triggered by click or button press + * @param action redux event to dispatch + * @param ref Pass a reference if the clicked element should lose focus + */ + const dispatchAction = ( + action: ActionCreatorWithoutPayload | undefined, + actionWithPayload?: ActionCreatorWithPayload, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload?: any, + ref?: React.RefObject, + ) => { + if (action) { + dispatch(action()); + } + if (actionWithPayload) { + dispatch(actionWithPayload(payload)); + } + + // Lose focus if clicked by mouse + if (ref) { + ref.current?.blur(); + } + }; + + const cuttingStyle = css({ + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + flexWrap: "wrap", + }); + + const cuttingActionButtonStyle = css({ + padding: "16px", + }); + + const verticalLineStyle = css({ + borderLeft: "2px solid #DDD;", + height: "32px", + }); + + return ( +
+ + + { + setType("Textbox"); + modalRef.current?.open(); + }} + css={[basicButtonStyle(theme), cuttingActionButtonStyle]} + > + + {t("interactiveElements.addTextbox")} + + +
+ + { + setType("Quiz"); + modalRef.current?.open(); + }} + css={[basicButtonStyle(theme), cuttingActionButtonStyle]} + > + + {t("interactiveElements.addQuiz")} + + +
+ +
+ ); +}; + +export default InteractiveElementsActions; diff --git a/src/main/InteractiveElementsList.tsx b/src/main/InteractiveElementsList.tsx new file mode 100644 index 000000000..b6e5f02c6 --- /dev/null +++ b/src/main/InteractiveElementsList.tsx @@ -0,0 +1,254 @@ +import React, { CSSProperties, useRef } from "react"; +import AutoSizer from "react-virtualized-auto-sizer"; +import { useAppDispatch, useAppSelector } from "../redux/store"; +import { ListChildComponentProps, VariableSizeList } from "react-window"; +import { css } from "@emotion/react"; +import { TimeInput } from "./SubtitleListEditor"; +import { memoize } from "lodash"; +import { useTranslation } from "react-i18next"; +import { ModalHandle, ProtoButton, useColorScheme } from "@opencast/appkit"; +import { useTheme } from "../themes"; +import { convertMsToReadableString } from "../util/utilityFunctions"; +import { LuFileQuestion, LuPen, LuSquareSigma, LuTrash } from "react-icons/lu"; +import { ThemedTooltip } from "./Tooltip"; +import { basicButtonStyle, timeInputStyle } from "../cssStyles"; +import { + InteractiveElement, + removeInteractiveElementById, + selectInteractiveElements, + updateStartAtIndex, +} from "../redux/interactiveElementsSlice"; +import InteractiveElementsEditor from "./InteractiveElementEditor"; + +/** + * Displays an overview of interactive elements and assorted actions + */ +const InteractiveElementsList: React.FC<{ + segmentHeight?: number, +}> = ({ + segmentHeight = 60, +}) => { + const listRef = useRef(null); + + const elements = useAppSelector(state => selectInteractiveElements(state)); + + const listStyle = css({ + display: "flex", + flexDirection: "column", + height: "100%", + width: "60%", + gap: "20px", + }); + + const calcEstimatedSize = React.useCallback(() => { + return segmentHeight; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const itemData = createItemData(elements); + type ItemData = ReturnType + + // useCallback to prevent new function objects getting created on every rerender + const renderSubtitleSegment = React.useCallback( + ({ index, data, style }: ListChildComponentProps) => ( + + ), + [], + ); + + return ( +
+ + {({ height, width }: { height: string | number, width: string | number; }) => ( + segmentHeight} + itemKey={(index, data) => data.items[index].idInternal} + width={width ? width : 0} + overscanCount={4} + estimatedItemSize={calcEstimatedSize()} + innerElementType={innerElementType} + ref={listRef} + > + {renderSubtitleSegment} + + )} + +
+ ); + +}; + +/** + * Helper function for reducing rerender calls caused by react-window + */ +const createItemData = memoize(items => ({ + items, +})); + +/** + * Global variable to synchronize padding for react-window elements + */ +const PADDING_SIZE = 20; + +// Used for padding in the VariableSizeList +const innerElementType = React.forwardRef(({ style, ...rest }, ref) => ( +
+)); + +const ListItem: React.FC<{ + index: number, + data: { items: InteractiveElement[] }, + style: CSSProperties, +}> = React.memo(props => { + // Parse props + const { items } = props.data; + const item = items[props.index]; + + const { t } = useTranslation(); + const theme = useTheme(); + const dispatch = useAppDispatch(); + const { scheme } = useColorScheme(); + + const modalRef = useRef(null); + + const updateStartTime = (value: number) => { + dispatch(updateStartAtIndex({ + index: props.index, + newStart: value, + })); + }; + + const editItem = () => { + modalRef.current?.open(); + }; + + const deleteItem = () => { + dispatch(removeInteractiveElementById(item.idInternal)); + }; + + const segmentStyle = css({ + display: "flex", + flexDirection: "row", + justifyContent: "left", + alignItems: "center", + gap: "20px", + "& textarea, input": { + outline: `${theme.element_outline}`, + }, + "& input": { + marginTop: (scheme === "dark-high-contrast" || scheme === "light-high-contrast" ? "3%" : "0%"), + marginBottom: (scheme === "dark-high-contrast" || scheme === "light-high-contrast" ? "3%" : "0%"), + }, + }); + + const typeStyle = css({ + width: "32px", + height: "32px", + background: `${theme.element_bg}`, + border: "1px solid #ccc", + zIndex: "1000", + display: "flex", + justifyContent: "center", + alignItems: "center", + }); + + const segmentButtonStyle = css({ + width: "32px", + height: "32px", + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, + zIndex: "1000", + }); + + const textFieldStyle = css({ + flexGrow: "7", + minWidth: "100px", + maxWidth: "200px", + height: "32px", + background: `${theme.element_bg}`, + border: "1px solid #ccc", + borderRadius: "3px", + display: "flex", + alignItems: "center", + paddingLeft: "8px", + }); + + return ( +
+
+ {item.type === "Textbox" ? : undefined} + {item.type === "Quiz" ? : undefined} +
+ +
+ {item.type === "Textbox" ? item.text : undefined} + {item.type === "Quiz" ? item.question : undefined} +
+ + + + + + + + + + + +
+ ); +}); + +function makeEnterSpaceHandler(callback: () => void) { + return (event: React.KeyboardEvent) => { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + callback(); + } + }; +} + +export default InteractiveElementsList; diff --git a/src/main/InteractiveElementsTimeline.tsx b/src/main/InteractiveElementsTimeline.tsx new file mode 100644 index 000000000..231dbd919 --- /dev/null +++ b/src/main/InteractiveElementsTimeline.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useRef, useState } from "react"; +import { css } from "@emotion/react"; +import { useAppDispatch, useAppSelector } from "../redux/store"; +import { selectDuration } from "../redux/videoSlice"; +import Draggable, { DraggableEventHandler } from "react-draggable"; +import { useTheme } from "../themes"; +import { InteractiveElement, selectInteractiveElements, updateStartAtIndex } from "../redux/interactiveElementsSlice"; +import { LuFileQuestion, LuSquareSigma } from "react-icons/lu"; +import InteractiveElementsEditor from "./InteractiveElementEditor"; +import { ModalHandle } from "@opencast/appkit"; + +const InteractiveElementsTimeline: React.FC<{ + timelineWidth: number + timelineHeight: number +}> = ({ + timelineWidth, + timelineHeight, +}) => { + const arbitraryHeight = 80; + const elements = useAppSelector(state => selectInteractiveElements(state)); + + const segmentsListStyle = css({ + position: "absolute", + width: "100%", + height: timelineHeight, + overflow: "hidden", + display: "flex", + alignItems: "center", + }); + + return ( +
+ {elements.map((item, i) => { + return ( + + ); + })} +
+ ); +}; + +const InteractiveElementSegment: React.FC<{ + timelineWidth: number, + item: InteractiveElement, + index: number, + height: number; +}> = React.memo(props => { + const dispatch = useAppDispatch(); + const theme = useTheme(); + const duration = useAppSelector(selectDuration); + + const modalRef = useRef(null); + const [controlledPosition, setControlledPosition] = useState({ x: 0, y: 0 }); + const [isGrabbed, setIsGrabbed] = useState(false); + const nodeRef = useRef(null); // For supressing "ReactDOM.findDOMNode() is deprecated" warning + const draggedRef = useRef(false); // For preventing onClicks when done dragging + + useEffect(() => { + setControlledPosition({ x: (props.item.start / duration) * (props.timelineWidth), y: 0 }); + }, [props.item.start, duration, props.timelineWidth]); + + const editItem = () => { + const dragged = draggedRef.current; + draggedRef.current = false; + if (!dragged) { + modalRef.current?.open(); + } + }; + + const onStartDrag: DraggableEventHandler = _e => { + setIsGrabbed(true); + }; + + const onStopDrag: DraggableEventHandler = (_e, position) => { + dispatch(updateStartAtIndex({ + index: props.index, + newStart: (position.x / props.timelineWidth) * (duration), + })); + + setIsGrabbed(false); + }; + + const segmentStyle = css({ + position: "absolute", + width: "32px", + height: "32px", + background: `${theme.element_bg}`, + border: "1px solid #ccc", + zIndex: "1000", + display: "flex", + justifyContent: "center", + alignItems: "center", + + cursor: isGrabbed ? "grabbing" : "grab", + }); + + return ( + <> + { draggedRef.current = true; }} + onStop={onStopDrag} + defaultPosition={{ x: 10, y: 10 }} + position={controlledPosition} + axis="x" + bounds="parent" + nodeRef={nodeRef} + cancel={".react-resizable-handle"} + > +
+ {props.item.type === "Textbox" ? : undefined} + {props.item.type === "Quiz" ? : undefined} +
+
+ + + ); +}); + +export default InteractiveElementsTimeline; diff --git a/src/main/MainContent.tsx b/src/main/MainContent.tsx index bfd78518a..1be8f5df2 100644 --- a/src/main/MainContent.tsx +++ b/src/main/MainContent.tsx @@ -23,6 +23,7 @@ import { useTheme } from "../themes"; import Thumbnail from "./Thumbnail"; import Cutting from "./Cutting"; import Chapter from "./Chapter"; +import InteractiveElements from "./InteractiveElements"; /** * A container for the main functionality @@ -127,6 +128,12 @@ const MainContent: React.FC = () => {
); + } else if (mainMenuState === MainMenuStateNames.interactiveElements) { + return ( +
+ +
+ ); } else if (mainMenuState === MainMenuStateNames.finish) { return (
diff --git a/src/main/MainMenu.tsx b/src/main/MainMenu.tsx index 2eeb0a6dc..595594ce5 100644 --- a/src/main/MainMenu.tsx +++ b/src/main/MainMenu.tsx @@ -3,7 +3,7 @@ import React from "react"; import { css, SerializedStyles } from "@emotion/react"; import { IconType } from "react-icons"; -import { LuScissors, LuFilm, LuFileText, LuSquareCheckBig, LuBookOpenText } from "react-icons/lu"; +import { LuScissors, LuFilm, LuFileText, LuSquareCheckBig, LuBookOpenText, LuSquareMousePointer } from "react-icons/lu"; import { LuImage } from "react-icons/lu"; import SubtitleIcon from "../img/subtitle.svg?react"; @@ -88,6 +88,12 @@ const MainMenu: React.FC = () => { bottomText={t(MainMenuStateNames.thumbnail)} ariaLabelText={t(MainMenuStateNames.thumbnail)} />} + {settings.interactiveElements.show && } { + const preparedElements = elements + .filter(element => element.type === type) + .map(({ type, idInternal, ...rest }) => rest); + return ({ + id: id ?? nanoid(), + elementsJSON: JSON.stringify(preparedElements), + }); + }; + const save = () => { dispatch(postVideoInformation({ segments: segments, @@ -173,6 +198,8 @@ export const SaveButton: React.FC<{ subtitles: prepareSubtitles(subtitles), chapters: prepareSubtitles(chapters), metadata: metadata, + textboxes: prepareInteractiveElement(interactiveElements, "Textbox", textboxesFromOpencast?.id), + quizzes: prepareInteractiveElement(interactiveElements, "Quiz", quizzesFromOpencast?.id), workflow: startWorkflow && selectedWorkflowId ? [{ id: selectedWorkflowId }] : undefined, })); }; diff --git a/src/main/SubtitleListEditor.tsx b/src/main/SubtitleListEditor.tsx index d8f052823..22f8fb05c 100644 --- a/src/main/SubtitleListEditor.tsx +++ b/src/main/SubtitleListEditor.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { shallowEqual } from "react-redux"; import { RootState, useAppDispatch, useAppSelector } from "../redux/store"; -import { basicButtonStyle } from "../cssStyles"; +import { basicButtonStyle, timeInputStyle } from "../cssStyles"; import { KEYMAP } from "../globalKeys"; import { SubtitleCue, SubtitlesInEditor } from "../types"; import { convertMsToReadableString } from "../util/utilityFunctions"; @@ -174,7 +174,6 @@ const SubtitleListEditor: React.FC<{ ], ); - return (
@@ -491,16 +490,9 @@ const SubtitleListSegment : React.FC<{ visibility: "hidden", }); - const fieldStyle = css({ - fontSize: "1em", - marginLeft: "15px", - marginRight: "2px", - borderRadius: "5px", - borderWidth: "1px", - padding: "10px 10px", - background: `${theme.element_bg}`, - border: "1px solid #ccc", - color: `${theme.text}`, + const subtitleTimeInputStyle = css({ + height: isChapterInputs ? "20px" : "20%", + width: "100px", }); const textFieldStyle = css({ @@ -526,7 +518,7 @@ const SubtitleListSegment : React.FC<{