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..85b860a89 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,44 @@ "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", + "deleteElement-warning-header": "Caution!", + "deleteElement-warning": "This will remove the element! Are you sure?" + }, + + "interactiveElementsEditor": { + "title": { + "textbox": "Textbox Editor", + "quiz": "Quiz Editor" + }, + "start": "Start", + "submit": "Submit", + "textbox": { + "description": "A little box for displaying a small amount of text and optionally a link. The box remains on screen for ten seconds.", + "text": "Text", + "link": "Link" + }, + "quiz": { + "description": "Stops the video at the start time to display a multiple-choice quiz.", + "question": "Question", + "answers": "Answers", + "answer": "Answer", + "answerCorrect": "Correct", + "answerDelete": "Remove" + } + }, + "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/CuttingActionsContextMenu.tsx b/src/main/CuttingActionsContextMenu.tsx index f5a0138ec..cf1733094 100644 --- a/src/main/CuttingActionsContextMenu.tsx +++ b/src/main/CuttingActionsContextMenu.tsx @@ -11,8 +11,12 @@ import { cut, markAsDeletedOrAlive, mergeLeft, mergeRight, selectIsCurrentSegmen const CuttingActionsContextMenu: React.FC<{ children: React.ReactNode, + isChapters?: boolean + isInteractiveElements?: boolean, }> = ({ children, + isChapters = false, + isInteractiveElements = false, }) => { const { t } = useTranslation(); @@ -60,12 +64,34 @@ const CuttingActionsContextMenu: React.FC<{ }, ]; + const render = () => { + if (isChapters) { + return ( + <> + {children} + + ); + } + if (isInteractiveElements) { + return ( + <> + {children} + + ); + } + return ( + + {children} + + ); + }; + return ( - - {children} - + <> + {render()} + ); }; diff --git a/src/main/InteractiveElementEditor.tsx b/src/main/InteractiveElementEditor.tsx new file mode 100644 index 000000000..de44f7ab5 --- /dev/null +++ b/src/main/InteractiveElementEditor.tsx @@ -0,0 +1,347 @@ +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 updateLink = (value: string) => { + setTextbox({ + ...textbox, + link: value, + }); + }; + + const submit = () => { + dispatch(addInteractiveElement(textbox)); + modalRef.current?.close?.(); + }; + + const modalStyle = css({ + display: "flex", + flexDirection: "column", + gap: "10px", + }); + + const descriptionStyle = css({ + maxWidth: "400px", + }); + + const fieldsStyle = css({ + display: "grid", + gridTemplateColumns: "1fr 4fr", + justifyContent: "center", + alignItems: "center", + gap: "10px", + }); + + return ( +
+
+ {t("interactiveElementsEditor.textbox.description")} +
+
+ + + + updateText(e.target.value)} + /> + + updateLink(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: [ + { text: "", correct: true }, + { text: "", correct: true }, + ], + 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", + gap: "10px", + }); + + const descriptionStyle = css({ + maxWidth: "480px", + }); + + const fieldsStyle = css({ + display: "grid", + gridTemplateColumns: "1fr 4fr", + justifyContent: "center", + alignItems: "center", + gap: "10px", + }); + + const segmentButtonStyle = css({ + height: "100%", + width: "44px", + 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 ( +
+
+ {t("interactiveElementsEditor.quiz.description")} +
+
+ + + + updateQuestion(e.target.value)} + /> + +
+
+
{t("interactiveElementsEditor.quiz.answer")}
+
{t("interactiveElementsEditor.quiz.answerCorrect")}
+
{t("interactiveElementsEditor.quiz.answerDelete")}
+
+ { quiz.answers.map((answer, i) => { + return ( +
+ updateAnswerText(i, e.target.value)} + /> + updateAnswerCorrect(i, e.target.checked)} + /> + 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..dbd141830 --- /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, LuType } 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..dda265845 --- /dev/null +++ b/src/main/InteractiveElementsList.tsx @@ -0,0 +1,279 @@ +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 { ConfirmationModal, ConfirmationModalHandle, ModalHandle, ProtoButton, useColorScheme } from "@opencast/appkit"; +import { useTheme } from "../themes"; +import { convertMsToReadableString } from "../util/utilityFunctions"; +import { LuFileQuestion, LuPen, LuType, 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"; +import { selectSegments } from "../redux/videoSlice"; + +/** + * 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 deleteModalRef = useRef(null); + + const segments = useAppSelector(selectSegments); + let wouldBeDeleted = false; + + for (const segment of segments) { + if (segment.start < item.start && segment.end > item.start) { + wouldBeDeleted = segment.deleted; + } + } + + 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: wouldBeDeleted ? "rgba(200, 0, 0, 1)" : `${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", + 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} +
+ + + + + + + deleteModalRef.current?.open()} + css={[basicButtonStyle(theme), segmentButtonStyle, { marginRight: "2px" }]} + > + + + + + { + deleteModalRef.current?.done(); + deleteItem(); + }} + ref={deleteModalRef} + text={{ + cancel: t("modal.cancel"), + close: t("modal.close"), + areYouSure: t("modal.areYouSure"), + }} + > + {t("interactiveElements.deleteElement-warning")} + +
+ ); +}); + +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..e5f152bf4 --- /dev/null +++ b/src/main/InteractiveElementsTimeline.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useRef, useState } from "react"; +import { css } from "@emotion/react"; +import { useAppDispatch, useAppSelector } from "../redux/store"; +import { selectDuration, selectSegments } from "../redux/videoSlice"; +import Draggable, { DraggableEventHandler } from "react-draggable"; +import { useTheme } from "../themes"; +import { InteractiveElement, selectInteractiveElements, updateStartAtIndex } from "../redux/interactiveElementsSlice"; +import { LuFileQuestion, LuType } 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 segments = useAppSelector(selectSegments); + let wouldBeDeleted = false; + + 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 + + for (const segment of segments) { + if (segment.start < props.item.start && segment.end > props.item.start) { + wouldBeDeleted = segment.deleted; + } + } + + 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: props.item.type === "Textbox" ? `${(10000 / duration) * 100}%` : "32px", + minWidth: "32px", + height: "32px", + background: wouldBeDeleted ? "rgba(200, 0, 0, 1)" : `${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<{