From 730f34c4277e49b5084449f9ed34ce15d850327a Mon Sep 17 00:00:00 2001 From: saa938 Date: Tue, 21 Jul 2026 11:25:06 -0700 Subject: [PATCH 1/5] Resize SVG stimulus images proportionally, matching PNG/JPG SVGs typically only declare a viewBox (no width/height), so has no intrinsic size to fall back on and previously got forced to a fixed w-full max-w-[450px] box regardless of their actual proportions. Now the SVG's own viewBox/width/height is parsed to derive its aspect ratio, so it scales proportionally within the same bounding box PNG/JPG already use, falling back to the old fixed-width behavior only when no intrinsic size can be determined. Fixes #321 Co-Authored-By: Claude Sonnet 5 --- .../RenderAdvancedTextbox.tsx | 42 ++++++++++++++++--- src/lib/utils.ts | 37 ++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx index 473debc3..73a7c5c3 100644 --- a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx @@ -4,7 +4,7 @@ import type { QuestionFile, QuestionInput } from "@/types/questions"; import "../../../styles/katexStyling.css"; import { decodeEntities, katexMacros } from "../Renderer"; -import { cn, isSvgFileName } from "@/lib/utils"; +import { cn, isSvgFileName, parseSvgIntrinsicSize } from "@/lib/utils"; interface Props { content: QuestionInput; @@ -75,6 +75,10 @@ export function getFileFromIndexedDB( const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { const [objectUrl, setObjectUrl] = useState(null); + const [svgSize, setSvgSize] = useState<{ + width: number; + height: number; + } | null>(null); useEffect(() => { let url: string | null = null; @@ -104,13 +108,34 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { }; }, [file]); + const isSvg = isSvgFileName(file.name) || file.key.startsWith("image-svg"); + + // SVGs typically only declare a viewBox (no width/height), so has no + // intrinsic size to fall back on and can collapse to the browser's 300x150 + // replaced-element default. Fetch the raw markup and derive the aspect + // ratio so SVGs scale the same way PNG/JPG do instead of a fixed width. + useEffect(() => { + setSvgSize(null); + if (!objectUrl || !isSvg) return; + + let cancelled = false; + fetch(objectUrl) + .then((res) => res.text()) + .then((markup) => { + if (!cancelled) setSvgSize(parseSvgIntrinsicSize(markup)); + }) + .catch((error) => { + console.error("Error reading SVG dimensions:", error); + }); + + return () => { + cancelled = true; + }; + }, [objectUrl, isSvg]); + if (!objectUrl) return null; if (isImageFileKey(file.key)) { - // SVGs frequently omit intrinsic dimensions (viewBox only), which makes them - // collapse or overflow under width/height auto. Give them a definite, - // responsive width instead. - const isSvg = isSvgFileName(file.name) || file.key.startsWith("image-svg"); return (
= ({ file }) => { loading="lazy" className={cn( "rounded-md object-contain shadow-sm transition-shadow duration-200 hover:shadow-md", - isSvg + isSvg && !svgSize ? "h-auto w-full max-w-[450px]" : "h-auto max-h-[450px] w-auto max-w-full", )} + style={ + isSvg && svgSize + ? { aspectRatio: `${svgSize.width} / ${svgSize.height}` } + : undefined + } />
); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 67c99b57..80b11270 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -28,3 +28,40 @@ export function isSvgFileName(name?: string | null): boolean { export function resolveUploadContentType(file: File): string | undefined { return file.type || (isSvgFileName(file.name) ? "image/svg+xml" : undefined); } + +/** + * Extracts an SVG's intrinsic aspect ratio from its markup. Most SVGs only + * declare a `viewBox` (no `width`/`height`), so an `` referencing one + * has no natural size to fall back on and can collapse to the browser's + * 300x150 replaced-element default. Reading the ratio here lets the caller + * size the SVG the same way it sizes PNG/JPG (proportional, capped by CSS) + * instead of forcing every SVG to an identical fixed width. + */ +export function parseSvgIntrinsicSize( + svgMarkup: string, +): { width: number; height: number } | null { + const viewBoxMatch = /viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i.exec( + svgMarkup, + ); + if (viewBoxMatch?.[1] && viewBoxMatch[2]) { + const width = parseFloat(viewBoxMatch[1]); + const height = parseFloat(viewBoxMatch[2]); + if (width > 0 && height > 0) return { width, height }; + } + + // Fall back to explicit width/height attributes (ignoring % values, which + // give no absolute ratio) if there's no usable viewBox. + const widthMatch = /[^-\w]width\s*=\s*["']\s*([\d.]+)(?:px)?\s*["']/i.exec( + svgMarkup, + ); + const heightMatch = /[^-\w]height\s*=\s*["']\s*([\d.]+)(?:px)?\s*["']/i.exec( + svgMarkup, + ); + if (widthMatch?.[1] && heightMatch?.[1]) { + const width = parseFloat(widthMatch[1]); + const height = parseFloat(heightMatch[1]); + if (width > 0 && height > 0) return { width, height }; + } + + return null; +} From 247c1a9f9349d2e23ce83ed374875e412ad6d5e6 Mon Sep 17 00:00:00 2001 From: saa938 Date: Tue, 21 Jul 2026 11:44:31 -0700 Subject: [PATCH 2/5] Apply prettier formatting Co-Authored-By: Claude Sonnet 5 --- src/lib/utils.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 80b11270..8acd16da 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -40,9 +40,10 @@ export function resolveUploadContentType(file: File): string | undefined { export function parseSvgIntrinsicSize( svgMarkup: string, ): { width: number; height: number } | null { - const viewBoxMatch = /viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i.exec( - svgMarkup, - ); + const viewBoxMatch = + /viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i.exec( + svgMarkup, + ); if (viewBoxMatch?.[1] && viewBoxMatch[2]) { const width = parseFloat(viewBoxMatch[1]); const height = parseFloat(viewBoxMatch[2]); From 5d7802ce75b5041d1ae12a6fc357ceb1044f730b Mon Sep 17 00:00:00 2001 From: Ashay <83087832+saa938@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:40:00 -0700 Subject: [PATCH 3/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../RenderAdvancedTextbox.tsx | 16 +++++++++---- src/lib/utils.ts | 23 +++++++++++-------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx index 73a7c5c3..b5862257 100644 --- a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx @@ -118,18 +118,24 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { setSvgSize(null); if (!objectUrl || !isSvg) return; - let cancelled = false; - fetch(objectUrl) - .then((res) => res.text()) + const controller = new AbortController(); + + fetch(objectUrl, { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.text(); + }) .then((markup) => { - if (!cancelled) setSvgSize(parseSvgIntrinsicSize(markup)); + if (!controller.signal.aborted) + setSvgSize(parseSvgIntrinsicSize(markup)); }) .catch((error) => { + if (controller.signal.aborted) return; console.error("Error reading SVG dimensions:", error); }); return () => { - cancelled = true; + controller.abort(); }; }, [objectUrl, isSvg]); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8acd16da..aa3fd98e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -40,10 +40,11 @@ export function resolveUploadContentType(file: File): string | undefined { export function parseSvgIntrinsicSize( svgMarkup: string, ): { width: number; height: number } | null { - const viewBoxMatch = - /viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i.exec( - svgMarkup, - ); + const num = String.raw`[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?`; + const viewBoxMatch = new RegExp( + String.raw`viewBox\s*=\s*["']\s*${num}(?:[,\s]+)${num}(?:[,\s]+)(${num})(?:[,\s]+)(${num})\s*["']`, + "i", + ).exec(svgMarkup); if (viewBoxMatch?.[1] && viewBoxMatch[2]) { const width = parseFloat(viewBoxMatch[1]); const height = parseFloat(viewBoxMatch[2]); @@ -52,12 +53,14 @@ export function parseSvgIntrinsicSize( // Fall back to explicit width/height attributes (ignoring % values, which // give no absolute ratio) if there's no usable viewBox. - const widthMatch = /[^-\w]width\s*=\s*["']\s*([\d.]+)(?:px)?\s*["']/i.exec( - svgMarkup, - ); - const heightMatch = /[^-\w]height\s*=\s*["']\s*([\d.]+)(?:px)?\s*["']/i.exec( - svgMarkup, - ); + const widthMatch = + /(?:^|[^\w-])width\s*=\s*["']\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)(?:\s*(?:px|pt|pc|mm|cm|in))?\s*["']/i.exec( + svgMarkup, + ); + const heightMatch = + /(?:^|[^\w-])height\s*=\s*["']\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)(?:\s*(?:px|pt|pc|mm|cm|in))?\s*["']/i.exec( + svgMarkup, + ); if (widthMatch?.[1] && heightMatch?.[1]) { const width = parseFloat(widthMatch[1]); const height = parseFloat(heightMatch[1]); From 7380b1176b6e29ecb7b00cdac04bd5e3085b07b1 Mon Sep 17 00:00:00 2001 From: TechnoSamurai02 Date: Sun, 26 Jul 2026 13:20:15 -0400 Subject: [PATCH 4/5] FRQ editor interface and navigation --- src/components/frq/editorFooter.tsx | 168 +++++++ src/components/frq/editorRenderer.tsx | 672 +++++++++++++++++++++++++- 2 files changed, 832 insertions(+), 8 deletions(-) create mode 100644 src/components/frq/editorFooter.tsx diff --git a/src/components/frq/editorFooter.tsx b/src/components/frq/editorFooter.tsx new file mode 100644 index 00000000..d85c4a00 --- /dev/null +++ b/src/components/frq/editorFooter.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ChevronUp, MapPin, Plus, Trash2 } from "lucide-react"; + +type BatchVisibility = "public" | "private"; + +interface FRQNavigationItem { + id: string; + title: string; +} + +interface FRQEditorFooterProps { + frqs: FRQNavigationItem[]; + currentFrqIndex: number; + batchName: string; + batchVisibility: BatchVisibility; + onBatchNameChange: (name: string) => void; + onBatchVisibilityChange: (visibility: BatchVisibility) => void; + onSelectFrq: (index: number) => void; + onPrevious: () => void; + onNext: () => void; +} + +const FRQEditorFooter = ({ + frqs, + currentFrqIndex, + batchName, + batchVisibility, + onBatchNameChange, + onBatchVisibilityChange, + onSelectFrq, + onPrevious, + onNext, +}: FRQEditorFooterProps) => { + return ( +
+
+ onBatchNameChange(event.target.value)} + className="h-9 max-w-48 font-medium" + /> + + + + + + + + { + if (value === "public" || value === "private") { + onBatchVisibilityChange(value); + } + }} + > + + Public + + + + Private + + + + +
+ +
+ + + + + FRQ {currentFrqIndex + 1} of {frqs.length} + + + + +

