diff --git a/.changeset/olive-pianos-knock.md b/.changeset/olive-pianos-knock.md new file mode 100644 index 00000000000..eb07aef20b1 --- /dev/null +++ b/.changeset/olive-pianos-knock.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus-core": minor +"@khanacademy/perseus-editor": minor +--- + +again diff --git a/.changeset/soft-olives-push.md b/.changeset/soft-olives-push.md new file mode 100644 index 00000000000..3e85e3ff023 --- /dev/null +++ b/.changeset/soft-olives-push.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus-core": minor +"@khanacademy/perseus-editor": minor +--- + +DEMO adding selectable regions diff --git a/.changeset/wet-files-repair.md b/.changeset/wet-files-repair.md new file mode 100644 index 00000000000..880664b95af --- /dev/null +++ b/.changeset/wet-files-repair.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus-core": minor +"@khanacademy/perseus-editor": minor +--- + +string substitutions diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts index bc81175bc5a..9a5c8d2c05c 100644 --- a/packages/perseus-core/src/index.ts +++ b/packages/perseus-core/src/index.ts @@ -7,6 +7,9 @@ export type { Relationship, Alignment, RecursiveReadonly, + SelectableRegion, + VariableSubstitution, + TextSubstitution, } from "./types"; export type { KeypadKey, @@ -284,6 +287,12 @@ export { getPerseusAIData, } from "./utils/extract-perseus-ai-data"; +export {extractSelectableRegions} from "./utils/selectable-content"; +export { + createItemVariation, + applyTextSubstitutions, +} from "./utils/item-variation"; + import {registerCoreWidgets} from "./widgets/core-widget-registry"; registerCoreWidgets(); diff --git a/packages/perseus-core/src/types.ts b/packages/perseus-core/src/types.ts index af669872256..03f2ab80a22 100644 --- a/packages/perseus-core/src/types.ts +++ b/packages/perseus-core/src/types.ts @@ -63,3 +63,50 @@ export type Alignment = export type RecursiveReadonly = { readonly [K in keyof T]: RecursiveReadonly; }; + +// ============================================================================ +// Variable Selection Types (for Scale Item feature) +// ============================================================================ + +/** + * Represents a region of content that can be selected as a variable. + * The token is opaque to consumers - only Perseus understands its format. + */ +export type SelectableRegion = { + /** Opaque identifier for this selectable region */ + token: string; + /** The text content that can be selected */ + text: string; + /** Human-readable label (e.g., "Question", "Choice A", "Hint 1") */ + label: string; + /** Category for grouping in UI */ + category: "question" | "widget" | "hint" | "answer"; + /** Widget type if from a widget (for display purposes only) */ + widgetType?: string; +}; + +/** + * A substitution to apply when creating an item variation. + * This replaces the entire region's content. + */ +export type VariableSubstitution = { + /** The opaque token from SelectableRegion */ + token: string; + /** The new value to substitute */ + newValue: string; +}; + +/** + * A text substitution with character offsets for substring replacement. + * This allows replacing specific text within a region rather than the entire region. + */ +export type TextSubstitution = { + /** The opaque token from SelectableRegion identifying which region */ + token: string; + /** Character offset where the replacement starts (0-based) */ + startIndex: number; + /** Character offset where the replacement ends (exclusive) */ + endIndex: number; + /** The new text to insert */ + newValue: string; +}; diff --git a/packages/perseus-core/src/utils/item-variation.ts b/packages/perseus-core/src/utils/item-variation.ts new file mode 100644 index 00000000000..4e50bd5ec18 --- /dev/null +++ b/packages/perseus-core/src/utils/item-variation.ts @@ -0,0 +1,643 @@ +/** + * Item Variation Creation + * + * Creates variations of Perseus items by applying substitutions. + * This is used by the Scale Item feature to generate multiple + * versions of an exercise item with different values. + * + * All Perseus model knowledge is encapsulated here - consumers pass + * opaque tokens without needing to understand Perseus internals. + */ +import deepClone from "./deep-clone"; + +import type {PerseusItem} from "../data-schema"; +import type {TextSubstitution, VariableSubstitution} from "../types"; + +/** + * Create a variation of a Perseus item with substitutions applied. + * Tokens are opaque - only this function knows how to interpret them. + * This replaces entire field values. + */ +export function createItemVariation( + item: PerseusItem, + substitutions: VariableSubstitution[], +): PerseusItem { + const newItem = deepClone(item); + + for (const sub of substitutions) { + applySubstitution(newItem, sub.token, sub.newValue); + } + + return newItem; +} + +/** + * Apply text substitutions with character offsets to a Perseus item. + * This allows replacing specific substrings within fields rather than + * replacing the entire field value. + * + * Substitutions are applied in reverse order by position to avoid + * index shifting issues when multiple substitutions target the same field. + * + * @param item - The Perseus item to modify + * @param substitutions - Array of text substitutions with character offsets + * @returns A new item with substitutions applied + */ +export function applyTextSubstitutions( + item: PerseusItem, + substitutions: TextSubstitution[], +): PerseusItem { + const newItem = deepClone(item); + + // Group substitutions by token + const byToken = new Map(); + for (const sub of substitutions) { + const existing = byToken.get(sub.token) ?? []; + existing.push(sub); + byToken.set(sub.token, existing); + } + + // Apply substitutions for each token + for (const [token, tokenSubs] of byToken) { + // Get current text value for this token + const currentText = getTextValueForToken(newItem, token); + if (currentText == null) { + continue; + } + + // Sort substitutions by startIndex descending (apply from end to start) + const sortedSubs = [...tokenSubs].sort( + (a, b) => b.startIndex - a.startIndex, + ); + + // Apply each substitution + let text = currentText; + for (const sub of sortedSubs) { + text = + text.slice(0, sub.startIndex) + + sub.newValue + + text.slice(sub.endIndex); + } + + // Set the modified text back + setTextValueForToken(newItem, token, text); + } + + return newItem; +} + +/** + * Get the text value for a given token from a Perseus item. + */ +function getTextValueForToken(item: PerseusItem, token: string): string | null { + const parts = token.split("."); + + if (parts[0] === "question" && parts[1] === "content") { + return item.question.content ?? null; + } + + if (parts[0] === "hint") { + const hintIndex = parseInt(parts[1], 10); + if (item.hints?.[hintIndex] != null && parts[2] === "content") { + return item.hints[hintIndex].content ?? null; + } + return null; + } + + if (parts[0] === "widget") { + return getWidgetTextValue(item, parts.slice(1)); + } + + return null; +} + +/** + * Set the text value for a given token in a Perseus item. + */ +function setTextValueForToken( + item: PerseusItem, + token: string, + value: string, +): void { + const parts = token.split("."); + + if (parts[0] === "question" && parts[1] === "content") { + item.question.content = value; + return; + } + + if (parts[0] === "hint") { + const hintIndex = parseInt(parts[1], 10); + if (item.hints?.[hintIndex] != null && parts[2] === "content") { + item.hints[hintIndex].content = value; + } + return; + } + + if (parts[0] === "widget") { + setWidgetTextValue(item, parts.slice(1), value); + } +} + +/** + * Get text value from a widget by path parts. + */ +function getWidgetTextValue(item: PerseusItem, parts: string[]): string | null { + const widgetId = parts[0]; + const widget = item.question.widgets?.[widgetId]; + + if (widget == null || widget.options == null) { + return null; + } + + const pathParts = parts.slice(1); + const options = widget.options as Record; + + switch (widget.type) { + case "radio": + if (pathParts[0] === "choices" && pathParts[2] === "content") { + const index = parseInt(pathParts[1], 10); + const choices = options.choices as + | Array<{content: string}> + | undefined; + return choices?.[index]?.content ?? null; + } + break; + case "numeric-input": + if (pathParts[0] === "labelText") { + return (options.labelText as string) ?? null; + } + break; + case "expression": + if (pathParts[0] === "answerForms" && pathParts[2] === "value") { + const index = parseInt(pathParts[1], 10); + const answerForms = options.answerForms as + | Array<{value: string}> + | undefined; + return answerForms?.[index]?.value ?? null; + } + if (pathParts[0] === "visibleLabel") { + return (options.visibleLabel as string) ?? null; + } + if (pathParts[0] === "ariaLabel") { + return (options.ariaLabel as string) ?? null; + } + break; + case "interactive-graph": + return getInteractiveGraphTextValue(options, pathParts); + } + + return null; +} + +/** + * Set text value in a widget by path parts. + */ +function setWidgetTextValue( + item: PerseusItem, + parts: string[], + value: string, +): void { + const widgetId = parts[0]; + const widget = item.question.widgets?.[widgetId]; + + if (widget == null || widget.options == null) { + return; + } + + const pathParts = parts.slice(1); + const options = widget.options as Record; + + switch (widget.type) { + case "radio": + if (pathParts[0] === "choices" && pathParts[2] === "content") { + const index = parseInt(pathParts[1], 10); + const choices = options.choices as + | Array<{content: string}> + | undefined; + if (choices?.[index] != null) { + choices[index].content = value; + } + } + break; + case "numeric-input": + if (pathParts[0] === "labelText") { + options.labelText = value; + } + break; + case "expression": + if (pathParts[0] === "answerForms" && pathParts[2] === "value") { + const index = parseInt(pathParts[1], 10); + const answerForms = options.answerForms as + | Array<{value: string}> + | undefined; + if (answerForms?.[index] != null) { + answerForms[index].value = value; + } + } else if (pathParts[0] === "visibleLabel") { + options.visibleLabel = value; + } else if (pathParts[0] === "ariaLabel") { + options.ariaLabel = value; + } + break; + case "interactive-graph": + setInteractiveGraphTextValue(options, pathParts, value); + break; + } +} + +/** + * Get text value from interactive graph widget. + */ +function getInteractiveGraphTextValue( + options: Record, + parts: string[], +): string | null { + if (parts[0] === "labels") { + const index = parseInt(parts[1], 10); + const labels = options.labels as string[] | undefined; + return labels?.[index] ?? null; + } + if (parts[0] === "fullGraphAriaLabel") { + return (options.fullGraphAriaLabel as string) ?? null; + } + if (parts[0] === "fullGraphAriaDescription") { + return (options.fullGraphAriaDescription as string) ?? null; + } + if (parts[0] === "lockedFigures") { + return getLockedFigureTextValue(options, parts.slice(1)); + } + return null; +} + +/** + * Set text value in interactive graph widget. + */ +function setInteractiveGraphTextValue( + options: Record, + parts: string[], + value: string, +): void { + if (parts[0] === "labels") { + const index = parseInt(parts[1], 10); + const labels = options.labels as string[] | undefined; + if (labels != null) { + labels[index] = value; + } + } else if (parts[0] === "fullGraphAriaLabel") { + options.fullGraphAriaLabel = value; + } else if (parts[0] === "fullGraphAriaDescription") { + options.fullGraphAriaDescription = value; + } else if (parts[0] === "lockedFigures") { + setLockedFigureTextValue(options, parts.slice(1), value); + } +} + +/** + * Get text value from a locked figure. + */ +function getLockedFigureTextValue( + options: Record, + parts: string[], +): string | null { + const figureIndex = parseInt(parts[0], 10); + const lockedFigures = options.lockedFigures as + | Array> + | undefined; + + if (lockedFigures?.[figureIndex] == null) { + return null; + } + + const figure = lockedFigures[figureIndex]; + + if (parts[1] === "ariaLabel") { + return (figure.ariaLabel as string) ?? null; + } + if (parts[1] === "text") { + return (figure.text as string) ?? null; + } + if (parts[1] === "labels") { + const labelIndex = parseInt(parts[2], 10); + const labels = figure.labels as Array<{text: string}> | undefined; + if (labels?.[labelIndex] != null && parts[3] === "text") { + return labels[labelIndex].text ?? null; + } + } + return null; +} + +/** + * Set text value in a locked figure. + */ +function setLockedFigureTextValue( + options: Record, + parts: string[], + value: string, +): void { + const figureIndex = parseInt(parts[0], 10); + const lockedFigures = options.lockedFigures as + | Array> + | undefined; + + if (lockedFigures?.[figureIndex] == null) { + return; + } + + const figure = lockedFigures[figureIndex]; + + if (parts[1] === "ariaLabel") { + figure.ariaLabel = value; + } else if (parts[1] === "text") { + figure.text = value; + } else if (parts[1] === "labels") { + const labelIndex = parseInt(parts[2], 10); + const labels = figure.labels as Array<{text: string}> | undefined; + if (labels?.[labelIndex] != null && parts[3] === "text") { + labels[labelIndex].text = value; + } + } +} + +/** + * Apply a single substitution to an item based on the token. + */ +function applySubstitution( + item: PerseusItem, + token: string, + newValue: string, +): void { + const parts = token.split("."); + + if (parts[0] === "question" && parts[1] === "content") { + applyQuestionContentSubstitution(item, parts.slice(2), newValue); + } else if (parts[0] === "widget") { + applyWidgetSubstitution(item, parts.slice(1), newValue); + } else if (parts[0] === "hint") { + applyHintSubstitution(item, parts.slice(1), newValue); + } +} + +/** + * Apply substitution to question content. + */ +function applyQuestionContentSubstitution( + item: PerseusItem, + _remainingParts: string[], + newValue: string, +): void { + // For now, replace entire content + // Future: support substring replacement with parts like "12:25" for char range + item.question.content = newValue; +} + +/** + * Apply substitution to a hint. + */ +function applyHintSubstitution( + item: PerseusItem, + parts: string[], + newValue: string, +): void { + // parts[0] = hint index, parts[1] = "content" + const hintIndex = parseInt(parts[0], 10); + + if ( + item.hints != null && + item.hints[hintIndex] != null && + parts[1] === "content" + ) { + item.hints[hintIndex].content = newValue; + } +} + +/** + * Apply substitution to a widget. + */ +function applyWidgetSubstitution( + item: PerseusItem, + parts: string[], + newValue: string, +): void { + // parts[0] = widget ID, parts[1...] = path within widget + const widgetId = parts[0]; + const widget = item.question.widgets?.[widgetId]; + + if (widget == null || widget.options == null) { + return; + } + + const pathParts = parts.slice(1); + + switch (widget.type) { + case "radio": + applyRadioSubstitution(widget.options, pathParts, newValue); + break; + case "numeric-input": + applyNumericInputSubstitution(widget.options, pathParts, newValue); + break; + case "expression": + applyExpressionSubstitution(widget.options, pathParts, newValue); + break; + case "interactive-graph": + applyInteractiveGraphSubstitution( + widget.options, + pathParts, + newValue, + ); + break; + } +} + +/** + * Apply substitution to a radio widget. + */ +function applyRadioSubstitution( + options: Record, + parts: string[], + newValue: string, +): void { + // parts: ["choices", index, "content"] + if (parts[0] === "choices" && parts[2] === "content") { + const index = parseInt(parts[1], 10); + const choices = options.choices as Array<{content: string}> | undefined; + if (choices != null && choices[index] != null) { + choices[index].content = newValue; + } + } +} + +/** + * Apply substitution to a numeric-input widget. + */ +function applyNumericInputSubstitution( + options: Record, + parts: string[], + newValue: string, +): void { + if (parts[0] === "labelText") { + options.labelText = newValue; + } +} + +/** + * Apply substitution to an expression widget. + */ +function applyExpressionSubstitution( + options: Record, + parts: string[], + newValue: string, +): void { + if (parts[0] === "answerForms" && parts[2] === "value") { + const index = parseInt(parts[1], 10); + const answerForms = options.answerForms as + | Array<{value: string}> + | undefined; + if (answerForms != null && answerForms[index] != null) { + answerForms[index].value = newValue; + } + } else if (parts[0] === "visibleLabel") { + options.visibleLabel = newValue; + } else if (parts[0] === "ariaLabel") { + options.ariaLabel = newValue; + } +} + +/** + * Apply substitution to an interactive-graph widget. + */ +function applyInteractiveGraphSubstitution( + options: Record, + parts: string[], + newValue: string, +): void { + if (parts[0] === "labels") { + const index = parseInt(parts[1], 10); + const labels = options.labels as string[] | undefined; + if (labels != null) { + labels[index] = newValue; + } + } else if (parts[0] === "fullGraphAriaLabel") { + options.fullGraphAriaLabel = newValue; + } else if (parts[0] === "fullGraphAriaDescription") { + options.fullGraphAriaDescription = newValue; + } else if (parts[0] === "lockedFigures") { + applyLockedFigureSubstitution(options, parts.slice(1), newValue); + } else if (parts[0] === "correct") { + applyCorrectAnswerSubstitution(options, parts.slice(1), newValue); + } +} + +/** + * Apply substitution to a locked figure. + */ +function applyLockedFigureSubstitution( + options: Record, + parts: string[], + newValue: string, +): void { + // parts: [figureIndex, "ariaLabel" | "labels" | "coord" | "text", ...] + const figureIndex = parseInt(parts[0], 10); + const lockedFigures = options.lockedFigures as + | Array> + | undefined; + + if (lockedFigures == null || lockedFigures[figureIndex] == null) { + return; + } + + const figure = lockedFigures[figureIndex]; + + if (parts[1] === "ariaLabel") { + figure.ariaLabel = newValue; + } else if (parts[1] === "text") { + figure.text = newValue; + } else if (parts[1] === "labels") { + const labelIndex = parseInt(parts[2], 10); + const labels = figure.labels as Array<{text: string}> | undefined; + if ( + labels != null && + labels[labelIndex] != null && + parts[3] === "text" + ) { + labels[labelIndex].text = newValue; + } + } else if (parts[1] === "coord") { + // Parse coordinate string like "(1, 2)" to [1, 2] + const coords = parseCoordinate(newValue); + if (coords != null) { + figure.coord = coords; + } + } +} + +/** + * Apply substitution to correct answer coordinates. + */ +function applyCorrectAnswerSubstitution( + options: Record, + parts: string[], + newValue: string, +): void { + const correct = options.correct as Record | undefined; + if (correct == null) { + return; + } + + if (parts[0] === "coords") { + const coords = correct.coords as Array | undefined; + if (coords == null) { + return; + } + + // Handle nested coordinates (e.g., for segments) + if (parts.length === 2) { + // Simple coordinate: correct.coords.0 + const index = parseInt(parts[1], 10); + const newCoord = parseCoordinate(newValue); + if (newCoord != null) { + coords[index] = newCoord; + } + } else if (parts.length === 3) { + // Nested coordinate: correct.coords.0.1 (segment) + const segIndex = parseInt(parts[1], 10); + const pointIndex = parseInt(parts[2], 10); + const segment = coords[segIndex] as Array | undefined; + if (segment != null) { + const newCoord = parseCoordinate(newValue); + if (newCoord != null) { + segment[pointIndex] = newCoord; + } + } + } + } else if (parts[0] === "center") { + const newCoord = parseCoordinate(newValue); + if (newCoord != null) { + correct.center = newCoord; + } + } else if (parts[0] === "radius") { + correct.radius = parseFloat(newValue); + } +} + +/** + * Parse a coordinate string like "(1, 2)" or "1, 2" to [number, number]. + */ +function parseCoordinate(value: string): [number, number] | null { + // Remove parentheses and whitespace + const cleaned = value.replace(/[()]/g, "").trim(); + const parts = cleaned.split(",").map((p) => p.trim()); + + if (parts.length !== 2) { + return null; + } + + const x = parseFloat(parts[0]); + const y = parseFloat(parts[1]); + + if (isNaN(x) || isNaN(y)) { + return null; + } + + return [x, y]; +} diff --git a/packages/perseus-core/src/utils/selectable-content.ts b/packages/perseus-core/src/utils/selectable-content.ts new file mode 100644 index 00000000000..3aac778f84d --- /dev/null +++ b/packages/perseus-core/src/utils/selectable-content.ts @@ -0,0 +1,423 @@ +/** + * Selectable Content Extraction + * + * Extracts selectable regions from Perseus items for variable creation. + * This is used by the Scale Item feature to allow content creators to + * create variables from item content. + * + * All Perseus model knowledge is encapsulated here - consumers receive + * opaque tokens without needing to understand Perseus internals. + */ +import type { + PerseusItem, + PerseusWidget, + PerseusRadioWidgetOptions, + PerseusNumericInputWidgetOptions, + PerseusExpressionWidgetOptions, + PerseusInteractiveGraphWidgetOptions, + LockedFigure, +} from "../data-schema"; +import type {SelectableRegion} from "../types"; + +/** + * Extract all selectable regions from a Perseus item. + * Consumers receive opaque tokens - they don't need to understand the paths. + */ +export function extractSelectableRegions( + item: PerseusItem, +): SelectableRegion[] { + const regions: SelectableRegion[] = []; + + // 1. Main question content + if (item.question.content) { + regions.push({ + token: "question.content", + text: item.question.content, + label: "Question", + category: "question", + }); + } + + // 2. Widget content (using widget-specific extractors) + for (const [widgetId, widget] of Object.entries( + item.question.widgets ?? {}, + )) { + const widgetRegions = extractWidgetRegions(widgetId, widget); + regions.push(...widgetRegions); + } + + // 3. Hints + item.hints?.forEach((hint, index) => { + if (hint.content) { + regions.push({ + token: `hint.${index}.content`, + text: hint.content, + label: `Hint ${index + 1}`, + category: "hint", + }); + } + }); + + return regions; +} + +/** + * Extract selectable regions from a widget based on its type. + */ +function extractWidgetRegions( + widgetId: string, + widget: PerseusWidget, +): SelectableRegion[] { + if (widget.options == null) { + return []; + } + + switch (widget.type) { + case "radio": + return extractRadioRegions( + widgetId, + widget.options as PerseusRadioWidgetOptions, + ); + case "numeric-input": + return extractNumericInputRegions( + widgetId, + widget.options as PerseusNumericInputWidgetOptions, + ); + case "expression": + return extractExpressionRegions( + widgetId, + widget.options as PerseusExpressionWidgetOptions, + ); + case "interactive-graph": + return extractInteractiveGraphRegions( + widgetId, + widget.options as PerseusInteractiveGraphWidgetOptions, + ); + default: + return []; + } +} + +/** + * Extract selectable regions from a radio widget. + */ +function extractRadioRegions( + widgetId: string, + options: PerseusRadioWidgetOptions, +): SelectableRegion[] { + if (options.choices == null) { + return []; + } + + return options.choices.map((choice, index) => ({ + token: `widget.${widgetId}.choices.${index}.content`, + text: choice.content, + label: `Choice ${String.fromCharCode(65 + index)}`, // A, B, C... + category: "widget" as const, + widgetType: "radio", + })); +} + +/** + * Extract selectable regions from a numeric-input widget. + */ +function extractNumericInputRegions( + widgetId: string, + options: PerseusNumericInputWidgetOptions, +): SelectableRegion[] { + const regions: SelectableRegion[] = []; + + // Label text is the main selectable content + if (options.labelText) { + regions.push({ + token: `widget.${widgetId}.labelText`, + text: options.labelText, + label: "Label", + category: "widget", + widgetType: "numeric-input", + }); + } + + return regions; +} + +/** + * Extract selectable regions from an expression widget. + */ +function extractExpressionRegions( + widgetId: string, + options: PerseusExpressionWidgetOptions, +): SelectableRegion[] { + const regions: SelectableRegion[] = []; + + // Answer forms contain the expression values + if (options.answerForms != null) { + options.answerForms.forEach((form, index) => { + if (form.value && form.considered === "correct") { + regions.push({ + token: `widget.${widgetId}.answerForms.${index}.value`, + text: form.value, + label: `Answer ${index + 1}`, + category: "answer", + widgetType: "expression", + }); + } + }); + } + + // Visible label + if (options.visibleLabel) { + regions.push({ + token: `widget.${widgetId}.visibleLabel`, + text: options.visibleLabel, + label: "Visible Label", + category: "widget", + widgetType: "expression", + }); + } + + // Aria label + if (options.ariaLabel) { + regions.push({ + token: `widget.${widgetId}.ariaLabel`, + text: options.ariaLabel, + label: "Aria Label", + category: "widget", + widgetType: "expression", + }); + } + + return regions; +} + +/** + * Extract selectable regions from an interactive-graph widget. + */ +function extractInteractiveGraphRegions( + widgetId: string, + options: PerseusInteractiveGraphWidgetOptions, +): SelectableRegion[] { + const regions: SelectableRegion[] = []; + + // Axis labels + if (options.labels) { + options.labels.forEach((label, index) => { + if (label) { + regions.push({ + token: `widget.${widgetId}.labels.${index}`, + text: label, + label: index === 0 ? "X-Axis Label" : "Y-Axis Label", + category: "widget", + widgetType: "interactive-graph", + }); + } + }); + } + + // Full graph aria label + if (options.fullGraphAriaLabel) { + regions.push({ + token: `widget.${widgetId}.fullGraphAriaLabel`, + text: options.fullGraphAriaLabel, + label: "Graph Aria Label", + category: "widget", + widgetType: "interactive-graph", + }); + } + + // Full graph aria description + if (options.fullGraphAriaDescription) { + regions.push({ + token: `widget.${widgetId}.fullGraphAriaDescription`, + text: options.fullGraphAriaDescription, + label: "Graph Aria Description", + category: "widget", + widgetType: "interactive-graph", + }); + } + + // Locked figures + if (options.lockedFigures != null) { + options.lockedFigures.forEach((figure, figureIndex) => { + const figureRegions = extractLockedFigureRegions( + widgetId, + figure, + figureIndex, + ); + regions.push(...figureRegions); + }); + } + + // Correct answer coordinates (for interactive graphs) + const correctRegions = extractCorrectAnswerRegions(widgetId, options); + regions.push(...correctRegions); + + return regions; +} + +/** + * Extract selectable regions from a locked figure. + */ +function extractLockedFigureRegions( + widgetId: string, + figure: LockedFigure, + figureIndex: number, +): SelectableRegion[] { + const regions: SelectableRegion[] = []; + const baseToken = `widget.${widgetId}.lockedFigures.${figureIndex}`; + + // Aria label (common to most locked figures) + if ("ariaLabel" in figure && figure.ariaLabel) { + regions.push({ + token: `${baseToken}.ariaLabel`, + text: figure.ariaLabel, + label: `Locked ${figure.type} ${figureIndex + 1} - Aria Label`, + category: "widget", + widgetType: "interactive-graph", + }); + } + + // Labels attached to the figure + if ("labels" in figure && figure.labels != null) { + figure.labels.forEach( + ( + label: {text: string; coord: [number, number]}, + labelIndex: number, + ) => { + if (label.text) { + regions.push({ + token: `${baseToken}.labels.${labelIndex}.text`, + text: label.text, + label: `Locked ${figure.type} ${figureIndex + 1} - Label ${labelIndex + 1}`, + category: "widget", + widgetType: "interactive-graph", + }); + } + }, + ); + } + + // Coordinates - format as string for selection + if (figure.type === "point" && "coord" in figure) { + const coordStr = `(${figure.coord[0]}, ${figure.coord[1]})`; + regions.push({ + token: `${baseToken}.coord`, + text: coordStr, + label: `Locked Point ${figureIndex + 1} - Coordinates`, + category: "widget", + widgetType: "interactive-graph", + }); + } + + if (figure.type === "label" && "text" in figure) { + regions.push({ + token: `${baseToken}.text`, + text: figure.text, + label: `Locked Label ${figureIndex + 1}`, + category: "widget", + widgetType: "interactive-graph", + }); + } + + return regions; +} + +/** + * Extract selectable regions from the correct answer of an interactive graph. + */ +function extractCorrectAnswerRegions( + widgetId: string, + options: PerseusInteractiveGraphWidgetOptions, +): SelectableRegion[] { + const regions: SelectableRegion[] = []; + const correct = options.correct; + + if (correct == null || correct.type === "none") { + return regions; + } + + // Extract coordinates based on graph type + if (correct.type === "point" && "coords" in correct && correct.coords) { + correct.coords.forEach((coord: [number, number], index: number) => { + const coordStr = `(${coord[0]}, ${coord[1]})`; + regions.push({ + token: `widget.${widgetId}.correct.coords.${index}`, + text: coordStr, + label: `Correct Point ${index + 1}`, + category: "answer", + widgetType: "interactive-graph", + }); + }); + } + + if (correct.type === "linear" && "coords" in correct && correct.coords) { + correct.coords.forEach((coord: [number, number], index: number) => { + const coordStr = `(${coord[0]}, ${coord[1]})`; + regions.push({ + token: `widget.${widgetId}.correct.coords.${index}`, + text: coordStr, + label: `Line Point ${index + 1}`, + category: "answer", + widgetType: "interactive-graph", + }); + }); + } + + if (correct.type === "segment" && "coords" in correct && correct.coords) { + correct.coords.forEach( + ( + segment: [[number, number], [number, number]], + segIndex: number, + ) => { + segment.forEach( + (coord: [number, number], pointIndex: number) => { + const coordStr = `(${coord[0]}, ${coord[1]})`; + regions.push({ + token: `widget.${widgetId}.correct.coords.${segIndex}.${pointIndex}`, + text: coordStr, + label: `Segment ${segIndex + 1} Point ${pointIndex + 1}`, + category: "answer", + widgetType: "interactive-graph", + }); + }, + ); + }, + ); + } + + if (correct.type === "polygon" && "coords" in correct && correct.coords) { + correct.coords.forEach((coord: [number, number], index: number) => { + const coordStr = `(${coord[0]}, ${coord[1]})`; + regions.push({ + token: `widget.${widgetId}.correct.coords.${index}`, + text: coordStr, + label: `Polygon Vertex ${index + 1}`, + category: "answer", + widgetType: "interactive-graph", + }); + }); + } + + if (correct.type === "circle" && "center" in correct && correct.center) { + const centerStr = `(${correct.center[0]}, ${correct.center[1]})`; + regions.push({ + token: `widget.${widgetId}.correct.center`, + text: centerStr, + label: "Circle Center", + category: "answer", + widgetType: "interactive-graph", + }); + + if ("radius" in correct && correct.radius != null) { + regions.push({ + token: `widget.${widgetId}.correct.radius`, + text: String(correct.radius), + label: "Circle Radius", + category: "answer", + widgetType: "interactive-graph", + }); + } + } + + return regions; +} diff --git a/packages/perseus-editor/src/index.ts b/packages/perseus-editor/src/index.ts index a32f5c3ad71..8f326d9d913 100644 --- a/packages/perseus-editor/src/index.ts +++ b/packages/perseus-editor/src/index.ts @@ -9,8 +9,19 @@ export {default as EditorPage} from "./editor-page"; export {default as Editor} from "./editor"; export {default as IframeContentRenderer} from "./iframe-content-renderer"; export {default as ContentPreview} from "./content-preview"; +export {SelectableItemRenderer} from "./selectable-item-renderer"; export type {Issue} from "./components/issues-panel"; +// Re-export variable selection utilities from perseus-core for convenience +export { + extractSelectableRegions, + createItemVariation, + applyTextSubstitutions, + type SelectableRegion, + type VariableSubstitution, + type TextSubstitution, +} from "@khanacademy/perseus-core"; + import "./styles/perseus-editor.css"; // eslint-disable-next-line import/order diff --git a/packages/perseus-editor/src/selectable-item-renderer.tsx b/packages/perseus-editor/src/selectable-item-renderer.tsx new file mode 100644 index 00000000000..53c0045897c --- /dev/null +++ b/packages/perseus-editor/src/selectable-item-renderer.tsx @@ -0,0 +1,78 @@ +/** + * SelectableItemRenderer + * + * A component that renders a Perseus item with selectable regions for + * variable creation. This is used by the Scale Item feature to allow + * content creators to create variables from item content. + * + * The component exposes selectable regions via callbacks, and consumers + * can use opaque tokens to identify regions without understanding + * Perseus internals. + */ +import { + ServerItemRenderer, + type APIOptions, + type PerseusDependenciesV2, +} from "@khanacademy/perseus"; +import { + extractSelectableRegions, + type PerseusItem, + type SelectableRegion, +} from "@khanacademy/perseus-core"; +import {View} from "@khanacademy/wonder-blocks-core"; +import {StyleSheet} from "aphrodite"; +import * as React from "react"; + +type Props = { + /** The Perseus item to render */ + item: PerseusItem; + /** Called once with all selectable regions when item loads */ + onRegionsExtracted?: (regions: SelectableRegion[]) => void; + /** Tokens to highlight as already selected (for visual feedback) */ + selectedTokens?: Set; + /** Standard Perseus dependencies */ + dependencies: PerseusDependenciesV2; + /** Optional API options */ + apiOptions?: APIOptions; +}; + +/** + * Renders a Perseus item with selectable regions for variable creation. + */ +export function SelectableItemRenderer({ + item, + onRegionsExtracted, + selectedTokens, + dependencies, + apiOptions, +}: Props): React.ReactElement { + // Extract regions on mount/item change + React.useEffect(() => { + const regions = extractSelectableRegions(item); + onRegionsExtracted?.(regions); + }, [item, onRegionsExtracted]); + + return ( + +
+ +
+
+ ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + overflow: "auto", + }, +});