diff --git a/app/controllers/api/bootcamp/drawings_controller.rb b/app/controllers/api/bootcamp/drawings_controller.rb new file mode 100644 index 0000000000..5c626c82b4 --- /dev/null +++ b/app/controllers/api/bootcamp/drawings_controller.rb @@ -0,0 +1,15 @@ +class API::Bootcamp::DrawingsController < API::Bootcamp::BaseController + before_action :use_drawing + + def update + @drawing.update(code: params[:code]) if params[:code].present? + @drawing.update(title: params[:title]) if params[:title].present? + + render json: {}, status: :ok + end + + private + def use_drawing + @drawing = current_user.bootcamp_drawings.find_by!(uuid: params[:uuid]) + end +end diff --git a/app/css/bootcamp/components/site-header.css b/app/css/bootcamp/components/site-header.css index ff0bdb9f64..d2f619486b 100644 --- a/app/css/bootcamp/components/site-header.css +++ b/app/css/bootcamp/components/site-header.css @@ -13,7 +13,8 @@ } } -body.controller-exercises.action-edit { +body.namespace-bootcamp.controller-exercises.action-edit, +body.namespace-bootcamp.controller-drawings.action-edit { .c-site-header { display: none; } diff --git a/app/helpers/react_components/bootcamp/drawing_page.rb b/app/helpers/react_components/bootcamp/drawing_page.rb index e70931b7a2..826bb5f82f 100644 --- a/app/helpers/react_components/bootcamp/drawing_page.rb +++ b/app/helpers/react_components/bootcamp/drawing_page.rb @@ -6,12 +6,13 @@ def to_s super(id, data) end - def id = "bootcamp-solve-exercise-page" + def id = "bootcamp-drawing-page" def data { drawing: { - uuid: drawing.uuid + uuid: drawing.uuid, + title: drawing.title }, code: { code: drawing.code, @@ -19,7 +20,7 @@ def data }, links: { update_code: Exercism::Routes.api_bootcamp_drawing_url(drawing), - drawings_index: Exercism::Routes.bootcamp_drawings_url(only_path: true) + drawings_index: Exercism::Routes.bootcamp_project_path(:drawing) } } end diff --git a/app/javascript/components/bootcamp/DrawingPage/DrawingPage.tsx b/app/javascript/components/bootcamp/DrawingPage/DrawingPage.tsx new file mode 100644 index 0000000000..4e4f7059d4 --- /dev/null +++ b/app/javascript/components/bootcamp/DrawingPage/DrawingPage.tsx @@ -0,0 +1,114 @@ +import React, { useMemo, useState } from 'react' +import { Header, StudentCodeGetter } from './Header/Header' +import { + Resizer, + useResizablePanels, +} from '../SolveExercisePage/hooks/useResize' +import { CodeMirror } from '../SolveExercisePage/CodeMirror/CodeMirror' +import ErrorBoundary from '../common/ErrorBoundary/ErrorBoundary' +import { useDrawingEditorHandler } from './useDrawingEditorHandler' +import { useLocalStorage } from '@uidotdev/usehooks' +import Scrubber from './Scrubber/Scrubber' +import { debounce } from 'lodash' +import { useSetupDrawingPage } from './useSetupDrawingPage' + +export default function DrawingPage({ + drawing, + code, + links, +}: DrawingPageProps) { + const [savingStateLabel, setSavingStateLabel] = useState('') + + const { + primarySize: LHSWidth, + secondarySize: RHSWidth, + handleMouseDown, + } = useResizablePanels({ + initialSize: 800, + direction: 'horizontal', + localStorageId: 'drawing-page-lhs', + }) + + const { + handleRunCode, + handleEditorDidMount, + getStudentCode, + editorViewRef, + viewContainerRef, + animationTimeline, + frames, + } = useDrawingEditorHandler({ code, links, drawing }) + + const [editorLocalStorageValue, setEditorLocalStorageValue] = useLocalStorage( + 'bootcamp-editor-value-' + drawing.uuid, + { code: code.code, storedAt: code.storedAt } + ) + + useSetupDrawingPage({ + code, + editorLocalStorageValue, + setEditorLocalStorageValue, + }) + + const patchCodeOnDebounce = useMemo(() => { + return debounce(() => { + setSavingStateLabel('Saving...') + patchDrawingCode(links, getStudentCode).then(() => + setSavingStateLabel('Saved') + ) + }, 5000) + }, [setEditorLocalStorageValue]) + + return ( +
+
+
+
+ + + + +
+ + {/* RHS */} +
+
+
+
+
+ ) +} + +async function patchDrawingCode( + links: DrawingPageProps['links'], + getStudentCode: StudentCodeGetter +) { + const studentCode = getStudentCode() + + const response = await fetch(links.updateCode, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code: studentCode, + }), + }) + + if (!response.ok) { + throw new Error('Failed to save code') + } + + return response.json() +} diff --git a/app/javascript/components/bootcamp/DrawingPage/Header/Header.tsx b/app/javascript/components/bootcamp/DrawingPage/Header/Header.tsx new file mode 100644 index 0000000000..396f841758 --- /dev/null +++ b/app/javascript/components/bootcamp/DrawingPage/Header/Header.tsx @@ -0,0 +1,109 @@ +import React, { useCallback, useState } from 'react' +import { wrapWithErrorBoundary } from '@/components/bootcamp/common/ErrorBoundary/wrapWithErrorBoundary' +import { assembleClassNames } from '@/utils/assemble-classnames' + +import { GraphicalIcon } from '@/components/common/GraphicalIcon' + +export type StudentCodeGetter = () => string | undefined + +const DEFAULT_SAVE_BUTTON_LABEL = 'Save' +function _Header({ + links, + savingStateLabel, + drawing, +}: { savingStateLabel: string } & Pick) { + const [titleInputValue, setTitleInputValue] = useState(drawing.title) + const [editMode, setEditMode] = useState(false) + const [titleSavingStateLabel, setTitleSavingStateLabel] = useState( + DEFAULT_SAVE_BUTTON_LABEL + ) + + const handleSaveTitle = useCallback(() => { + setTitleSavingStateLabel('Saving...') + patchDrawingTitle(links, titleInputValue) + .then(() => { + setTitleSavingStateLabel(DEFAULT_SAVE_BUTTON_LABEL) + setEditMode(false) + }) + .catch(() => setTitleSavingStateLabel('Try again')) + }, [links, titleInputValue]) + + return ( +
+
+ +
+ Exercism Bootcamp +
+
+
+ {savingStateLabel && ( + + {savingStateLabel} + + )} +
+ {editMode ? ( + <> + + + { + setTitleInputValue(e.target.value) + setTitleSavingStateLabel(DEFAULT_SAVE_BUTTON_LABEL) + }} + type="text" + style={{ all: 'unset', borderBottom: '1px solid' }} + /> + + ) : ( + <> + + {titleInputValue} + + )} +
+ + + Back to drawings + +
+
+ ) +} + +export const Header = wrapWithErrorBoundary(_Header) + +async function patchDrawingTitle( + links: DrawingPageProps['links'], + title: string +) { + const response = await fetch(links.updateCode, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title, + }), + }) + + if (!response.ok) { + throw new Error('Failed to save code') + } + + return response.json() +} diff --git a/app/javascript/components/bootcamp/DrawingPage/Scrubber/InformationWidgetToggleButton.tsx b/app/javascript/components/bootcamp/DrawingPage/Scrubber/InformationWidgetToggleButton.tsx new file mode 100644 index 0000000000..275942295a --- /dev/null +++ b/app/javascript/components/bootcamp/DrawingPage/Scrubber/InformationWidgetToggleButton.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { useCallback } from 'react' +import useEditorStore from '../../SolveExercisePage/store/editorStore' +import useTestStore from '../../SolveExercisePage/store/testStore' + +export function InformationWidgetToggleButton({ + disabled, +}: { + disabled: boolean +}) { + const { + toggleShouldShowInformationWidget, + shouldShowInformationWidget, + setHighlightedLine, + } = useEditorStore() + const { inspectedTestResult } = useTestStore() + const handleToggleShouldShowInformationWidget = useCallback(() => { + toggleShouldShowInformationWidget() + + if (!inspectedTestResult) return + + // if there is only one frame.. + if (inspectedTestResult.frames.length === 1) { + // ...and we are about to show information widget + if (!shouldShowInformationWidget) { + // highlight relevant line + setHighlightedLine(inspectedTestResult.frames[0].line) + } else { + // if toggling's next step is off, remove highlight + setHighlightedLine(0) + } + } + }, [shouldShowInformationWidget, inspectedTestResult]) + return ( + + ) +} diff --git a/app/javascript/components/bootcamp/DrawingPage/Scrubber/Scrubber.tsx b/app/javascript/components/bootcamp/DrawingPage/Scrubber/Scrubber.tsx new file mode 100644 index 0000000000..a105744f73 --- /dev/null +++ b/app/javascript/components/bootcamp/DrawingPage/Scrubber/Scrubber.tsx @@ -0,0 +1,172 @@ +import React from 'react' +import { useEffect, useState } from 'react' +import { calculateMaxInputValue, useScrubber } from './useScrubber' +import useEditorStore from '@/components/bootcamp/SolveExercisePage/store/editorStore' +import { TooltipInformation } from './ScrubberTooltipInformation' +import { InformationWidgetToggleButton } from './InformationWidgetToggleButton' +import { Icon } from '@/components/common' +import { Frame } from '@/interpreter/frames' +import type { AnimationTimeline } from '../../SolveExercisePage/AnimationTimeline/AnimationTimeline' + +function Scrubber({ + animationTimeline, + frames, +}: { + animationTimeline: AnimationTimeline | undefined + frames: Frame[] +}) { + const [isPlaying, setIsPlaying] = useState(false) + + const { hasCodeBeenEdited, setShouldShowInformationWidget } = useEditorStore() + + const { + value, + handleChange, + handleOnMouseUp, + handleOnKeyUp, + handleOnKeyDown, + handleMouseDown, + updateInputBackground, + rangeRef, + handleGoToNextFrame, + handleGoToPreviousFrame, + handleScrubToCurrentTime, + } = useScrubber({ + setIsPlaying, + animationTimeline, + frames, + }) + + useEffect(() => { + if (isPlaying || hasCodeBeenEdited) { + setShouldShowInformationWidget(false) + } + }, [isPlaying, hasCodeBeenEdited]) + + // when user switches between test results, scrub to animation timeline's persisted currentTime + useEffect(() => { + if (!animationTimeline) { + return + } + handleScrubToCurrentTime(animationTimeline) + }, [animationTimeline?.timeline.currentTime]) + + return ( +
{ + // we wanna focus the range input, so keyboard shortcuts work + rangeRef.current?.focus() + }} + tabIndex={-1} + className="relative group" + > + {animationTimeline && ( + { + animationTimeline?.play(() => setShouldShowInformationWidget(false)) + }} + /> + )} + handleOnKeyUp(event, animationTimeline)} + onKeyDown={(event) => handleOnKeyDown(event, animationTimeline)} + min={0} + ref={rangeRef} + max={calculateMaxInputValue(animationTimeline, frames)} + onInput={updateInputBackground} + value={value} + onMouseDown={(event) => + handleMouseDown(event, animationTimeline, frames) + } + onChange={(event) => { + handleChange(event, animationTimeline, frames) + updateInputBackground() + }} + onMouseUp={() => handleOnMouseUp(animationTimeline, frames)} + /> + handleGoToNextFrame(animationTimeline, frames)} + onPrev={() => handleGoToPreviousFrame(animationTimeline, frames)} + disabled={shouldScrubberBeDisabled({ + hasCodeBeenEdited, + frames, + animationTimeline, + })} + /> + + +
+ ) +} + +export default Scrubber + +function PlayButton({ + disabled, + onClick, +}: { + disabled: boolean + onClick: () => void +}) { + return ( + + ) +} + +function FrameStepperButtons({ + onNext, + onPrev, + disabled, +}: { + onNext: () => void + onPrev: () => void + disabled: boolean +}) { + return ( +
+ + +
+ ) +} + +function shouldScrubberBeDisabled({ + hasCodeBeenEdited, + frames, + animationTimeline, +}: { + hasCodeBeenEdited: boolean + frames: Frame[] + animationTimeline: AnimationTimeline | undefined +}) { + // if the code has been edited, the scrubber should be disabled + // if there is no animation timeline and there is only one frame, the scrubber should be disabled + return hasCodeBeenEdited || (!animationTimeline && frames.length === 1) +} diff --git a/app/javascript/components/bootcamp/DrawingPage/Scrubber/ScrubberTooltipInformation.tsx b/app/javascript/components/bootcamp/DrawingPage/Scrubber/ScrubberTooltipInformation.tsx new file mode 100644 index 0000000000..ff861b5918 --- /dev/null +++ b/app/javascript/components/bootcamp/DrawingPage/Scrubber/ScrubberTooltipInformation.tsx @@ -0,0 +1,29 @@ +import React from 'react' +export function TooltipInformation({ + hasCodeBeenEdited, + notEnoughFrames, +}: Record) { + // editing code removes frames anyway, so this has to be higher precedence + if (hasCodeBeenEdited) { + return ( + + ) + } + + if (notEnoughFrames) { + return ( + + ) + } +} + +function StaticTooltip({ text }: { text: string }) { + return ( +
+ {text} +
+ ) +} diff --git a/app/javascript/components/bootcamp/DrawingPage/Scrubber/useScrubber.ts b/app/javascript/components/bootcamp/DrawingPage/Scrubber/useScrubber.ts new file mode 100644 index 0000000000..6ca9ef5daf --- /dev/null +++ b/app/javascript/components/bootcamp/DrawingPage/Scrubber/useScrubber.ts @@ -0,0 +1,381 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import anime, { type AnimeInstance } from 'animejs' +import useEditorStore from '../../SolveExercisePage/store/editorStore' +import type { AnimationTimeline } from '../../SolveExercisePage/AnimationTimeline/AnimationTimeline' +import type { Frame } from '@/interpreter/frames' +import { showError } from '../../SolveExercisePage/utils/showError' +import type { StaticError } from '@/interpreter/error' +import { INFO_HIGHLIGHT_COLOR } from '../../SolveExercisePage/CodeMirror/extensions/lineHighlighter' + +export function useScrubber({ + setIsPlaying, + animationTimeline, + frames, +}: { + setIsPlaying: React.Dispatch> + animationTimeline: AnimationTimeline | undefined + frames: Frame[] +}) { + const [value, setValue] = useState(0) + const [currentFrameIdx] = useState(0) + const { + setHighlightedLine, + setHighlightedLineColor, + setInformationWidgetData, + informationWidgetData, + setShouldShowInformationWidget, + setUnderlineRange, + } = useEditorStore() + + // this effect is responsible for updating the scrubber value based on the current time of animationTimeline + useEffect(() => { + if (animationTimeline) { + animationTimeline.onUpdate((anime) => { + setValue(anime.currentTime) + animationTimeline?.currentFrame + }) + } + }, [animationTimeline]) + + // this effect is responsible for updating the highlighted line and information widget based on currentFrame + useEffect(() => { + let currentFrame: Frame | undefined + if (animationTimeline) { + currentFrame = animationTimeline.currentFrame + } else { + currentFrame = frames[value] + } + if (currentFrame) { + setHighlightedLine(currentFrame.line) + switch (currentFrame.status) { + case 'SUCCESS': { + setHighlightedLineColor(INFO_HIGHLIGHT_COLOR) + setInformationWidgetData({ + html: currentFrame.description, + line: currentFrame.line, + status: 'SUCCESS', + }) + break + } + case 'ERROR': { + const error = currentFrame.error + showError({ + error: error as StaticError, + setHighlightedLine, + setHighlightedLineColor, + setInformationWidgetData, + setShouldShowInformationWidget, + setUnderlineRange, + }) + } + } + } + }, [animationTimeline, value]) + + const handleScrubToCurrentTime = useCallback( + (animationTimeline: AnimationTimeline) => { + if (!animationTimeline) return + const value = animationTimeline.timeline.currentTime + setValue(value) + animationTimeline.seek(value) + }, + [setValue] + ) + + const handleChange = useCallback( + ( + event: + | React.ChangeEvent + | React.MouseEvent, + animationTimeline: AnimationTimeline | undefined, + frames: Frame[] + ) => { + const newValue = Number((event.target as HTMLInputElement).value) + setValue(newValue) + + if (animationTimeline) { + animationTimeline.pause() + animationTimeline.seek(newValue) + } else { + const validIndex = Math.max(0, newValue) + const highlightedLine = newValue === -1 ? 0 : frames[validIndex].line + setHighlightedLine(highlightedLine) + } + }, + [setValue, setInformationWidgetData] + ) + + const handleMouseDown = useCallback( + ( + event: + | React.ChangeEvent + | React.MouseEvent, + animationTimeline: AnimationTimeline | undefined, + frames: Frame[] + ) => { + if (informationWidgetData.line === 0) { + handleChange(event, animationTimeline, frames) + } + }, + [handleChange, informationWidgetData.line] + ) + + const scrubberValueAnimation = useRef() + + const handleOnMouseUp = useCallback( + (animationTimeline: AnimationTimeline | undefined, frames: Frame[]) => { + if (!animationTimeline) return + // find closest frame to progress + const { progress } = animationTimeline + const { duration } = animationTimeline.timeline + let closestTime = duration + let closestDifference = Math.abs(duration - progress) + + for (const frame of frames) { + const frameTime = frame.time + const difference = Math.abs(frameTime - progress) + + if (difference < closestDifference) { + closestTime = frameTime + closestDifference = difference + } + } + if (scrubberValueAnimation.current) { + anime.remove(scrubberValueAnimation.current) + } + + scrubberValueAnimation.current = anime({ + // for smooth animation, use progress (which is current `value`) as a starting point + targets: { value }, + // if progress is closer to duration than time, then snap to duration + value: closestTime, + duration: 50, + easing: 'easeOutQuad', + update: function (anim) { + const newTime = Number(anim.animations[0].currentValue) + setValue(newTime) + animationTimeline.seek(newTime) + }, + }) + }, + [setValue, value] + ) + + const handleGoToPreviousFrame = useCallback( + (animationTimeline: AnimationTimeline | undefined, frames: Frame[]) => { + if (!animationTimeline) { + // index shouldn't be under 0 or above the last frame + const validIndex = Math.min(Math.max(0, value - 1), frames.length - 1) + setValue(validIndex) + return + } + + if (scrubberValueAnimation.current) { + anime.remove(scrubberValueAnimation.current) + } + + const currentTime = animationTimeline.progress + const lastFrameTime = frames[frames.length - 1].time + + /* + + if we are at the very end of the animation timeline, + targetTime should be the start time of the last frame’s animation. + + e.g.: if the last frame’s animation starts at 5 seconds and the timeline ends at 10 seconds, + being at 10 seconds should move us to 5 seconds. + + otherwise, move to the start time of the previous frame. + + */ + + const prevFrame = animationTimeline.previousFrame + const targetTime = + currentTime === animationTimeline.timeline.duration + ? lastFrameTime + : // if there is no previous frame, go to the start of the timeline + prevFrame + ? prevFrame.time + : 0 + + scrubberValueAnimation.current = anime({ + targets: { value }, + value: targetTime, + duration: 50, + easing: 'easeOutQuad', + update: function (anim) { + const animatedTime = Number(anim.animations[0].currentValue) + setValue(animatedTime) + animationTimeline.seek(animatedTime) + }, + }) + }, + [value] + ) + + const handleGoToNextFrame = useCallback( + (animationTimeline: AnimationTimeline | undefined, frames: Frame[]) => { + if (!animationTimeline) { + // index shouldn't be under 0 or above the last frame + const validIndex = Math.min(Math.max(0, value + 1), frames.length - 1) + setValue(validIndex) + return + } + + if (scrubberValueAnimation.current) { + anime.remove(scrubberValueAnimation.current) + } + + // if there is no next frame, go to the end of the animation + const targetTime = animationTimeline.nextFrame + ? animationTimeline.nextFrame.time + : animationTimeline.timeline.duration + + scrubberValueAnimation.current = anime({ + targets: { value }, + value: targetTime, + duration: 50, + easing: 'easeOutQuad', + update: function (anim) { + const animatedTime = Number(anim.animations[0].currentValue) + setValue(animatedTime) + animationTimeline.seek(animatedTime) + }, + }) + }, + [value] + ) + + const handleGoToFirstFrame = useCallback( + (animationTimeline: AnimationTimeline | undefined) => { + if (animationTimeline) { + animationTimeline.seekFirstFrame() + } + }, + [] + ) + + const handleGoToEndOfTimeline = useCallback( + (animationTimeline: AnimationTimeline | undefined) => { + if (animationTimeline) { + animationTimeline.seekEndOfTimeline() + } + }, + [] + ) + + /* + when holding a key down, store it in a set and escape invoking frame-stepping handlers. + let user browse scrubber freely + */ + const [heldKeys, setHeldKeys] = useState(new Set()) + const handleOnKeyUp = useCallback( + ( + event: React.KeyboardEvent, + animationTimeline: AnimationTimeline | undefined + ) => { + if (!animationTimeline) return + setHeldKeys((prev) => { + const newSet = new Set(prev) + newSet.delete(event.key) + return newSet + }) + }, + [setHeldKeys] + ) + + const handleOnKeyDown = ( + event: React.KeyboardEvent, + animationTimeline: AnimationTimeline | undefined + ) => { + if (!animationTimeline) return + + setHeldKeys((prev) => new Set(prev).add(event.key)) + // if user is holding a key, don't invoke frame-stepping handlers + if (heldKeys.has(event.key)) return + + /* + preventing default is necessary to avoid jarring UI jumps: + without it, moving from 1 to 2 causes an immediate jump to 2, + then snaps back to the animation's current progress before completing the animation, easing from 1 to 2. + */ + event.preventDefault() + + switch (event.key) { + case 'ArrowLeft': + handleGoToPreviousFrame(animationTimeline, frames) + break + + case 'ArrowRight': + handleGoToNextFrame(animationTimeline, frames) + break + + case 'ArrowDown': + handleGoToFirstFrame(animationTimeline) + break + + case 'ArrowUp': + handleGoToEndOfTimeline(animationTimeline) + break + + case ' ': + if (animationTimeline.paused) { + animationTimeline.play() + setIsPlaying(true) + } else { + animationTimeline.pause() + setIsPlaying(false) + } + break + default: + return + } + } + + const rangeRef = useRef(null) + const updateInputBackground = () => { + const input = rangeRef.current + if (!input) return + + const value = parseFloat(input.value) + const min = parseFloat(input.min) + const max = parseFloat(input.max) + + const percentage = ((value - min) / (max - min)) * 100 + // 7128F5 - jiki purple + const backgroundStyle = `linear-gradient(to right, #7128F5 ${percentage}%, #fff ${percentage}%)` + input.style.background = backgroundStyle + } + + useEffect(() => { + updateInputBackground() + }, [value, animationTimeline]) + + return { + value, + handleChange, + handleMouseDown, + handleOnMouseUp, + handleOnKeyUp, + handleOnKeyDown, + handleGoToNextFrame, + handleGoToPreviousFrame, + handleGoToEndOfTimeline, + handleGoToFirstFrame, + currentFrameIdx, + rangeRef, + updateInputBackground, + handleScrubToCurrentTime, + } +} + +export function calculateMaxInputValue( + animationTimeline: AnimationTimeline | undefined, + frames: Frame[] +) { + if (animationTimeline) { + return animationTimeline.timeline.duration + } + + return frames.length - 1 +} diff --git a/app/javascript/components/bootcamp/DrawingPage/useDrawingEditorHandler.ts b/app/javascript/components/bootcamp/DrawingPage/useDrawingEditorHandler.ts new file mode 100644 index 0000000000..9c7b593226 --- /dev/null +++ b/app/javascript/components/bootcamp/DrawingPage/useDrawingEditorHandler.ts @@ -0,0 +1,114 @@ +import { useRef, useState } from 'react' +import type { EditorView } from 'codemirror' +import type { Handler } from '../SolveExercisePage/CodeMirror/CodeMirror' +import DrawExercise from '../SolveExercisePage/exercises/draw' +import { interpret } from '@/interpreter/interpreter' +import useEditorStore from '../SolveExercisePage/store/editorStore' +import { showError } from '../SolveExercisePage/utils/showError' +import { AnimationTimeline } from '../SolveExercisePage/AnimationTimeline/AnimationTimeline' +import type { Frame } from '@/interpreter/frames' + +export function useDrawingEditorHandler({ + drawing, + code, + links, +}: DrawingPageProps) { + const editorHandler = useRef(null) + const editorViewRef = useRef(null) + const viewContainerRef = useRef(null) + const [animationTimeline, setAnimationTimeline] = + useState() + const [frames, setFrames] = useState([]) + + const [latestValueSnapshot, setLatestValueSnapshot] = useState< + string | undefined + >(undefined) + + const handleEditorDidMount = (handler: Handler) => { + editorHandler.current = handler + // run code on mount + handleRunCode() + } + + const getStudentCode = () => { + if (editorViewRef.current) { + return editorViewRef.current.state.doc.toString() + } + } + + const { + setHighlightedLine, + setHighlightedLineColor, + setInformationWidgetData, + setShouldShowInformationWidget, + setUnderlineRange, + setHasCodeBeenEdited, + } = useEditorStore() + + const handleRunCode = () => { + document.querySelectorAll('.exercise-container').forEach((e) => e.remove()) + setHasCodeBeenEdited(false) + const drawExerciseInstance = new DrawExercise() + if (editorHandler.current) { + const value = editorHandler.current.getValue() + setLatestValueSnapshot(value) + // value is studentCode + const evaluated = interpret(value, { + externalFunctions: drawExerciseInstance?.availableFunctions, + language: 'JikiScript', + }) + + const { frames } = evaluated + setFrames(frames) + + const { animations } = drawExerciseInstance + const animationTimeline = + animations.length > 0 + ? new AnimationTimeline({}, frames).populateTimeline(animations) + : null + + if (animationTimeline) { + setAnimationTimeline(animationTimeline) + } + + if (evaluated.error) { + showError({ + error: evaluated.error, + setHighlightedLine, + setHighlightedLineColor, + setInformationWidgetData, + setShouldShowInformationWidget, + setUnderlineRange, + }) + } + + const view = drawExerciseInstance.getView() + + if (view) { + if (!viewContainerRef.current) return + if (viewContainerRef.current.children.length > 0) { + const oldView = viewContainerRef.current.children[0] as HTMLElement + document.body.appendChild(oldView) + oldView.style.display = 'none' + } + + // on each result change, clear out view-container + viewContainerRef.current.innerHTML = '' + viewContainerRef.current.appendChild(view) + view.style.display = 'block' + } + } + } + + return { + handleEditorDidMount, + handleRunCode, + getStudentCode, + editorHandler, + latestValueSnapshot, + editorViewRef, + viewContainerRef, + animationTimeline, + frames, + } +} diff --git a/app/javascript/components/bootcamp/DrawingPage/useSetupDrawingPage.ts b/app/javascript/components/bootcamp/DrawingPage/useSetupDrawingPage.ts new file mode 100644 index 0000000000..1ffe2d3e93 --- /dev/null +++ b/app/javascript/components/bootcamp/DrawingPage/useSetupDrawingPage.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react' +import useEditorStore from '../SolveExercisePage/store/editorStore' + +export function useSetupDrawingPage({ + editorLocalStorageValue, + setEditorLocalStorageValue, + code, +}) { + const { setDefaultCode, setShouldAutoRunCode } = useEditorStore() + + // Setup hook + useEffect(() => { + if ( + editorLocalStorageValue.storedAt && + code.storedAt && + // if the code on the server is newer than in localstorage, update the storage and load the code from the server + editorLocalStorageValue.storedAt < code.storedAt + ) { + setEditorLocalStorageValue({ code: code.code, storedAt: code.storedAt }) + setDefaultCode(code.code) + } else { + // otherwise we are using the code from the storage + setDefaultCode(editorLocalStorageValue.code) + } + setShouldAutoRunCode(true) + }, [code, setDefaultCode, setEditorLocalStorageValue]) +} diff --git a/app/javascript/components/bootcamp/SolveExercisePage/CodeMirror/CodeMirror.tsx b/app/javascript/components/bootcamp/SolveExercisePage/CodeMirror/CodeMirror.tsx index e80502929b..c92fe01a9c 100644 --- a/app/javascript/components/bootcamp/SolveExercisePage/CodeMirror/CodeMirror.tsx +++ b/app/javascript/components/bootcamp/SolveExercisePage/CodeMirror/CodeMirror.tsx @@ -33,8 +33,6 @@ import useEditorStore from '../store/editorStore' import * as Ext from './extensions' import * as Hook from './hooks' import { INFO_HIGHLIGHT_COLOR } from './extensions/lineHighlighter' -import { useLocalStorage } from '@uidotdev/usehooks' -import { SolveExercisePageContext } from '../SolveExercisePageContextWrapper' import { debounce } from 'lodash' import { jikiscript } from '@exercism/codemirror-lang-jikiscript' @@ -69,10 +67,17 @@ export const CodeMirror = forwardRef(function _CodeMirror( editorDidMount, handleRunCode, style, + setEditorLocalStorageValue, + onEditorChangeCallback, }: { editorDidMount: (handler: Handler) => void handleRunCode: () => void style?: React.CSSProperties + onEditorChangeCallback?: () => void + setEditorLocalStorageValue: (value: { + code: string + storedAt: string + }) => void }, ref: ForwardedRef ) { @@ -93,11 +98,6 @@ export const CodeMirror = forwardRef(function _CodeMirror( } = useEditorStore() const [textarea, setTextarea] = useState(null) - const { code, exercise } = useContext(SolveExercisePageContext) - const [_, setEditorLocalStorageValue] = useLocalStorage( - 'bootcamp-editor-value-' + exercise.config.title, - { code: code.code, storedAt: code.storedAt } - ) const updateLocalStorageValueOnDebounce = useMemo(() => { return debounce( @@ -201,6 +201,14 @@ export const CodeMirror = forwardRef(function _CodeMirror( if (shouldAutoRunCode) { handleRunCode() } + }, + () => { + console.log('editor change callback') + if (onEditorChangeCallback) { + onEditorChangeCallback() + } else { + console.log('no editor callback') + } } ), Ext.cursorTooltip(), diff --git a/app/javascript/components/bootcamp/SolveExercisePage/Scrubber/InformationWidgetTiggleButton.tsx b/app/javascript/components/bootcamp/SolveExercisePage/Scrubber/InformationWidgetToggleButton.tsx similarity index 100% rename from app/javascript/components/bootcamp/SolveExercisePage/Scrubber/InformationWidgetTiggleButton.tsx rename to app/javascript/components/bootcamp/SolveExercisePage/Scrubber/InformationWidgetToggleButton.tsx diff --git a/app/javascript/components/bootcamp/SolveExercisePage/Scrubber/Scrubber.tsx b/app/javascript/components/bootcamp/SolveExercisePage/Scrubber/Scrubber.tsx index 291499e527..5446b4fb73 100644 --- a/app/javascript/components/bootcamp/SolveExercisePage/Scrubber/Scrubber.tsx +++ b/app/javascript/components/bootcamp/SolveExercisePage/Scrubber/Scrubber.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { calculateMaxInputValue, useScrubber } from './useScrubber' import useEditorStore from '@/components/bootcamp/SolveExercisePage/store/editorStore' import { TooltipInformation } from './ScrubberTooltipInformation' -import { InformationWidgetToggleButton } from './InformationWidgetTiggleButton' +import { InformationWidgetToggleButton } from './InformationWidgetToggleButton' import { Icon } from '@/components/common' function Scrubber({ testResult }: { testResult: NewTestResult }) { diff --git a/app/javascript/components/bootcamp/SolveExercisePage/SolveExercisePage.tsx b/app/javascript/components/bootcamp/SolveExercisePage/SolveExercisePage.tsx index 5a3c0528c9..fa1e02f881 100644 --- a/app/javascript/components/bootcamp/SolveExercisePage/SolveExercisePage.tsx +++ b/app/javascript/components/bootcamp/SolveExercisePage/SolveExercisePage.tsx @@ -6,12 +6,13 @@ import { Instructions } from './Instructions/Instructions' import { useSetupStores } from './hooks/useSetupStores' import { ControlButtons } from './ControlButtons/ControlButtons' import { CodeMirror } from './CodeMirror/CodeMirror' -import { ErrorBoundary } from '@/components/ErrorBoundary' +import ErrorBoundary from '../common/ErrorBoundary/ErrorBoundary' import { Resizer, useResizablePanels } from './hooks/useResize' import { TaskPreview } from './TaskPreview/TaskPreview' import SolveExercisePageContextWrapper from './SolveExercisePageContextWrapper' import { PreviousTestResultView } from './PreviousTestResultsView/PreviousTestResultsView' import { Header } from './Header/Header' +import { useLocalStorage } from '@uidotdev/usehooks' export default function SolveExercisePage({ exercise, @@ -53,6 +54,11 @@ export default function SolveExercisePage({ localStorageId: 'solve-exercise-page-editor-height', }) + const [_, setEditorLocalStorageValue] = useLocalStorage( + 'bootcamp-editor-value-' + exercise.config.title, + { code: code.code, storedAt: code.storedAt } + ) + return ( diff --git a/app/javascript/components/bootcamp/SolveExercisePage/exercises/draw/DrawExercise.tsx b/app/javascript/components/bootcamp/SolveExercisePage/exercises/draw/DrawExercise.tsx index 84986d6f13..519ff619a9 100644 --- a/app/javascript/components/bootcamp/SolveExercisePage/exercises/draw/DrawExercise.tsx +++ b/app/javascript/components/bootcamp/SolveExercisePage/exercises/draw/DrawExercise.tsx @@ -1,6 +1,5 @@ -import React from 'react' import { Exercise } from '../Exercise' -import { rToA } from './utils' +import { aToR, rToA } from './utils' import * as Shapes from './shapes' import type { ExecutionContext } from '@/interpreter/executor' @@ -69,6 +68,7 @@ export default class DrawExercise extends Exercise { Object.assign(this.view.style, { display: 'none', + position: 'relative', }) const grid = document.createElement('div') @@ -77,7 +77,66 @@ export default class DrawExercise extends Exercise { this.canvas = document.createElement('div') this.canvas.classList.add('canvas') + this.canvas.style.position = 'relative' this.view.appendChild(this.canvas) + + this.tooltip = document.createElement('div') + this.tooltip.classList.add('tooltip') + Object.assign(this.tooltip.style, { + whiteSpace: 'nowrap', + position: 'absolute', + background: '#333', + color: '#fff', + padding: '4px', + borderRadius: '4px', + fontSize: '12px', + pointerEvents: 'none', + display: 'none', + }) + this.view.appendChild(this.tooltip) + + this.canvas.addEventListener('mousemove', this.showTooltip.bind(this)) + this.canvas.addEventListener('mouseleave', this.hideTooltip.bind(this)) + } + + showTooltip(event: MouseEvent) { + const rect = this.canvas.getBoundingClientRect() + const canvasWidth = rect.width + const canvasHeight = rect.height + + const absX = event.clientX - rect.left + const absY = event.clientY - rect.top + + const relX = Math.round(aToR(absX, canvasWidth)) + const relY = Math.round(aToR(absY, canvasHeight)) + + let tooltipX = absX + 10 + let tooltipY = absY + 10 + + // providing these as constant values saves us from recalculating them every time + // update these values if the tooltip style changes + // measure max tooltip width/height with the fn below + // console.log(this.tooltip.getBoundingClientRect().width, this.tooltip.getBoundingClientRect().height) + const maxTooltipWidth = 75 + const maxTooltipHeight = 32 + // handle tooltip overflow-x + if (tooltipX + maxTooltipWidth + 5 > canvasWidth) { + tooltipX = absX - maxTooltipWidth - 10 + } + + // handle tooltip overflow-y + if (tooltipY + maxTooltipHeight + 5 > canvasHeight) { + tooltipY = absY - maxTooltipHeight - 10 + } + + this.tooltip.textContent = `X: ${relX}, Y: ${relY}` + this.tooltip.style.left = `${tooltipX}px` + this.tooltip.style.top = `${tooltipY}px` + this.tooltip.style.display = 'block' + } + + hideTooltip() { + this.tooltip.style.display = 'none' } public getState() { diff --git a/app/javascript/components/bootcamp/SolveExercisePage/exercises/draw/utils.ts b/app/javascript/components/bootcamp/SolveExercisePage/exercises/draw/utils.ts index 335ae6443a..d2848acf3b 100644 --- a/app/javascript/components/bootcamp/SolveExercisePage/exercises/draw/utils.ts +++ b/app/javascript/components/bootcamp/SolveExercisePage/exercises/draw/utils.ts @@ -2,7 +2,6 @@ * Relative constant number the absolute number maps to */ export const RELATIVE_SIZE = 100 - /** * * Convert relative x or y value to absolute value @@ -10,112 +9,10 @@ export const RELATIVE_SIZE = 100 export function rToA(n: number) { return (n / RELATIVE_SIZE) * 100 } - /** * * Convert absolute x or y value to absolute value */ -export function aToR(n: number) { - console.group('HERE') - //return Math.round(n / (CANVAS_SIZE / RELATIVE_SIZE)); -} -/** - * - */ -export function relativeToAbsolute(x: number, y: number) { - return { x: rToA(x), y: rToA(y) } -} -/** - * - */ -export function absoluteToRelative(x: number, y: number) { - return { x: aToR(x), y: aToR(y) } -} - -// export function showMouseCoord(p: p5) { -// const mouseX = p.mouseX; -// const mouseY = p.mouseY; - -// let textX = mouseX + 10; -// let textY = mouseY + 10; - -// const textWidth = p.textWidth(`(${mouseX}, ${mouseY})`); -// const textHeight = 16; - -// // in case of overflow -// if (textX + textWidth > p.width) { -// textX = mouseX - textWidth - 10; -// } - -// if (textY + textHeight > p.height) { -// textY = mouseY - textHeight - 10; -// } - -// if (textX < 0) { -// textX = 10; -// } - -// if (textY < 0) { -// textY = 10; -// } - -// const { x, y } = absoluteToRelative(mouseX, mouseY); -// p.text(`(${x}, ${y})`, textX, textY); - -// p.stroke(0, 0, 0, 50); -// // xline -// p.line(mouseX, 0, mouseX, p.height); -// // yline -// p.line(0, mouseY, p.width, mouseY); -// } - -// export function isMouseOverTheCanvas(p: p5) { -// return p.mouseX >= 0 && p.mouseX <= p.width && p.mouseY >= 0 && p.mouseY <= p.height; -// } - -/** - * Converts the code string to a array of dictionary of function names and their arguments - */ -function parseCode(code: string): Array<{ [key: string]: number[] }> { - const result: Array<{ [key: string]: number[] }> = [] - - const lines = code.trim().split('\n') - - const regex = /(\w+)\(([\d\s,]+)\)/ - - for (const line of lines) { - const match = line.match(regex) - if (match) { - const functionName = match[1] - const args = match[2].split(',').map((arg) => parseFloat(arg.trim())) - const obj: { [key: string]: number[] } = {} - obj[functionName] = args - result.push(obj) - } - } - - return result -} - -/** - * Calls p5 function with arguments - */ -// export function drawThings(code: string, p: p5) { -// const parsedCode = parseCode(code); -// parsedCode.forEach((line) => { -// const functionName = Object.keys(line)[0]; -// const args = line[functionName]; -// const digestedArgs = mapRelativeArgsToAbsoluteArgs(functionName, args); -// // @ts-ignore -// p[functionName](...digestedArgs); -// }); -// } - -function mapRelativeArgsToAbsoluteArgs(functionName: string, args: number[]) { - switch (functionName) { - case 'rect': - return args.map(rToA) - default: - return args - } +export function aToR(n: number, canvasSize: number) { + return (n / canvasSize) * RELATIVE_SIZE } diff --git a/app/javascript/components/bootcamp/types/DrawingPage.d.ts b/app/javascript/components/bootcamp/types/DrawingPage.d.ts new file mode 100644 index 0000000000..32a2cd7d08 --- /dev/null +++ b/app/javascript/components/bootcamp/types/DrawingPage.d.ts @@ -0,0 +1,14 @@ +declare type DrawingPageProps = { + drawing: { + uuid: string + title: string + } + code: { + code: string + storedAt: string + } + links: { + updateCode: string + drawingsIndex: string + } +} diff --git a/app/javascript/packs/bootcamp-ui-js.tsx b/app/javascript/packs/bootcamp-ui-js.tsx index d429c11c0e..2f95d1345a 100644 --- a/app/javascript/packs/bootcamp-ui-js.tsx +++ b/app/javascript/packs/bootcamp-ui-js.tsx @@ -7,6 +7,9 @@ import { camelizeKeysAs } from '@/utils/camelize-keys-as' const SolveExercisePage = lazy( () => import('../components/bootcamp/SolveExercisePage/SolveExercisePage') ) +const DrawingPage = lazy( + () => import('../components/bootcamp/DrawingPage/DrawingPage') +) declare global { interface Window { @@ -26,6 +29,12 @@ const mappings = { (data)} /> ), + + 'bootcamp-drawing-page': (data: DrawingPageProps): JSX.Element => ( + + (data)} /> + + ), } // Add all react components here. diff --git a/app/models/bootcamp/drawing.rb b/app/models/bootcamp/drawing.rb index d401b27e3f..296d4c5aec 100644 --- a/app/models/bootcamp/drawing.rb +++ b/app/models/bootcamp/drawing.rb @@ -4,6 +4,7 @@ class Bootcamp::Drawing < ApplicationRecord before_create do self.uuid = SecureRandom.uuid unless uuid.present? self.code = "" unless code.present? + self.title = "Drawing #{user.bootcamp_drawings.count + 1}" unless title.present? end def to_param = uuid diff --git a/db/migrate/20250109143039_add_title_to_drawings.rb b/db/migrate/20250109143039_add_title_to_drawings.rb new file mode 100644 index 0000000000..66b533c82f --- /dev/null +++ b/db/migrate/20250109143039_add_title_to_drawings.rb @@ -0,0 +1,13 @@ +class AddTitleToDrawings < ActiveRecord::Migration[7.0] + def change + return if Rails.env.production? + + add_column :bootcamp_drawings, :title, :string, null: true + + Bootcamp::Drawing.all.each do |drawing| + drawing.update!(title: "Drawing #{drawing.id}") + end + + change_column_null :bootcamp_drawings, :title, false + end +end diff --git a/db/schema.rb b/db/schema.rb index bf95f788df..47fadbae7b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2025_01_08_193404) do +ActiveRecord::Schema[7.0].define(version: 2025_01_09_143039) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -90,6 +90,7 @@ t.text "code", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "title", null: false t.index ["user_id"], name: "index_bootcamp_drawings_on_user_id" end diff --git a/test/controllers/api/bootcamp/drawings_controller_test.rb b/test/controllers/api/bootcamp/drawings_controller_test.rb new file mode 100644 index 0000000000..ef03928bdb --- /dev/null +++ b/test/controllers/api/bootcamp/drawings_controller_test.rb @@ -0,0 +1,35 @@ +require_relative '../base_test_case' + +class API::Bootcamp::DrawingsControllerTest < API::BaseTestCase + test "update: updates and 200s" do + user = create :user + drawing = create(:bootcamp_drawing, user:) + code = "Something" + title = "New title" + + setup_user(user) + patch api_bootcamp_drawing_url(drawing, code:, title:), headers: @headers + + assert_response :ok + assert_json_response({}) + + assert code, drawing.reload.code + assert title, drawing.reload.title + end + + test "update: missing title doesn't touch code" do + user = create :user + code = "Something" + title = "Some title" + drawing = create(:bootcamp_drawing, user:, code:, title:) + + setup_user(user) + patch api_bootcamp_drawing_url(drawing, title: ''), headers: @headers + + assert_response :ok + assert_json_response({}) + + assert code, drawing.reload.code + assert title, drawing.reload.title + end +end