Navigate to an FRQ

+ +

+ Select an FRQ from this batch. +

+ +
+ {frqs.map((frq, index) => { + const isCurrent = index === currentFrqIndex; + + return ( + + ); + })} +
+
+
+ + +
+ +
+ + + +
+
+ ); +}; + +export default FRQEditorFooter; \ No newline at end of file diff --git a/src/components/frq/editorRenderer.tsx b/src/components/frq/editorRenderer.tsx index 0b93bd48..10d29763 100644 --- a/src/components/frq/editorRenderer.tsx +++ b/src/components/frq/editorRenderer.tsx @@ -1,17 +1,673 @@ +"use client"; + +import AdvancedTextbox from "@/components/article-creator/custom_questions/AdvancedTextbox"; +import FRQEditorFooter from "@/components/frq/editorFooter"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { QuestionFormat, QuestionInput } from "@/types/questions"; +import { Clock3, Eye, Info, Plus, Save, Trash2 } from "lucide-react"; +import { useState } from "react"; + +type QuestionStatus = "public" | "legacy"; +type InputType = "text" | "equation"; +type BatchVisibility = "public" | "private"; + +interface GradingCriterion { + id: string; + description: string; + points: number; +} + +interface EditorQuestion { + id: string; + questionData: QuestionFormat; + points: number; + status: QuestionStatus; + inputType: InputType; + criteria: GradingCriterion[]; +} + +interface EditorFRQ { + id: string; + title: string; + description: QuestionInput; + questions: EditorQuestion[]; +} + interface FRQEditorRendererProps { frqFound: boolean; } -const FRQEditorRenderer = ({ - frqFound, -}: FRQEditorRendererProps) => { +const createQuestionInput = (value = ""): QuestionInput => ({ + value, + files: [], +}); + +const createDescriptionQuestion = ( + description: QuestionInput, +): QuestionFormat => ({ + question: { + value: description.value, + files: [...description.files], + }, + type: "frq", + options: [], + answers: [], + explanation: createQuestionInput(), + content: createQuestionInput(), + topic: "", +}); + +const createEditorQuestion = ( + id: string, + prompt: string, + points: number, +): EditorQuestion => ({ + id, + points, + status: "public", + inputType: "text", + criteria: [ + { + id: `${id}-criterion-1`, + description: "", + points, + }, + ], + questionData: { + question: createQuestionInput(prompt), + type: "frq", + options: [], + answers: [], + explanation: createQuestionInput(), + content: createQuestionInput(), + topic: "", + }, +}); + +const getSubquestionLabel = (frqIndex: number, questionIndex: number) => + `${frqIndex + 1}${String.fromCharCode(97 + questionIndex)}`; + +const mockFRQs: EditorFRQ[] = [ + { + id: "frq-1", + title: "FRQ 1", + description: createQuestionInput( + "Read the source material carefully. Then answer all parts of the question using evidence from the source.", + ), + questions: [ + createEditorQuestion( + "frq-1-a", + "Identify one claim made in the source.", + 1, + ), + createEditorQuestion( + "frq-1-b", + "Explain how evidence from the source supports that claim.", + 2, + ), + createEditorQuestion( + "frq-1-c", + "Evaluate one limitation of the source's argument.", + 2, + ), + ], + }, + { + id: "frq-2", + title: "FRQ 2", + description: createQuestionInput( + "Examine the provided information and respond to each part of the question.", + ), + questions: [ + createEditorQuestion( + "frq-2-a", + "Describe the main process shown in the provided material.", + 1, + ), + createEditorQuestion( + "frq-2-b", + "Explain one relationship between two parts of the process.", + 2, + ), + createEditorQuestion( + "frq-2-c", + "Use evidence from the material to justify your response.", + 2, + ), + ], + }, + { + id: "frq-3", + title: "FRQ 3", + description: createQuestionInput( + "Use the information provided to develop and support a defensible response.", + ), + questions: [ + createEditorQuestion( + "frq-3-a", + "State a defensible conclusion based on the information provided.", + 1, + ), + createEditorQuestion( + "frq-3-b", + "Support your conclusion with two relevant pieces of evidence.", + 2, + ), + createEditorQuestion( + "frq-3-c", + "Explain how one piece of evidence could be interpreted differently.", + 2, + ), + ], + }, +]; + +const FRQEditorRenderer = ({ frqFound }: FRQEditorRendererProps) => { + const [frqs, setFrqs] = useState(mockFRQs); + const [currentFrqIndex, setCurrentFrqIndex] = useState(0); + const [batchName, setBatchName] = useState("FRQ Batch"); + const [batchVisibility, setBatchVisibility] = + useState("public"); + const [timeLimitMinutes, setTimeLimitMinutes] = useState(90); + + const goToPreviousFrq = () => { + setCurrentFrqIndex((index) => Math.max(index - 1, 0)); + }; + + const goToNextFrq = () => { + setCurrentFrqIndex((index) => Math.min(index + 1, frqs.length - 1)); + }; + + const updateCurrentFrq = (updater: (frq: EditorFRQ) => EditorFRQ) => { + setFrqs((currentFrqs) => + currentFrqs.map((frq, index) => + index === currentFrqIndex ? updater(frq) : frq, + ), + ); + }; + + const updateQuestion = ( + questionId: string, + updater: (question: EditorQuestion) => EditorQuestion, + ) => { + updateCurrentFrq((frq) => ({ + ...frq, + questions: frq.questions.map((question) => + question.id === questionId ? updater(question) : question, + ), + })); + }; + + const addCriterion = (questionId: string) => { + updateQuestion(questionId, (question) => ({ + ...question, + criteria: [ + ...question.criteria, + { + id: `${questionId}-criterion-${Date.now()}`, + description: "", + points: 1, + }, + ], + })); + }; + + const updateCriterion = ( + questionId: string, + criterionId: string, + changes: Partial>, + ) => { + updateQuestion(questionId, (question) => ({ + ...question, + criteria: question.criteria.map((criterion) => + criterion.id === criterionId ? { ...criterion, ...changes } : criterion, + ), + })); + }; + + const deleteCriterion = (questionId: string, criterionId: string) => { + updateQuestion(questionId, (question) => ({ + ...question, + criteria: question.criteria.filter( + (criterion) => criterion.id !== criterionId, + ), + })); + }; + + if (!frqFound) { + return
Failed to load FRQ.
; + } + + const currentFrq = frqs[currentFrqIndex]; + + if (!currentFrq) { + return
Failed to load FRQ.
; + } + + const descriptionQuestions = [ + createDescriptionQuestion(currentFrq.description), + ]; + + const questionFormats = currentFrq.questions.map( + (question) => question.questionData, + ); + return ( -
- {frqFound - ? "FRQ loaded successfully." - : "Failed to load FRQ."} +
+
+
+ +
+ +
+ + + + setTimeLimitMinutes(Math.max(1, Number(event.target.value) || 1)) + } + className="h-9 w-20" + /> + minutes +
+ +
+ +
+
+ +
+
+
+
+

+ FRQ {currentFrqIndex + 1} of {frqs.length} +

+ + + + updateCurrentFrq((frq) => ({ + ...frq, + title: event.target.value, + })) + } + className="mt-2 max-w-md text-base font-semibold" + /> +
+ +
+

FRQ Description

+

+ Add the source material and directions students need for this + FRQ. +

+ +
+ { + const updatedDescription = updatedQuestions[0]?.question; + + if (!updatedDescription) { + return; + } + + updateCurrentFrq((frq) => ({ + ...frq, + description: updatedDescription, + })); + }} + origin="question" + qIndex={0} + placeholder="Enter the FRQ description here." + /> +
+
+
+ +
+
+
+

