Skip to content

Commit

Permalink
Add free-drawing page (#7266)
Browse files Browse the repository at this point in the history
* Add basic setup

* Add DrawingPage, Add editorHandler, Move localStorage setter outside of codemirror

* Add tons of stuff

* Add scrubber copy

* Change types of stuff

* Run code on mount too

* Remove logging

* Add potential ability to save code, add header

* Add endpoint

* Add titles

* Add test

* Save code on debounce

* Update header

* Remove log

* Save title

* Break out setup hook

* Add canvas coordinates tooltip (#7270)

* Add canvas coordinates tooltip

* Don't recalculate tooltip size on every mouse move

* Add edit mode to header (#7271)

* Add edit mode to header

* Remove practically useless code

* Change back button label on save

* Move back buttons

* More label work

* Update app/css/bootcamp/components/site-header.css

---------

Co-authored-by: Jeremy Walker <[email protected]>
  • Loading branch information
dem4ron and iHiD authored Jan 9, 2025
1 parent cdc4dd2 commit 375f5b4
Show file tree
Hide file tree
Showing 23 changed files with 1,173 additions and 121 deletions.
15 changes: 15 additions & 0 deletions app/controllers/api/bootcamp/drawings_controller.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion app/css/bootcamp/components/site-header.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 4 additions & 3 deletions app/helpers/react_components/bootcamp/drawing_page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@ 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,
stored_at: drawing.updated_at
},
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
Expand Down
114 changes: 114 additions & 0 deletions app/javascript/components/bootcamp/DrawingPage/DrawingPage.tsx
Original file line number Diff line number Diff line change
@@ -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<string>('')

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 (
<div id="bootcamp-solve-exercise-page">
<Header
links={links}
savingStateLabel={savingStateLabel}
drawing={drawing}
/>
<div className="page-body">
<div style={{ width: LHSWidth }} className="page-body-lhs">
<ErrorBoundary>
<CodeMirror
style={{ height: `100%` }}
ref={editorViewRef}
editorDidMount={handleEditorDidMount}
handleRunCode={handleRunCode}
setEditorLocalStorageValue={setEditorLocalStorageValue}
onEditorChangeCallback={patchCodeOnDebounce}
/>
<Scrubber animationTimeline={animationTimeline} frames={frames} />
</ErrorBoundary>
</div>
<Resizer direction="vertical" handleMouseDown={handleMouseDown} />
{/* RHS */}
<div className="page-body-rhs" style={{ width: RHSWidth }}>
<div ref={viewContainerRef} id="view-container" />
</div>
</div>
</div>
)
}

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()
}
109 changes: 109 additions & 0 deletions app/javascript/components/bootcamp/DrawingPage/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -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<DrawingPageProps, 'links' | 'drawing'>) {
const [titleInputValue, setTitleInputValue] = useState(drawing.title)
const [editMode, setEditMode] = useState(false)
const [titleSavingStateLabel, setTitleSavingStateLabel] = useState<string>(
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 (
<div className="page-header">
<div className="ident">
<GraphicalIcon icon="logo" category="bootcamp" />
<div>
<strong className="font-semibold">Exercism</strong> Bootcamp
</div>
</div>
<div className="ml-auto flex items-center gap-12">
{savingStateLabel && (
<span className="text-xs text-gray-500 font-semibold mr-4">
{savingStateLabel}
</span>
)}
<div className="flex items-center gap-12">
{editMode ? (
<>
<button onClick={handleSaveTitle} className="btn-primary btn-xxs">
{titleSavingStateLabel}
</button>
<button
className="btn-secondary btn-xxs"
onClick={() => setEditMode(false)}
>
Cancel
</button>
<input
value={titleInputValue}
onChange={(e) => {
setTitleInputValue(e.target.value)
setTitleSavingStateLabel(DEFAULT_SAVE_BUTTON_LABEL)
}}
type="text"
style={{ all: 'unset', borderBottom: '1px solid' }}
/>
</>
) : (
<>
<button onClick={() => setEditMode(true)}>
<GraphicalIcon icon="edit" height={15} width={15} />
</button>
<span>{titleInputValue}</span>
</>
)}
</div>

<a
href={links.drawingsIndex}
className={assembleClassNames('btn-secondary btn-xxs')}
>
Back to drawings
</a>
</div>
</div>
)
}

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()
}
Original file line number Diff line number Diff line change
@@ -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 (
<label className="switch">
<input
disabled={disabled}
type="checkbox"
onChange={handleToggleShouldShowInformationWidget}
checked={shouldShowInformationWidget}
/>
<span className="slider round"></span>
</label>
)
}
Loading

0 comments on commit 375f5b4

Please sign in to comment.