Skip to content

Commit

Permalink
Merge pull request #4152 from serlo/feat/add-interactive-video-plugin
Browse files Browse the repository at this point in the history
feat(editor): add interactive video plugin
  • Loading branch information
elbotho authored Nov 18, 2024
2 parents 3e465ac + 4576182 commit ad05524
Show file tree
Hide file tree
Showing 53 changed files with 1,347 additions and 61 deletions.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@serlo/editor": "workspace:*",
"@serlo/katex-styles": "1.0.1",
"@tippyjs/react": "^4.2.6",
"@vidstack/react": "next",
"array-move": "^4.0.0",
"autoprefixer": "^10.4.20",
"canvas-confetti": "^1.9.3",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/modal-with-close-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface ModalWithCloseButtonProps {
extraCloseButtonClassName?: string
extraOverlayClassName?: string
onEscapeKeyDown?: (event: KeyboardEvent) => void
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void
onKeyDown?: (event: React.KeyboardEvent) => void
}

export function ModalWithCloseButton({
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/serlo-editor-integration/create-plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createHighlightPlugin } from '@editor/plugins/highlight'
import { createImageGalleryPlugin } from '@editor/plugins/image-gallery'
import { injectionPlugin } from '@editor/plugins/injection'
import { createInputExercisePlugin } from '@editor/plugins/input-exercise'
import { interactiveVideoPlugin } from '@editor/plugins/interactive-video'
import {
createArticleIntroduction,
createMultimediaPlugin,
Expand Down Expand Up @@ -80,6 +81,7 @@ export function createPlugins({ lang }: { lang: Instance }): PluginsWithData {
EditorPluginType.ScMcExercise,
EditorPluginType.InputExercise,
EditorPluginType.BlanksExercise,
EditorPluginType.InteractiveVideo,
EditorPluginType.Solution,

EditorPluginType.Unsupported,
Expand Down Expand Up @@ -120,6 +122,14 @@ export function createPlugins({ lang }: { lang: Instance }): PluginsWithData {
...(isProduction
? []
: [{ type: EditorPluginType.Audio, plugin: audioPlugin }]),
...(isProduction
? []
: [
{
type: EditorPluginType.InteractiveVideo,
plugin: interactiveVideoPlugin,
},
]),
{ type: EditorPluginType.Anchor, plugin: anchorPlugin },
{ type: EditorPluginType.PageLayout, plugin: pageLayoutPlugin },
{ type: EditorPluginType.PagePartners, plugin: pagePartnersPlugin },
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/serlo-editor-integration/create-renderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
EditorSpoilerDocument,
EditorExerciseGroupDocument,
EditorDropzoneImageDocument,
EditorInteractiveVideoDocument,
} from '@editor/types/editor-plugins'
import dynamic from 'next/dynamic'
import { ComponentProps } from 'react'
Expand Down Expand Up @@ -66,20 +67,23 @@ const BlanksExerciseStaticRenderer = dynamic<EditorBlanksExerciseDocument>(() =>
(mod) => mod.BlanksExerciseStaticRenderer
)
)
const InteractiveVideoRenderer = dynamic<EditorInteractiveVideoDocument>(() =>
import('@editor/plugins/interactive-video/static').then(
(mod) => mod.InteractiveVideoStaticRenderer
)
)
const InjectionStaticRenderer = dynamic<EditorInjectionDocument>(() =>
import('@editor/plugins/injection/static').then(
(mod) => mod.InjectionStaticRenderer
)
)

const DropzoneImageStaticRenderer = dynamic<
EditorDropzoneImageDocument & { openOverwrite?: boolean; onOpen?: () => void }
>(() =>
import('@editor/plugins/dropzone-image/static').then(
(mod) => mod.DropzoneImageStaticRenderer
)
)

const PageLayoutStaticRenderer = dynamic<EditorPageLayoutDocument>(() =>
import('@editor/plugins/page-layout/static').then(
(mod) => mod.PageLayoutStaticRenderer
Expand All @@ -100,7 +104,6 @@ const SolutionSerloStaticRenderer = dynamic<EditorSolutionDocument>(() =>
'@/serlo-editor-integration/serlo-plugin-wrappers/solution-serlo-static-renderer'
).then((mod) => mod.SolutionSerloStaticRenderer)
)

const SerloTableStaticRenderer = dynamic<EditorSerloTableDocument>(() =>
import('@editor/plugins/serlo-table/static').then(
(mod) => mod.SerloTableStaticRenderer
Expand Down Expand Up @@ -226,6 +229,10 @@ export function createRenderers(): InitRenderersArgs {
type: EditorPluginType.BlanksExercise,
renderer: BlanksExerciseStaticRenderer,
},
{
type: EditorPluginType.InteractiveVideo,
renderer: InteractiveVideoRenderer,
},
{
type: EditorPluginType.Solution,
renderer: SolutionSerloStaticRenderer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function H5pSerloStaticRenderer(props: EditorH5PDocument) {

if (e_id === id) {
editorLearnerEvent.trigger?.({
pluginId: props.id,
verb: 'answered',
correct: e.type === 'h5pExerciseCorrect',
contentType: 'h5p-exercise',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LearnerEvent } from '@editor/plugin/helpers/editor-learner-event'
import { LearnerEventData } from '@editor/plugin/helpers/editor-learner-event'

export function useSerloHandleLearnerEvent() {
function handleLearnerEvent(data: LearnerEvent) {
function handleLearnerEvent(data: LearnerEventData) {
// eslint-disable-next-line no-console
console.log(data)
}
Expand Down
1 change: 1 addition & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
},
"dependencies": {
"@serlo/katex-styles": "1.0.1",
"@vidstack/react": "next",
"dompurify": "^3.2.0",
"lit": "^3.2.1",
"react": "^18.2.0",
Expand Down
30 changes: 30 additions & 0 deletions packages/editor/src/editor-ui/switch-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { cn } from '@editor/utils/cn'

export function SwitchButton({
isOn,
onClick,
}: {
isOn: boolean
onClick: () => void
}) {
return (
<button
className="inline-block cursor-pointer align-bottom"
onClick={onClick}
>
<div
className={cn(
'flex h-6 w-12 rounded-full bg-gray-300 p-1 duration-300 ease-in-out',
isOn && 'bg-green-400'
)}
>
<div
className={cn(
'h-4 w-4 transform rounded-full bg-white shadow-md duration-300 ease-in-out',
isOn && 'translate-x-6'
)}
></div>
</div>
</button>
)
}
31 changes: 26 additions & 5 deletions packages/editor/src/i18n/strings/de/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,28 @@ export const editStrings = {
errorLoading:
'Inhalt konnte nicht geladen werden, bitte überprüfe die ID',
},
interactiveVideo: {
title: 'Interaktives Video',
description: 'Erstelle ein interaktives Video mit Aufgaben',
editOverlayTitle: 'Aufgabe erstellen',
titlePlaceholder: 'Aufgabentitel',
defaultTitle: 'Aufgabe',
autoOpenLabel: 'Automatisch öffnen',
autoOpenExplanation:
'Der Inhalt öffnet wird automatisch angezeigt, wenn das Video dort ankommt',
mandatoryLabel: 'Verpflichtende Aufgabe',
mandatoryExplanation:
'Die Aufgabe muss richtig beantwortet werden, um das Video weiter abzuspielen',
forceRewatchLabel: 'Auto-Wiederholung',
forceRewatchExplanation:
'Wenn die Aufgabe falsch beantwortet wurde, kann der Lerner per Button den letzten Abschnitt noch mal anschauen',
editMark: 'Bearbeiten',
removeMark: 'Löschen',
addOverlayContent: 'Aufgabe an aktueller Stelle einfügen',
addVideo: 'Füge ein Video hinzu (z.B. YouTube)',
changeVideo: 'Video austauschen',
saveButton: 'Speichern',
},
multimedia: {
title: 'Erklärung mit Multimedia-Inhalt',
description:
Expand Down Expand Up @@ -368,11 +390,10 @@ export const editStrings = {
changeInteractive: 'Interaktives Element ändern',
confirmRemoveInteractive:
'Deine aktuellen Änderungen werden dabei überschrieben, bist du sicher?',
createSolution: 'Lösung hinzufügen',
removeSolution: 'Lösung entfernen',
previewMode: 'Vorschau',
previewIsActiveHint: 'Vorschaumodus ist aktiv',
previewIsDeactiveHint: 'Hier kannst du bearbeiten',
createSolution: 'Lösungsvorschlag hinzufügen',
removeSolution: 'Lösungsvorschlag entfernen',
toLearnersView: 'Zur Lernenden-Ansicht',
toEditView: 'Zur Bearbeitungs-Ansicht',
},
exerciseGroup: {
title: 'Aufgabe mit Teilaufgaben',
Expand Down
9 changes: 9 additions & 0 deletions packages/editor/src/i18n/strings/de/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const staticStrings = {
noRevisionForPage: 'Ungegeprüfte Seite',
},
exercise: {
title: 'Aufgabe',
prerequisite: 'Für diese Aufgabe benötigst Du folgendes Grundwissen:',
task: 'Aufgabenstellung',
correct: 'Richtig',
Expand All @@ -70,6 +71,14 @@ export const staticStrings = {
video: {
failed: 'Sorry, das Video konnte nicht geladen werden.',
},
interactiveVideo: {
play: 'Abspielen',
rewind: 'Zurückspulen',
mandatoryWarning: 'Du musst erst die Aufgabe lösen.',
exerciseSolved: "Gut gemacht! Jetzt geht's weiter.",
repeatPromt:
'Schau dir doch noch mal den Teil des Videos vor der Aufgabe an',
},
},
embed: {
activateEmbed: 'Aktivieren',
Expand Down
29 changes: 24 additions & 5 deletions packages/editor/src/i18n/strings/en/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,26 @@ export const editStrings = {
"Please use a valid Serlo ID (just numbers). E.g. '/1555'",
errorLoading: 'Content could not be loaded, please check the id',
},
interactiveVideo: {
title: 'Interactive Video',
description: 'Create an interactive video with questions and feedback.',
editOverlayTitle: 'Edit Exercise',
titlePlaceholder: 'Exercise Title',
defaultTitle: 'Exercise',
autoOpenLabel: 'Automatically open',
autoOpenExplanation: 'Content automatically opens when video is at mark',
mandatoryLabel: 'Mandatory Exercise',
mandatoryExplanation: 'Exercise has to be solved to continue video',
forceRewatchLabel: 'Auto Rewatch',
forceRewatchExplanation:
'If an exercise is answered incorrectly, the video jumps back to the last mark',
editMark: 'Edit',
removeMark: 'Remove',
addOverlayContent: 'Add exercise',
addVideo: 'Add a video url (e.g. YouTube) to get started',
changeVideo: 'Change video',
saveButton: 'Save',
},
multimedia: {
title: 'Multimedia content associated with text',
description:
Expand Down Expand Up @@ -360,11 +380,10 @@ export const editStrings = {
changeInteractive: 'Change interactive element',
confirmRemoveInteractive:
'Your current changes will be replaced. Are you sure?',
createSolution: 'Create solution',
removeSolution: 'Remove solution',
previewMode: 'Preview',
previewIsActiveHint: 'Preview mode is active',
previewIsDeactiveHint: 'Here you can edit',
createSolution: 'Create proposed solution',
removeSolution: 'Remove proposed solution',
toLearnersView: 'To Learners View',
toEditView: 'To Edit View',
},
exerciseGroup: {
title: 'Exercise Group',
Expand Down
8 changes: 8 additions & 0 deletions packages/editor/src/i18n/strings/en/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const staticStrings = {
noRevisionForPage: 'unreviewed page',
},
exercise: {
title: 'Exercise',
prerequisite: 'For this task you need the following basic knowledge:',
task: 'Task',
correct: 'Correct',
Expand All @@ -68,6 +69,13 @@ export const staticStrings = {
video: {
failed: "Sorry, the video couldn't be loaded.",
},
interactiveVideo: {
play: 'Play',
rewind: 'Rewind',
mandatoryWarning: 'You have to solve the task first.',
exerciseSolved: "Well done! Let's move on.",
repeatPromt: 'Take another look at the part of the video before the task',
},
},
embed: {
activateEmbed: 'Activate',
Expand Down
16 changes: 13 additions & 3 deletions packages/editor/src/plugin/helpers/editor-learner-event.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface LearnerEvent {
export interface LearnerEventData {
verb: 'opened' | 'attempted' | 'interacted' | 'answered'
contentType:
| 'input-exercise'
Expand All @@ -10,8 +10,11 @@ export interface LearnerEvent {
| 'solution'
correct?: boolean
value?: object | string | number
pluginId?: string // editor id of the plugin that triggered the event
}
type Trigger = (data: LearnerEvent) => void
type Trigger = (data: LearnerEventData) => void

export const editorLearnerEventName = 'editorLearnerEvent'

export const editorLearnerEvent = (function (): {
init: (triggerIn: Trigger) => void
Expand All @@ -28,8 +31,15 @@ export const editorLearnerEvent = (function (): {
Object.freeze(handleLearnerEvent)
}

function trigger(data: LearnerEvent) {
function trigger(data: LearnerEventData) {
handleLearnerEvent?.(data)

// also trigger as custom js event so other editor plugins can listen to it
const customEvent = new CustomEvent(editorLearnerEventName, {
detail: { ...data },
})

document.dispatchEvent(customEvent)
}

return { init, trigger }
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/plugins/blanks-exercise/static.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { BlanksExerciseMode } from '.'
import { BlanksExerciseRenderer } from './renderer'

export function BlanksExerciseStaticRenderer({
id,
state: { text: childPlugin, mode, extraDraggableAnswers },
}: EditorBlanksExerciseDocument) {
return (
Expand All @@ -17,6 +18,7 @@ export function BlanksExerciseStaticRenderer({
extraDraggableAnswers={extraDraggableAnswers}
onEvaluate={(correct: boolean) => {
editorLearnerEvent.trigger?.({
pluginId: id,
verb: 'answered',
correct,
contentType: 'blanks-exercise',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,11 @@ export function InteractiveExercisesSelection({
return getPluginMenuItems(editorStrings)
.map((menuItem) => {
if (!isExerciseDocument(menuItem.initialState)) return false
const interactive = menuItem.initialState.state.interactive
if (!interactive || !editorPlugins.isSupported(interactive.plugin)) {
const initialState = menuItem.initialState.state.interactive
if (!initialState || !editorPlugins.isSupported(initialState.plugin)) {
return false
}
const pluginMenuItem = {
...menuItem,
initialState: interactive,
}
return pluginMenuItem
return { ...menuItem, initialState }
})
.filter(Boolean) as unknown as PluginMenuItem[]
}, [editorStrings])
Expand Down
17 changes: 4 additions & 13 deletions packages/editor/src/plugins/exercise/components/preview-button.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { EditorTooltip } from '@editor/editor-ui/editor-tooltip'
import { FaIcon } from '@editor/editor-ui/fa-icon'
import { useEditStrings } from '@editor/i18n/edit-strings-provider'
import { faCheckCircle, faCircle } from '@fortawesome/free-regular-svg-icons'
import { faEye, faPencilAlt } from '@fortawesome/free-solid-svg-icons'

export function PreviewButton({
previewActive,
Expand All @@ -15,19 +14,11 @@ export function PreviewButton({
return (
<button
onClick={() => setPreviewActive(!previewActive)}
className="serlo-tooltip-trigger mr-2 rounded-md border border-gray-500 px-1 text-sm transition-all hover:bg-editor-primary-200 focus-visible:bg-editor-primary-200"
className="mr-2 rounded-md bg-editor-primary-200 px-1.5 py-0.5 text-sm transition-colors hover:bg-editor-primary-300 focus-visible:bg-editor-primary-300"
data-qa="plugin-exercise-preview-button"
>
<EditorTooltip
text={
previewActive
? exStrings.previewIsActiveHint
: exStrings.previewIsDeactiveHint
}
className="-ml-5 !pb-1"
/>
{exStrings.previewMode}{' '}
<FaIcon icon={previewActive ? faCheckCircle : faCircle} />
{previewActive ? exStrings.toEditView : exStrings.toLearnersView}{' '}
<FaIcon icon={previewActive ? faPencilAlt : faEye} />
</button>
)
}
Loading

0 comments on commit ad05524

Please sign in to comment.