diff --git a/gulpfile.js b/gulpfile.js index 80558301dc0b..7d96ef8361cd 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -777,6 +777,7 @@ exports.tt = teacherTool; exports.icons = buildSVGIcons; exports.testhelpers = testhelpers; exports.testpxteditor = testpxteditor; +exports.reactCommon = reactCommon; exports.cli = gulp.series( gulp.parallel(pxtlib, pxtweb), gulp.parallel(pxtcompiler, pxtsim, backendutils), diff --git a/localtypings/pxteditor.d.ts b/localtypings/pxteditor.d.ts index ebba04fdd713..5e72b5bf2b89 100644 --- a/localtypings/pxteditor.d.ts +++ b/localtypings/pxteditor.d.ts @@ -54,6 +54,8 @@ declare namespace pxt.editor { | "redo" | "renderblocks" | "renderpython" + | "renderxml" + | "renderbyblockid" | "setscale" | "startactivity" | "saveproject" @@ -64,6 +66,7 @@ declare namespace pxt.editor { | "requestprojectcloudstatus" | "convertcloudprojectstolocal" | "setlanguagerestriction" + | "gettoolboxcategories" | "toggletrace" // EditorMessageToggleTraceRequest | "togglehighcontrast" @@ -294,6 +297,21 @@ declare namespace pxt.editor { layout?: BlockLayout; } + export interface EditorMessageRenderXmlRequest extends EditorMessageRequest { + action: "renderxml"; + // xml to render + xml: string; + snippetMode?: boolean; + layout?: BlockLayout; + } + + export interface EditorMessageRenderByBlockIdRequest extends EditorMessageRequest { + action: "renderbyblockid"; + blockId: string; + snippetMode?: boolean; + layout?: BlockLayout; + } + export interface EditorMessageRunEvalRequest extends EditorMessageRequest { action: "runeval"; validatorPlan: pxt.blocks.ValidatorPlan; @@ -305,6 +323,16 @@ declare namespace pxt.editor { xml: Promise; } + export interface EditorMessageRenderXmlResponse { + svg: SVGSVGElement; + resultXml: Promise; + } + + export interface EditorMessageRenderByBlockIdResponse { + svg: SVGSVGElement; + resultXml: Promise; + } + export interface EditorMessageRenderPythonRequest extends EditorMessageRequest { action: "renderpython"; // typescript code to render @@ -398,6 +426,15 @@ declare namespace pxt.editor { restriction: pxt.editor.LanguageRestriction; } + export interface EditorMessageGetToolboxCategoriesRequest extends EditorMessageRequest { + action: "gettoolboxcategories"; + advanced?: boolean; + } + + export interface EditorMessageGetToolboxCategoriesResponse { + categories: pxt.editor.ToolboxCategoryDefinition[]; + } + export interface DataStreams { console?: T; messages?: T; @@ -927,10 +964,13 @@ declare namespace pxt.editor { blocksScreenshotAsync(pixelDensity?: number, encodeBlocks?: boolean): Promise; renderBlocksAsync(req: pxt.editor.EditorMessageRenderBlocksRequest): Promise; renderPythonAsync(req: pxt.editor.EditorMessageRenderPythonRequest): Promise; + renderXml(req: pxt.editor.EditorMessageRenderXmlRequest): pxt.editor.EditorMessageRenderXmlResponse; + renderByBlockIdAsync(req: pxt.editor.EditorMessageRenderByBlockIdRequest): Promise; // FIXME (riknoll) need to figure out how to type this better // getBlocks(): Blockly.Block[]; getBlocks(): any[]; + getToolboxCategories(advanced?: boolean): pxt.editor.EditorMessageGetToolboxCategoriesResponse; toggleHighContrast(): void; setHighContrast(on: boolean): void; @@ -1218,4 +1258,4 @@ declare namespace pxt.workspace { fireEvent?: (ev: pxt.editor.EditorEvent) => void; } -} \ No newline at end of file +} diff --git a/pxteditor/editorcontroller.ts b/pxteditor/editorcontroller.ts index 019e1c27f232..964415ea16b3 100644 --- a/pxteditor/editorcontroller.ts +++ b/pxteditor/editorcontroller.ts @@ -154,6 +154,26 @@ export function bindEditorMessages(getEditorAsync: () => Promise) }) }); } + case "renderxml": { + const rendermsg = data as pxt.editor.EditorMessageRenderXmlRequest; + return Promise.resolve() + .then(() => { + const r = projectView.renderXml(rendermsg); + return r.resultXml.then((svg: any) => { + resp = svg.xml; + }) + }); + } + case "renderbyblockid": { + const rendermsg = data as pxt.editor.EditorMessageRenderByBlockIdRequest; + return Promise.resolve() + .then(() => projectView.renderByBlockIdAsync(rendermsg)) + .then(r => { + return r.resultXml.then((svg: any) => { + resp = svg.xml; + }) + }); + } case "runeval": { const evalmsg = data as pxt.editor.EditorMessageRunEvalRequest; const plan = evalmsg.validatorPlan; @@ -166,6 +186,13 @@ export function bindEditorMessages(getEditorAsync: () => Promise) resp = { result: results }; }); } + case "gettoolboxcategories": { + const msg = data as pxt.editor.EditorMessageGetToolboxCategoriesRequest; + return Promise.resolve() + .then(() => { + resp = projectView.getToolboxCategories(msg.advanced); + }); + } case "renderpython": { const rendermsg = data as pxt.editor.EditorMessageRenderPythonRequest; return Promise.resolve() @@ -346,4 +373,4 @@ export function postHostMessageAsync(msg: pxt.editor.EditorMessageRequest): Prom if (!msg.response) resolve(undefined) }) -} \ No newline at end of file +} diff --git a/pxtlib/util.ts b/pxtlib/util.ts index f7e6d84a151b..543ace5b8ddd 100644 --- a/pxtlib/util.ts +++ b/pxtlib/util.ts @@ -1426,6 +1426,14 @@ namespace ts.pxtc.Util { return (n || "").split(/(?=[A-Z])/g).join(" ").toLowerCase(); } + export function camelCaseToLowercaseWithSpaces(n: string) { + return n.replace(/([A-Z])/gm, ' $1').toLocaleLowerCase().trim(); + } + + export function snakeCaseToLowercaseWithSpaces(n: string) { + return n.replace(/_/g, ' ').toLocaleLowerCase().trim(); + } + export function range(len: number) { let r: number[] = [] for (let i = 0; i < len; ++i) r.push(i) diff --git a/pxtservices/iframeDriver.ts b/pxtservices/iframeDriver.ts index 46f8447c3b69..cd906f0ae11f 100644 --- a/pxtservices/iframeDriver.ts +++ b/pxtservices/iframeDriver.ts @@ -352,6 +352,42 @@ export class IframeDriver { return (resp.resp as pxt.editor.EditorMessageRenderPythonResponse).python; } + async renderXml(xml: string) { + const resp = await this.sendRequest( + { + type: "pxteditor", + action: "renderxml", + xml + } as pxt.editor.EditorMessageRenderXmlRequest + ) as pxt.editor.EditorMessageResponse; + + return resp.resp; + } + + async renderByBlockId(blockId: string) { + const resp = await this.sendRequest( + { + type: "pxteditor", + action: "renderbyblockid", + blockId: blockId + } as pxt.editor.EditorMessageRenderByBlockIdRequest + ) as pxt.editor.EditorMessageResponse; + + return resp.resp; + } + + async getToolboxCategories(advanced?: boolean): Promise { + const resp = await this.sendRequest( + { + type: "pxteditor", + action: "gettoolboxcategories", + advanced + } as pxt.editor.EditorMessageGetToolboxCategoriesRequest + ) as pxt.editor.EditorMessageResponse; + + return (resp.resp as pxt.editor.EditorMessageGetToolboxCategoriesResponse).categories; + } + async runValidatorPlan(validatorPlan: pxt.blocks.ValidatorPlan, planLib: pxt.blocks.ValidatorPlan[]) { const resp = await this.sendRequest( { @@ -550,4 +586,4 @@ export class IframeDriver { } } } -} \ No newline at end of file +} diff --git a/react-common/components/controls/LazyImage.tsx b/react-common/components/controls/LazyImage.tsx index 1bb98e1e4da0..017b5ced9222 100644 --- a/react-common/components/controls/LazyImage.tsx +++ b/react-common/components/controls/LazyImage.tsx @@ -5,6 +5,7 @@ export interface LazyImageProps extends ControlProps { src: string; alt: string; title?: string; + loadingElement?: JSX.Element; } let observer: IntersectionObserver; @@ -20,6 +21,7 @@ export const LazyImage = (props: LazyImageProps) => { ariaLabel, ariaHidden, ariaDescribedBy, + loadingElement, } = props; initObserver(); @@ -34,9 +36,8 @@ export const LazyImage = (props: LazyImageProps) => { observer.observe(ref); } - - return
+
{loadingElement ? loadingElement :
}
{ aria-hidden={ariaHidden} aria-describedby={ariaDescribedBy} /> -
} @@ -85,4 +85,4 @@ function initObserver() { }) } observer = new IntersectionObserver(onIntersection, config); -} \ No newline at end of file +} diff --git a/react-common/styles/controls/LazyImage.less b/react-common/styles/controls/LazyImage.less index 4b41568d55ca..bcc3db8f2057 100644 --- a/react-common/styles/controls/LazyImage.less +++ b/react-common/styles/controls/LazyImage.less @@ -4,10 +4,12 @@ align-items: center; .common-spinner { - position: absolute; width: 60px; height: 60px; + } + .loading-element { + position: absolute; opacity: 1; transition: opacity 0.3s ease; } @@ -28,7 +30,7 @@ } .common-lazy-image-wrapper.loaded { - .common-spinner { + .loading-element { opacity: 0; } @@ -42,7 +44,7 @@ } .common-lazy-image-wrapper.error { - .common-spinner { + .loading-element { opacity: 0; } @@ -53,4 +55,4 @@ img { opacity: 0; } -} \ No newline at end of file +} diff --git a/teachertool/src/App.tsx b/teachertool/src/App.tsx index 76fc205c7f82..4973715e2cf8 100644 --- a/teachertool/src/App.tsx +++ b/teachertool/src/App.tsx @@ -15,6 +15,7 @@ import { loadValidatorPlansAsync } from "./transforms/loadValidatorPlansAsync"; import { tryLoadLastActiveRubricAsync } from "./transforms/tryLoadLastActiveRubricAsync"; import { ImportRubricModal } from "./components/ImportRubricModal"; import { ConfirmationModal } from "./components/ConfirmationModal"; +import { BlockPickerModal } from "./components/BlockPickerModal"; export const App = () => { const { state, dispatch } = useContext(AppStateContext); @@ -59,6 +60,7 @@ export const App = () => { + ); diff --git a/teachertool/src/components/AddCriteriaButton.tsx b/teachertool/src/components/AddCriteriaButton.tsx index 5e51815e3676..8e3935c20876 100644 --- a/teachertool/src/components/AddCriteriaButton.tsx +++ b/teachertool/src/components/AddCriteriaButton.tsx @@ -5,6 +5,7 @@ import { AppStateContext } from "../state/appStateContext"; import { useContext, useMemo } from "react"; import { classList } from "react-common/components/util"; import { Strings } from "../constants"; +import { CatalogDisplayOptions } from "../types/modalOptions"; interface IProps {} @@ -19,7 +20,7 @@ export const AddCriteriaButton: React.FC = ({}) => {
+
+ ) : null; +}; + +const LoadingBlocks: React.FC = () => { + return ( +
+
+
+ ); +}; + +export interface BlockPickerModalProps {} +export const BlockPickerModal: React.FC = ({}) => { + const { state: teacherTool } = useContext(AppStateContext); + const [blockPickerOptions, setBlockPickerOptions] = useState(undefined); + + useEffect(() => { + if (teacherTool.modalOptions?.modal === "block-picker") { + setBlockPickerOptions(teacherTool.modalOptions as BlockPickerOptions); + } else { + setBlockPickerOptions(undefined); + } + }, [teacherTool.modalOptions]); + + function handleBlockSelected(block: pxt.editor.ToolboxBlockDefinition) { + if (blockPickerOptions) { + setParameterValue(blockPickerOptions.criteriaInstanceId, blockPickerOptions.paramName, block.blockId); + } else { + logError(ErrorCode.selectedBlockWithoutOptions, "Block selected without block picker options."); + } + + hideModal(); + } + + const modalActions = [ + { + label: Strings.Cancel, + className: "secondary", + onClick: hideModal, + }, + ]; + + return blockPickerOptions ? ( + + {teacherTool.toolboxCategories && + Object.values(teacherTool.toolboxCategories).map(category => { + return ( + handleBlockSelected(block)} + /> + ); + })} + {!teacherTool.toolboxCategories && } + + ) : null; +}; diff --git a/teachertool/src/components/CatalogModal.tsx b/teachertool/src/components/CatalogModal.tsx index 10503b0f4249..c755d1e5f8eb 100644 --- a/teachertool/src/components/CatalogModal.tsx +++ b/teachertool/src/components/CatalogModal.tsx @@ -7,6 +7,7 @@ import { addCriteriaToRubric } from "../transforms/addCriteriaToRubric"; import { CatalogCriteria } from "../types/criteria"; import { getSelectableCatalogCriteria } from "../state/helpers"; import { ReadOnlyCriteriaDisplay } from "./ReadonlyCriteriaDisplay"; +import { Strings } from "../constants"; import css from "./styling/CatalogModal.module.scss"; interface CatalogModalProps {} @@ -47,18 +48,18 @@ export const CatalogModal: React.FC = ({}) => { const modalActions = [ { - label: lf("Cancel"), + label: Strings.Cancel, className: "secondary", onClick: closeModal, }, { - label: lf("Add Selected"), + label: Strings.AddSelected, className: "primary", onClick: handleAddSelectedClicked, }, ]; - return teacherTool.modal === "catalog-display" ? ( + return teacherTool.modalOptions?.modal === "catalog-display" ? ( = () => { const { state: teacherTool } = useContext(AppStateContext); + const [confirmationModalOptions, setConfirmationModalOptions] = useState( + undefined + ); + + useEffect(() => { + if (teacherTool.modalOptions?.modal === "confirmation") { + setConfirmationModalOptions(teacherTool.modalOptions as ConfirmationModalOptions); + } else { + setConfirmationModalOptions(undefined); + } + }, [teacherTool.modalOptions]); function handleCancel() { hideModal(); - teacherTool.confirmationOptions?.onCancel?.(); + confirmationModalOptions?.onCancel?.(); } function handleContinue() { hideModal(); - teacherTool.confirmationOptions?.onContinue?.(); + confirmationModalOptions?.onContinue?.(); } const actions = [ { - label: lf("Cancel"), + label: Strings.Cancel, className: "secondary", onClick: handleCancel, }, { - label: lf("Continue"), + label: Strings.Continue, className: "primary", onClick: handleContinue, }, ]; - return teacherTool.modal === "confirmation" && teacherTool.confirmationOptions ? ( - - {teacherTool.confirmationOptions.message} + return confirmationModalOptions ? ( + + {confirmationModalOptions.message} ) : null; }; diff --git a/teachertool/src/components/CriteriaInstanceDisplay.tsx b/teachertool/src/components/CriteriaInstanceDisplay.tsx index ae0b7c7873ac..b26da1a253af 100644 --- a/teachertool/src/components/CriteriaInstanceDisplay.tsx +++ b/teachertool/src/components/CriteriaInstanceDisplay.tsx @@ -3,11 +3,16 @@ import { CriteriaInstance, CriteriaParameterValue } from "../types/criteria"; import { logDebug } from "../services/loggingService"; import { setParameterValue } from "../transforms/setParameterValue"; import { classList } from "react-common/components/util"; -import { splitCriteriaTemplate } from "../utils"; +import { getReadableBlockString, splitCriteriaTemplate } from "../utils"; // eslint-disable-next-line import/no-internal-modules import css from "./styling/CriteriaInstanceDisplay.module.scss"; -import { useState } from "react"; +import { useContext, useMemo, useState } from "react"; import { Input } from "react-common/components/controls/Input"; +import { Button } from "react-common/components/controls/Button"; +import { AppStateContext } from "../state/appStateContext"; +import { Strings } from "../constants"; +import { showModal } from "../transforms/showModal"; +import { BlockPickerOptions } from "../types/modalOptions"; interface InlineInputSegmentProps { initialValue: string; @@ -30,7 +35,7 @@ const InlineInputSegment: React.FC = ({ setParameterValue(instance.instanceId, param.name, newValue); } - const tooltip = isEmpty ? lf("{0}: value required", param.name) : param.name; + const tooltip = isEmpty ? `"${param.name}: ${Strings.ValueRequired}` : param.name; return (
= ({ ); }; +interface BlockInputSegmentProps { + instance: CriteriaInstance; + param: CriteriaParameterValue; +} +interface BlockData { + category: pxt.editor.ToolboxCategoryDefinition; + block: pxt.editor.ToolboxBlockDefinition; +} +const BlockInputSegment: React.FC = ({ instance, param }) => { + const { state: teacherTool } = useContext(AppStateContext); + function handleClick() { + showModal({ + modal: "block-picker", + criteriaInstanceId: instance.instanceId, + paramName: param.name, + } as BlockPickerOptions); + } + + const blockData = useMemo(() => { + if (!param.value || !teacherTool.toolboxCategories) { + return undefined; + } + + // Scan all categories and find the block with the matching id + for (const category of Object.values(teacherTool.toolboxCategories)) { + const block = category.blocks?.find(b => b.blockId === param.value); + if (block) { + return { category, block }; + } + } + return undefined; + }, [param.value, teacherTool.toolboxCategories]); + + const style = blockData ? { backgroundColor: blockData.category.color, color: "white" } : undefined; + return ( +