Questions

+

+ {currentFrq.questions.length} questions in this FRQ +

+
+ + +
+ + question.id)} + className="space-y-4" + > + {currentFrq.questions.map((question, questionIndex) => ( + + +
+ + {getSubquestionLabel( + currentFrqIndex, + questionIndex, + )} + + + {question.points}{" "} + {question.points === 1 ? "point" : "points"} + +
+
+ + +
+ +
+ { + updateCurrentFrq((frq) => ({ + ...frq, + questions: frq.questions.map( + (frqQuestion, index) => ({ + ...frqQuestion, + questionData: + updatedQuestions[index] ?? + frqQuestion.questionData, + }), + ), + })); + }} + origin="question" + qIndex={questionIndex} + placeholder="Enter the question prompt here." + /> +
+
+ +
+
+ + + updateQuestion(question.id, (currentQuestion) => ({ + ...currentQuestion, + points: Math.max( + 0, + Number(event.target.value) || 0, + ), + })) + } + className="mt-2" + /> +
+ +
+ + + + + + + { + if (value !== "text" && value !== "equation") { + return; + } + + updateQuestion( + question.id, + (currentQuestion) => ({ + ...currentQuestion, + inputType: value, + }), + ); + }} + > + + Text + + + Equation + + + + +
+ +
+ + + + + + + { + if (value !== "public" && value !== "legacy") { + return; + } + + updateQuestion( + question.id, + (currentQuestion) => ({ + ...currentQuestion, + status: value, + }), + ); + }} + > + + Public + + + Legacy + + + + +
+
+ +
+ + + + + +
+ +
+

+ Grading Criteria +

+ + {question.criteria.length === 0 ? ( +

+ No grading criteria have been added. +

+ ) : ( + question.criteria.map((criterion, criterionIndex) => ( +
+
+ +