diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c74d395cb..baa4d6c17 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,7 @@ jobs: STAGING_CLOUDFRONT_DISTRIBUTION_ID: E2ELTBTA2OFPY2 REVIEW_CLOUDFRONT_DISTRIBUTION_ID: E3267W09ZJHQG9 REACT_APP_FOUNDATION_BUILD: ${{ github.repository_owner == 'microbit-foundation' }} + CI: false steps: # Note: This workflow disables deployment steps and micro:bit branding installation on forks. diff --git a/package-lock.json b/package-lock.json index 225f778d7..e629fd6cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "lzma": "^2.3.2", "marked": "^4.0.15", "mobile-drag-drop": "^2.3.0-rc.2", + "perlin-noise": "^0.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-icons": "^4.8.0", @@ -15851,6 +15852,11 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "dev": true }, + "node_modules/perlin-noise": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/perlin-noise/-/perlin-noise-0.0.1.tgz", + "integrity": "sha512-33wNN1FN7jZPF0ISkSF8BLag71wjBWzrpzd/m00iFsxtIhKeZ8VaKBQtzPX3TBegK9GYPXwGzR3oJp9v2T7QuQ==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", diff --git a/package.json b/package.json index b7e3739bb..591c857da 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lzma": "^2.3.2", "marked": "^4.0.15", "mobile-drag-drop": "^2.3.0-rc.2", + "perlin-noise": "^0.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-icons": "^4.8.0", diff --git a/src/editor/codemirror/CodeMirror.tsx b/src/editor/codemirror/CodeMirror.tsx index 31d155dd1..15b4c4c5e 100644 --- a/src/editor/codemirror/CodeMirror.tsx +++ b/src/editor/codemirror/CodeMirror.tsx @@ -12,7 +12,15 @@ import { lineNumbers, ViewUpdate, } from "@codemirror/view"; -import { useEffect, useMemo, useRef } from "react"; +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; import { useIntl } from "react-intl"; import { lineNumFromUint8Array } from "../../common/text-util"; import useActionFeedback from "../../common/use-action-feedback"; @@ -40,6 +48,7 @@ import { languageServer } from "./language-server/view"; import { lintGutter } from "./lint/lint"; import { codeStructure } from "./structure-highlighting"; import themeExtensions from "./themeExtensions"; +import { reactWidgetExtension } from "./helper-widgets/reactWidgetExtension"; interface CodeMirrorProps { className?: string; @@ -52,6 +61,20 @@ interface CodeMirrorProps { parameterHelpOption: ParameterHelpOption; } +interface PortalContent { + dom: HTMLElement; + content: ReactNode; +} + +/** + * Creates a React portal for a CodeMirror dom element (e.g. for a widget) and + * returns a clean-up function to call when the widget is destroyed. + */ +export type PortalFactory = ( + dom: HTMLElement, + content: ReactNode +) => () => void; + /** * A React component for CodeMirror 6. * @@ -100,6 +123,29 @@ const CodeMirror = ({ [fontSize, codeStructureOption, parameterHelpOption] ); + const [portals, setPortals] = useState<PortalContent[]>([]); + const portalFactory: PortalFactory = useCallback((dom, content) => { + setPortals((portals) => { + let found = false; + let updated = portals.map((p) => { + if (p.dom === dom) { + found = true; + return { + dom, + content, + }; + } + return p; + }); + if (!found) { + updated = [...portals, { dom, content }]; + } + return updated; + }); + + return () => setPortals((portals) => portals.filter((p) => p.dom !== dom)); + }, []); + useEffect(() => { const initializing = !viewRef.current; if (initializing) { @@ -113,11 +159,13 @@ const CodeMirror = ({ logPastedLineCount(logging, update); } }); + const state = EditorState.create({ doc: defaultValue, extensions: [ notify, editorConfig, + reactWidgetExtension(portalFactory), // Extension requires external state. dndSupport({ sessionSettings, setSessionSettings }), // Extensions only relevant for editing: @@ -172,6 +220,9 @@ const CodeMirror = ({ parameterHelpOption, uri, apiReferenceMap, + portals, + portalFactory, + setPortals, ]); useEffect(() => { // Do this separately as we don't want to destroy the view whenever options needed for initialization change. @@ -260,13 +311,16 @@ const CodeMirror = ({ }, [routerState, setRouterState]); return ( - <section - data-testid="editor" - aria-label={intl.formatMessage({ id: "code-editor" })} - style={{ height: "100%" }} - className={className} - ref={elementRef} - /> + <> + <section + data-testid="editor" + aria-label={intl.formatMessage({ id: "code-editor" })} + style={{ height: "100%" }} + className={className} + ref={elementRef} + /> + {portals.map(({ content, dom }) => createPortal(content, dom))} + </> ); }; diff --git a/src/editor/codemirror/helper-widgets/openWidgets.tsx b/src/editor/codemirror/helper-widgets/openWidgets.tsx new file mode 100644 index 000000000..fcb392471 --- /dev/null +++ b/src/editor/codemirror/helper-widgets/openWidgets.tsx @@ -0,0 +1,82 @@ +import { Button, Center, HStack } from "@chakra-ui/react"; +import { StateEffect } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { useCallback } from "react"; + +export const openWidgetEffect = StateEffect.define<number>(); +export const OpenReactComponent = ({ + loc, + view, +}: { + loc: number; + view: EditorView; +}) => { + const handleClick = useCallback(() => { + view.dispatch({ + effects: [openWidgetEffect.of(loc)], + }); + }, [loc, view]); + return ( + <Button onClick={handleClick} size="xs">Open</Button> + ); +}; + + +function createSoundWavePath(): string { + let pathData = 'M0,12'; + + const totalPoints = 18; + + + const stepSize = 24 / totalPoints; + + for (let i = 0; i < totalPoints; i++) { + const x = i * stepSize; + const angle = (x / totalPoints) * 3 * Math.PI; + + const heightVariation = Math.cos(angle) * 6; + const y1 = 12 + heightVariation; + const y2 = 12 - heightVariation; + + pathData += ` M${x},${y1} L${x},${y2}`; + } + + return pathData; +} + +export const OpenSoundComponent = ({ + loc, + view, +}: { + loc: number; + view: EditorView; +}) => { + + + + const handleClick = useCallback(() => { + view.dispatch({ + effects: [openWidgetEffect.of(loc)], + }); + }, [loc, view]); + + const soundWavePath = createSoundWavePath(); + + return ( + <Button onClick={handleClick} size="sm" height="25px" marginBottom="3px" marginLeft="5px" style={{ padding: '3px 3px' }}> + <svg + width="20" + height="18" + viewBox="0 0 24 24" + fill="none" + > + <path + d={soundWavePath} + stroke="green" + strokeWidth="1" + fill="none" + /> + </svg> + </Button> + ); +}; \ No newline at end of file diff --git a/src/editor/codemirror/helper-widgets/reactWidgetExtension.tsx b/src/editor/codemirror/helper-widgets/reactWidgetExtension.tsx new file mode 100644 index 000000000..2480b27e0 --- /dev/null +++ b/src/editor/codemirror/helper-widgets/reactWidgetExtension.tsx @@ -0,0 +1,158 @@ +import { EditorState, Extension, StateField } from "@codemirror/state"; +import { + Decoration, + DecorationSet, + EditorView, + WidgetType, +} from "@codemirror/view"; +import { syntaxTree } from "@codemirror/language"; +import { PortalFactory } from "../CodeMirror"; +import React from "react"; +import { createWidget } from "./widgetArgParser"; +import { openWidgetEffect } from "./openWidgets"; +import { ValidateComponentArgs } from "./widgetArgParser"; + +export interface WidgetProps { + // Note: always an array, can be singleton + args: any[]; + // Ranges of where to insert each argument + ranges: { from: number; to: number }[]; + // Type of each argument, can be checked in widget to determine if it is editable + types: string[]; + // Where to insert the changed values + from: number; + to: number; +} + +/** + * This widget will have its contents rendered by the code in CodeMirror.tsx + * which it communicates with via the portal factory. + */ +class Widget extends WidgetType { + private portalCleanup: (() => void) | undefined; + + constructor( + private component: React.ComponentType<any>, + private props: WidgetProps, + private open: React.ComponentType<any>, + private inline: boolean, + private createPortal: PortalFactory + ) { + super(); + } + + eq(other: WidgetType): boolean { + const them = other as Widget; + let args1 = this.props.args; + let args2 = them.props.args; + let eqArgs = + args1.length === args2.length && + args1.every((element, index) => element === args2[index]); + + return ( + them.component === this.component && + them.props.to === this.props.to && + eqArgs && + them.inline === this.inline + ); + } + + updateDOM(dom: HTMLElement, view: EditorView): boolean { + dom.style.display = this.inline ? "inline-block" : "unset"; + this.portalCleanup = this.createPortal(dom, this.toComponent(view)); + return true; + } + + private toComponent(view: EditorView) { + if (this.inline) { + return <this.open loc={this.props.to} view={view} />; + } + return <this.component props={this.props} view={view} />; + } + + toDOM(view: EditorView) { + const dom = document.createElement("div"); + + if ( + this.inline && + !ValidateComponentArgs(this.component, this.props.args, this.props.types) + ) { + return dom; + } + dom.style.display = this.inline ? "inline-block" : "unset"; + this.portalCleanup = this.createPortal(dom, this.toComponent(view)); + return dom; + } + + destroy(dom: HTMLElement): void { + if (this.portalCleanup) { + this.portalCleanup(); + } + } + + ignoreEvent() { + return true; + } +} + +// Iterates through the syntax tree, finding occurences of SoundEffect ArgList, and places widget there +export const reactWidgetExtension = ( + createPortal: PortalFactory +): Extension => { + const decorate = (state: EditorState) => { + let widgets: any[] = []; + + syntaxTree(state).iterate({ + enter: (ref) => { + // Found an ArgList, parent will be a CallExpression + if (ref.name === "ArgList" && ref.node.parent) { + // Match CallExpression name to our widgets + let name = state.doc.sliceString(ref.node.parent.from, ref.from); + let widget = createWidget(name, state, ref.node); + if (widget) { + let deco = Decoration.widget({ + widget: new Widget( + widget.comp, + widget.props, + widget.open, + widget.props.to !== openWidgetLoc, + createPortal + ), + side: 1, + }); + widgets.push(deco.range(ref.to)); + } + } + }, + }); + + return Decoration.set(widgets); + }; + + let openWidgetLoc = -1; + const stateField = StateField.define<DecorationSet>({ + create(state) { + return decorate(state); + }, + update(widgets, transaction) { + // check for open/close button pressed + for (let effect of transaction.effects) { + if (effect.is(openWidgetEffect)) { + openWidgetLoc = effect.value; + return decorate(transaction.state); + } + } + // else check for other doc edits + if (transaction.docChanged) { + // update openWidgetLoc if changes moves it + openWidgetLoc = transaction.changes.mapPos(openWidgetLoc); + return decorate(transaction.state); + } + return widgets.map(transaction.changes); + }, + provide(field) { + return EditorView.decorations.from(field); + }, + }); + return [stateField]; +}; diff --git a/src/editor/codemirror/helper-widgets/setPixelWidget.tsx b/src/editor/codemirror/helper-widgets/setPixelWidget.tsx new file mode 100644 index 000000000..f61a93202 --- /dev/null +++ b/src/editor/codemirror/helper-widgets/setPixelWidget.tsx @@ -0,0 +1,211 @@ +import React from "react"; +import { + Box, + Button, + Slider, + SliderTrack, + SliderFilledTrack, + SliderThumb, +} from "@chakra-ui/react"; +import { EditorView } from "@codemirror/view"; +import { WidgetProps } from "./reactWidgetExtension"; +import { openWidgetEffect } from "./openWidgets"; + +interface Pixel { + x: number; + y: number; + brightness: number; +} + +interface MicrobitSinglePixelGridProps { + onPixelClick: (pixel: Pixel) => void; + initialPixel: Pixel | null; + onCloseClick: () => void; +} +const MicrobitSinglePixelGrid: React.FC<MicrobitSinglePixelGridProps> = ({ + onPixelClick, + initialPixel, + onCloseClick, +}) => { + const { x, y, brightness } = initialPixel ?? { x: 0, y: 0, brightness: 9 }; + + const handlePixelClick = (x: number, y: number) => { + const newPixel: Pixel = { x, y, brightness }; + onPixelClick(newPixel); + }; + + const handleSliderChange = (value: number) => { + const updatedPixel: Pixel = { x, y, brightness: value }; + onPixelClick(updatedPixel); + }; + + const calculateColor = () => { + const red = brightness * 25.5; + return `rgb(${red}, 0, 0)`; + }; + + return ( + <div> + <Box ml="10px" style={{ marginRight: "4px" }}> + <Button size="xs" onClick={onCloseClick} bg="white"> + X + </Button> + </Box> + <Box // TODO: copy to allow other widgets to access bg and close + display="flex" + flexDirection="row" + justifyContent="flex-start" + width="250px" + background="snon" + border='1px solid lightgray' + boxShadow='0 0 10px 5px rgba(173, 216, 230, 0.7)' + > + <Box> + <Box + bg="white" + p="10px" + borderRadius="0px" + border="1px solid black" + style={{ marginLeft: "15px", marginTop: "15px", marginBottom: "15px" }} + > + {[...Array(5)].map((_, gridY) => ( + <Box key={gridY} display="flex"> + {[...Array(5)].map((_, gridX) => ( + <Box key={gridX} display="flex" mr="0px"> + <Button + height="32px" + width="30px" + p={0} + borderRadius={0} + bgColor={ + gridX === x && gridY === y + ? `rgba(255, 0, 0, ${brightness / 9})` + : "rgba(255, 255, 255, 0)" + } + border={ + gridX === x && gridY === y + ? "2px solid black" + : "1px solid black" + } + _hover={{ + bgColor: + gridX === x && gridY === y + ? `rgba(255, 0, 0, ${brightness / 9})` + : "rgba(255, 255, 255, 0.5)", + }} + onClick={() => handlePixelClick(gridX, gridY)} + /> + </Box> + ))} + </Box> + ))} + </Box> + </Box> + <Box ml="10px" style={{ marginTop: "15px" }}> + <Slider + aria-label="brightness" + defaultValue={brightness} + min={0} + max={9} + step={1} + height="182px" + orientation="vertical" + _focus={{ boxShadow: "none" }} + _active={{ bgColor: "transparent" }} + onChange={handleSliderChange} + > + <SliderTrack> + <SliderFilledTrack bg={calculateColor()} /> + </SliderTrack> + <SliderThumb /> + </Slider> + </Box> + </Box> + </div> + ); +}; + +export const MicrobitSinglePixelComponent = ({ + props, + view, +}: { + props: WidgetProps; + view: EditorView; +}) => { + let args = props.args; + let ranges = props.ranges; + let types = props.types; + let from = props.from; + let to = props.to; + + const selectedPixel = parseArgs(args, types); + + const handleCloseClick = () => { + view.dispatch({ + effects: [openWidgetEffect.of(-1)], + }); + }; + + const handleSelectPixel = (pixel: Pixel) => { + const { x, y, brightness } = pixel; + if (ranges.length === 3) { + view.dispatch({ + changes: [ + { + from: ranges[0].from, + to: ranges[0].to, + insert: `${x}`, + }, + { + from: ranges[1].from, + to: ranges[1].to, + insert: `${y}`, + }, + { + from: ranges[2].from, + to: ranges[2].to, + insert: `${brightness}`, + }, + ], + effects: [openWidgetEffect.of(to)], + }); + } else { + let vals = `${x},${y},${brightness}`; + view.dispatch({ + changes: [ + { + from: from + 1, + to: to - 1, + insert: vals, + }, + ], + effects: [openWidgetEffect.of(vals.length + from + 2)], + }); + } + }; + + return ( + <MicrobitSinglePixelGrid + onPixelClick={handleSelectPixel} + initialPixel={selectedPixel} + onCloseClick={handleCloseClick} + /> + ); +}; + +const parseArgs = (args: string[], types: string[]): Pixel => { + const parsedArgs: number[] = []; + for (let i = 0; i < args.length; i++) { + let arg = args[i]; + if (types[i] === "Number") { + parsedArgs.push(parseInt(arg)); + } else { + parsedArgs.push(0); + } + } + // Replace missing arguments with 0 + while (parsedArgs.length < 3) { + parsedArgs.push(0); + } + return { x: parsedArgs[0], y: parsedArgs[1], brightness: parsedArgs[2] }; +}; diff --git a/src/editor/codemirror/helper-widgets/showImageWidget.tsx b/src/editor/codemirror/helper-widgets/showImageWidget.tsx new file mode 100644 index 000000000..484747ebb --- /dev/null +++ b/src/editor/codemirror/helper-widgets/showImageWidget.tsx @@ -0,0 +1,222 @@ +import { + Box, + Button, + Slider, + SliderTrack, + SliderFilledTrack, + SliderThumb, +} from "@chakra-ui/react"; +import React, { useState } from "react"; +import { WidgetProps } from "./reactWidgetExtension"; +import { EditorView } from "@codemirror/view"; +import { openWidgetEffect } from "./openWidgets"; + +interface MultiMicrobitGridProps { + selectedPixels: number[][]; + onCloseClick: () => void; + onPixelChange: (x: number, y: number, brightness: number) => void; +} + +const MicrobitMultiplePixelsGrid: React.FC<MultiMicrobitGridProps> = ({ + selectedPixels, + onCloseClick, + onPixelChange, +}) => { + const [currentBrightness, setCurrentBrightness] = useState<number>(5); + const [selectedPixel, setSelectedPixel] = useState<{ + x: number; + y: number; + } | null>(null); + + const handlePixelClick = (x: number, y: number) => { + setSelectedPixel({ x, y }); + onPixelChange(x, y, currentBrightness); + }; + + const handleBrightnessChange = (brightness: number) => { + setCurrentBrightness(brightness); + if (selectedPixel) { + onPixelChange(selectedPixel.x, selectedPixel.y, brightness); + } + }; + + const calculateColor = () => { + const red = currentBrightness * 25.5; + return `rgb(${red}, 0, 0)`; + }; + + return ( + <Box + display="flex" + flexDirection="row" + justifyContent="flex-start" + bg="lightgray" + > + <Box ml="10px" style={{ marginRight: "4px" }}> + <Button size="xs" onClick={onCloseClick} bg="white"> + X + </Button> + </Box> + <Box> + <Box + bg="black" + p="10px" + borderRadius="5px" + style={{ marginTop: "15px" }} + > + {selectedPixels.map((row, y) => ( + <Box key={y} display="flex"> + {row.map((brightness, x) => ( + <Box key={x} display="flex" mr="2px"> + <Button + size="xs" + h="15px" + w="15px" + p={0} + borderRadius={0} + border={ + selectedPixel?.x === x && selectedPixel.y === y + ? "2px solid white" + : "0.5px solid white" + } + bgColor={`rgba(255, 0, 0, ${brightness / 9})`} + _hover={{ + bgColor: + brightness > 0 + ? `rgba(255, 100, 100, ${selectedPixels[y][x] / 9})` + : "rgba(255, 255, 255, 0.5)", + }} + onClick={() => handlePixelClick(x, y)} + /> + </Box> + ))} + </Box> + ))} + </Box> + </Box> + <Box ml="10px" style={{ marginTop: "15px" }}> + <Slider + aria-label="brightness" + value={currentBrightness} + min={0} + max={9} + step={1} + orientation="vertical" + _focus={{ boxShadow: "none" }} + _active={{ bgColor: "transparent" }} + onChange={(value) => handleBrightnessChange(value)} + > + <SliderTrack> + <SliderFilledTrack bg={calculateColor()} /> + </SliderTrack> + <SliderThumb /> + </Slider> + </Box> + </Box> + ); +}; + +export const MicrobitMultiplePixelComponent = ({ + props, + view, +}: { + props: WidgetProps; + view: EditorView; +}) => { + let args = props.args; + let ranges = props.ranges; + let types = props.types; + let from = props.from; + let to = props.to; + + const initialSelectedPixels = parseArgs(args); + const [selectedPixels, setSelectedPixels] = useState<number[][]>( + initialSelectedPixels + ); + + const handlePixelChange = (x: number, y: number, brightness: number) => { + const updatedPixels = [...selectedPixels]; + updatedPixels[y][x] = brightness; + setSelectedPixels(updatedPixels); + updateView(); + }; + + const updateView = () => { + let insertion = pixelsToString(selectedPixels); + console.log(insertion); + if (ranges.length === 1) { + view.dispatch({ + changes: { + from: ranges[0].from, + to: ranges[0].to, + insert: insertion, + }, + effects: [openWidgetEffect.of(insertion.length + from + 2)], + }); + } else { + view.dispatch({ + changes: [ + { + from: from + 1, + to: to - 1, + insert: insertion, + }, + ], + effects: [openWidgetEffect.of(insertion.length + from + 2)], + }); + } + }; + + const handleCloseClick = () => { + view.dispatch({ + effects: [openWidgetEffect.of(-1)], + }); + }; + + return ( + <MicrobitMultiplePixelsGrid + selectedPixels={selectedPixels} + onPixelChange={handlePixelChange} + onCloseClick={handleCloseClick} + /> + ); +}; + +const parseArgs = (args: string[]): number[][] => { + const defaultPixels = Array.from({ length: 5 }, () => Array(5).fill(0)); + // If args is empty, return a 5x5 array filled with zeros + if (args.length === 0) { + return defaultPixels; + } + if (args.length !== 1) { + return defaultPixels; + } + const argString = args[0].replace(/"/g, ""); + const rows = argString.split(":"); + if (rows.length !== 5) { + return defaultPixels; + } + const numbers: number[][] = []; + for (let row of rows) { + row = row.trim(); + if (!/^\d{5}$/.test(row)) { + return defaultPixels; + } + const rowNumbers = row.split("").map(Number); + numbers.push(rowNumbers); + } + return numbers; +}; + +function pixelsToString(pixels: number[][]): string { + let outputString = '"'; + for (let y = 0; y < 5; y++) { + for (let x = 0; x < 5; x++) { + outputString += pixels[y][x].toString(); + } + outputString += ":"; + } + outputString = outputString.slice(0, -1); + outputString += '"'; + return outputString; +} diff --git a/src/editor/codemirror/helper-widgets/soundWidget.tsx b/src/editor/codemirror/helper-widgets/soundWidget.tsx new file mode 100644 index 000000000..f999f19e3 --- /dev/null +++ b/src/editor/codemirror/helper-widgets/soundWidget.tsx @@ -0,0 +1,556 @@ +import { Box, Button, HStack } from "@chakra-ui/react"; +import { EditorView } from "@codemirror/view"; +import React, { useState } from "react"; +import { openWidgetEffect } from "./openWidgets"; +import { WidgetProps } from "./reactWidgetExtension"; + +type FixedLengthArray = [ + number, + number, + number, + number, + number, + string, + string +]; + +interface SliderProps { + min: number; + max: number; + step: number; + value: number; + onChange: (value: number) => void; + sliderStyle?: React.CSSProperties; + label: string; + vertical: boolean; + colour: string; +} + +const startVolProps: Omit<SliderProps, "value" | "onChange"> = { + min: 0, + max: 255, + step: 1, + sliderStyle: { + width: "100%", // Adjust the width of the slider + height: "100px", // Adjust the height of the slider + backgroundColor: "lightgray", // Change the background color of the slider + borderRadius: "10px", // Apply rounded corners to the slider track + border: "none", // Remove the border of the slider track + outline: "none", // Remove the outline when focused + }, + label: "Start Vol", + vertical: true, + colour: "red", +}; + +const endFrequencySliderProps: Omit<SliderProps, "value" | "onChange"> = { + min: 0, + max: 9999, + step: 1, + sliderStyle: { + width: "100%", + height: "100px", + backgroundColor: "lightgray", + borderRadius: "10px", + border: "none", + outline: "none", + }, + label: "End Freq", + vertical: true, + colour: "green", +}; + +const startFrequencySliderProps: Omit<SliderProps, "value" | "onChange"> = { + min: 0, + max: 9999, + step: 1, + sliderStyle: { + width: "200%", // Adjust the width of the slider + height: "100px", // Adjust the height of the slider + backgroundColor: "lightgray", // Change the background color of the slider + borderRadius: "10px", // Apply rounded corners to the slider track + border: "none", // Remove the border of the slider track + outline: "none", // Remove the outline when focused + }, + label: "Start Freq", + vertical: true, + colour: "blue", +}; + +const endVolProps: Omit<SliderProps, "value" | "onChange"> = { + min: 0, + max: 255, + step: 1, + sliderStyle: { + width: "200%", // Adjust the width of the slider + height: "100px", // Adjust the height of the slider + backgroundColor: "lightgray", // Change the background color of the slider + borderRadius: "10px", // Apply rounded corners to the slider track + border: "none", // Remove the border of the slider track + outline: "none", // Remove the outline when focused + }, + label: "End Vol", + vertical: true, + colour: "black", +}; + +const Slider: React.FC<SliderProps & { vertical?: boolean; colour: string }> = + ({ + min, + max, + step, + value, + onChange, + sliderStyle, + label, + vertical, + colour, + }) => { + const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const newValue = parseFloat(event.target.value); + onChange(newValue); + }; + + return ( + <div + style={{ + position: "relative", + height: "80px", + width: "45px", + display: "flex", + flexDirection: "column", + alignItems: "center", + }} + > + <input + type="range" + min={min} + max={max} + step={step} + value={value} + onChange={handleChange} + style={{ + position: "absolute", + width: "115px", // Width of the slider + height: "40px", // Height of the slider + transform: "rotate(-90deg)", // Rotate the slider to vertical orientation + accentColor: colour, + bottom: "0%", + }} + /> + <div + style={{ + position: "absolute", + left: "calc(100% - 15px)", // Position the label to the right of the slider + bottom: `${((value - min) / (max - min)) * 100}%`, // Calculate the position based on value + transform: "translateY(50%)", // Center the label vertically with the thumb + fontSize: "13px", // Font size of the label + }} + > + {value} + </div> + <div + style={{ marginTop: "120px", textAlign: "center", fontSize: "11px" }} + > + <b>{label}</b> + </div> + </div> + ); + }; + +const TripleSliderWidget: React.FC<{ + freqStartProps: Omit<SliderProps, "value" | "onChange">; + freqEndProps: Omit<SliderProps, "value" | "onChange">; + volStartProps: Omit<SliderProps, "value" | "onChange">; + volEndprops: Omit<SliderProps, "value" | "onChange">; + props: WidgetProps; + view: EditorView; +}> = ({ + freqStartProps, + freqEndProps, + volStartProps, + volEndprops, + props, + view, +}) => { + let args = props.args; + let ranges = props.ranges; + let types = props.types; + let from = props.from; + let to = props.to; + + //parse args + + let argsToBeUsed: FixedLengthArray = [200, 500, 2000, 50, 50, "sine", "None"]; // default args + let count = 0; + for (let i = 2; i < args.length; i += 3) { + //Update default args with user args where they exist + argsToBeUsed[count] = args[i]; + let arg = args[i]; + console.log("arg: ", arg); + count += 1; + } + + console.log("args", argsToBeUsed); + + const startFreq = Math.min(argsToBeUsed[0], 9999); + const endFreq = Math.min(argsToBeUsed[1], 9999); + const startVol = Math.min(argsToBeUsed[3], 255); + const endVol = Math.min(argsToBeUsed[4], 9999); + + const [waveType, setWaveType] = useState("sine"); + + const waveformOptions = ["None", "Vibrato", "Tremolo", "Warble"]; + const textBoxValue = Number(argsToBeUsed[2]); + + const updateView = (change: Partial<ParsedArgs>) => { + let insertion = statesToString({ + startFreq, + endFreq, + duration: textBoxValue, + startVol, + endVol, + ...change, + }); + console.log(insertion); + if (ranges.length === 1) { + view.dispatch({ + changes: { + from: ranges[0].from, + to: ranges[0].to, + insert: insertion, + }, + effects: [openWidgetEffect.of(insertion.length + from + 2)], + }); + } else { + view.dispatch({ + changes: [ + { + from: from + 1, + to: to - 1, + insert: insertion, + }, + ], + effects: [openWidgetEffect.of(insertion.length + from + 2)], + }); + } + }; + + const handleTextInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + //const newValue = e.target.value; + //setTextBoxValue(Number(newValue)); + updateView({}); + }; + + const handleSlider1Change = (value: number) => { + //freqStartProps.onChange(value); + //setInitialFrequency(value); + updateView({ + startFreq: value, + }); + }; + + const handleSlider2Change = (value: number) => { + //freqEndProps.onChange(value); + //setEndFrequency(value); // + updateView({}); + }; + + const handleSlider3Change = (value: number) => { + //freqStartProps.onChange(value); + //setStartAmplitude(value); + updateView({}); + }; + + const handleSlider4Change = (value: number) => { + //freqStartProps.onChange(value); + //setEndAmplitude(value); + updateView({}); + }; + + const handleWaveTypeChange = (value: string) => { + setWaveType(value); + }; + + const generateWavePath = () => { + const waveLength = 400; // Width of the box + const pathData = []; + + const frequencyDifference = endFreq - startFreq; + const amplitudeDifference = endVol - startVol; + + // Loop through the wave's width to generate the path + for (let x = 0; x <= waveLength; x++) { + const currentFrequency = + (startFreq + (frequencyDifference * x) / waveLength) / 100; + const currentAmplitude = + (startVol + (amplitudeDifference * x) / waveLength) / 2.2; + const period = waveLength / currentFrequency; + + // Calculate the y-coordinate based on the current frequency and amplitude + let y = 0; + switch (waveType) { + case "sine": + y = 65 + currentAmplitude * Math.sin((x / period) * 2 * Math.PI); + break; + case "square": + y = + x % period < period / 2 + ? 65 + currentAmplitude + : 65 - currentAmplitude; + break; + case "sawtooth": + y = + 65 + + currentAmplitude - + ((x % period) / period) * (2 * currentAmplitude); + break; + case "triangle": + const tPeriod = x % period; + y = + tPeriod < period / 2 + ? 65 + ((2 * currentAmplitude) / period) * tPeriod + : 65 - ((2 * currentAmplitude) / period) * (tPeriod - period / 2); + break; + case "noisy": + // Generate noisy wave based on sine wave and random noise + const baseWave = + 65 + currentAmplitude * Math.sin((x / period) * 2 * Math.PI); + const randomNoise = Math.random() * 2 - 1; + y = baseWave + randomNoise * (currentAmplitude * 0.3); + break; + } + + // Add the point to the path data + pathData.push(`${x},${y}`); + } + + // Join the path data points to create the path + return `M${pathData.join(" ")}`; + }; + + return ( + <div> + <div + style={{ + display: "flex", + justifyContent: "flex-start", + backgroundColor: "snow", + width: "575px", + height: "150px", + border: "1px solid lightgray", + boxShadow: "0 0 10px 5px rgba(173, 216, 230, 0.7)", + zIndex: 10, + }} + > + {/* Vertical Slider 1 */} + <div + style={{ + marginLeft: "6px", + marginRight: "20px", + height: "100px", + marginTop: "9px", + }} + > + <Slider + {...freqStartProps} + value={startFreq} + onChange={handleSlider1Change} + vertical + /> + </div> + {/* Vertical Slider 2 */} + <div style={{ marginRight: "20px", height: "100px", marginTop: "9px" }}> + <Slider + {...freqEndProps} + // TODO: for this and all the following sliders we need value to come from the parsed args above + // and the handleXXXChange functions need to be updated to pass the relevant change to updateView + value={0} + onChange={handleSlider2Change} + vertical + /> + </div> + {/* Vertical Slider 3 */} + <div style={{ marginRight: "20px", height: "100px", marginTop: "9px" }}> + <Slider + {...volStartProps} + value={0} + onChange={handleSlider3Change} + vertical + /> + </div> + {/* Vertical Slider 4 */} + <div style={{ marginRight: "25px", height: "100px", marginTop: "9px" }}> + <Slider + {...volEndprops} + value={0} + onChange={handleSlider4Change} + vertical + /> + </div> + + <div style={{ marginRight: "10px", height: "100px", fontSize: "12px" }}> + {/* waveform type selection */} + <label + style={{ display: "block", marginBottom: "5px", marginTop: "7px" }} + > + <b>Waveform:</b> + </label> + <select onChange={(e) => handleWaveTypeChange(e.target.value)}> + <option value="sine">Sine</option> + <option value="square">Square</option> + <option value="sawtooth">Sawtooth</option> + <option value="triangle">Triangle</option> + <option value="noisy">Noisy</option> + </select> + + {/* fx type selection */} + + <label + style={{ display: "block", marginBottom: "5px", marginTop: "10px" }} + > + <b>Effects:</b> + </label> + <select onChange={(e) => handleWaveTypeChange(e.target.value)}> + <option value="sine">None</option> + <option value="square">Vibrato</option> + <option value="sawtooth">Tremelo</option> + <option value="triangle">Warble</option> + </select> + + {/* Duration selctor */} + + <label + style={{ display: "block", marginBottom: "5px", marginTop: "10px" }} + > + <b>Duration(ms):</b> + </label> + {/* Input field with associated datalist */} + <input + type="text" + value={textBoxValue} + onChange={handleTextInputChange} // Handle the selected or typed-in value + defaultValue="2000" + style={{ width: "75px" }} + /> + </div> + {/* Waveform box */} + <div + style={{ + width: "200px", + height: "130px", + backgroundColor: "linen", + marginTop: "9px", + marginLeft: "5px", + }} + > + <svg width="100%" height="100%"> + <path d={generateWavePath()} stroke="black" fill="none" /> + <line + x1="0%" // Start of the line + y1="50%" // Vertically center the line + x2="100%" // End of the line + y2="50%" // Keep the line horizontal + stroke="gray" // Line color + strokeWidth="0.5" // Line thickness + /> + </svg> + </div> + </div> + </div> + ); +}; + +export const SoundComponent = ({ + props, + view, +}: { + props: WidgetProps; + view: EditorView; +}) => { + let args = props.args; + let ranges = props.ranges; + let types = props.types; + let from = props.from; + let to = props.to; + + //for future reference add a aclose button + const handleCloseClick = () => { + view.dispatch({ + effects: [openWidgetEffect.of(-1)], + }); + }; + + const updateView = () => { + let insertion = "test"; + console.log(insertion); + if (ranges.length === 1) { + view.dispatch({ + changes: { + from: ranges[0].from, + to: ranges[0].to, + insert: insertion, + }, + effects: [openWidgetEffect.of(insertion.length + from + 2)], + }); + } else { + view.dispatch({ + changes: [ + { + from: from + 1, + to: to - 1, + insert: insertion, + }, + ], + effects: [openWidgetEffect.of(insertion.length + from + 2)], + }); + } + }; + + return ( + <HStack fontFamily="body" spacing={5} py={3} zIndex={10}> + <Box ml="10px" style={{ marginRight: "4px" }}> + <Button size="xs" onClick={handleCloseClick} bg="white"> + X + </Button> + </Box> + <TripleSliderWidget + freqStartProps={startFrequencySliderProps} + freqEndProps={endFrequencySliderProps} + volStartProps={startVolProps} + volEndprops={endVolProps} + props={props} + view={view} + /> + </HStack> + ); +}; + +//(startFreq: number, endFreq: Number, duration: Number, startVol: number, endVol: Number, waveform: string, fx: string) + +interface ParsedArgs { + startFreq: number; + endFreq: number; + duration: number; + startVol: number; + endVol: number; +} + +function statesToString({ + startFreq, + endFreq, + duration, + startVol, + endVol, +}: ParsedArgs): string { + return ( + `\n` + + ` freq_start=${startFreq},\n` + + ` freq_end=${endFreq},\n` + + ` duration=${duration},\n` + + ` vol_start=${startVol},\n` + + ` vol_end=${endVol},\n` + + ` waveform=SoundEffect.FX_WARBLE,\n` + + ` fx=SoundEffect.FX_VIBRATO` + ); +} diff --git a/src/editor/codemirror/helper-widgets/widgetArgParser.tsx b/src/editor/codemirror/helper-widgets/widgetArgParser.tsx new file mode 100644 index 000000000..176cd8b6e --- /dev/null +++ b/src/editor/codemirror/helper-widgets/widgetArgParser.tsx @@ -0,0 +1,142 @@ +import { EditorState } from "@codemirror/state"; +import { SyntaxNode } from "@lezer/common"; +import { WidgetProps } from "./reactWidgetExtension"; +import { MicrobitSinglePixelComponent } from "./setPixelWidget"; +import { MicrobitMultiplePixelComponent } from "./showImageWidget"; +import { SoundComponent } from "./soundWidget"; +import { OpenReactComponent, OpenSoundComponent } from "./openWidgets"; + +export interface CompProps { + comp: React.ComponentType<any>; + props: WidgetProps; + open: React.ComponentType<any> +} + +export function createWidget( + name: string, + state: EditorState, + node: SyntaxNode +): CompProps | null { + let children = getChildNodes(node); + let ranges = getRanges(children); + let args = getArgs(state, ranges); + let types = getTypes(children); + let component: React.ComponentType<any> | null = null; + + switch (name) { + case "display.set_pixel": + component = MicrobitSinglePixelComponent; + break; + case "Image": + component = MicrobitMultiplePixelComponent; + break; + case "audio.SoundEffect": + case "SoundEffect": + component = SoundComponent; + break; + default: + // No widget implemented for this function + // console.log("No widget implemented for this function: " + name); + return null; + } + if (component) { + return { + comp: component, + props: { + args: args, + ranges: ranges, + types: types, + from: node.from, + to: node.to, + }, + open: OpenButtonDesign(component, args, types) + }; + } + return null; +} + +// Gets all child nodes of a CallExpression, no typechecking +function getChildNodes(node: SyntaxNode): SyntaxNode[] { + let child = node.firstChild?.nextSibling; + let children = []; + while (child && child.name !== ")") { + if (child.name !== "," && child.name !== "Comment") children.push(child); + child = child.nextSibling; + } + return children; +} + +// Gets ranges for insertion into arguments +function getRanges(nodes: SyntaxNode[]): { from: number; to: number }[] { + let ranges: { from: number; to: number }[] = []; + nodes.forEach(function (value) { + ranges.push({ from: value.from, to: value.to }); + }); + return ranges; +} + +// Gets arguments as string +function getArgs( + state: EditorState, + ranges: { from: number; to: number }[] +): string[] { + let args: string[] = []; + ranges.forEach(function (value) { + args.push(state.doc.sliceString(value.from, value.to)); + }); + return args; +} + +// Gets types of each arg to determine if it is editable +function getTypes(nodes: SyntaxNode[]): string[] { + let types: string[] = []; + nodes.forEach(function (value) { + types.push(value.name); + }); + return types; +} + +function OpenButtonDesign( + name: React.ComponentType<any>, + args: string[], + types: string[] +): React.ComponentType<any> { + switch (name) { + case MicrobitMultiplePixelComponent: + return OpenReactComponent; + case MicrobitSinglePixelComponent: + return OpenReactComponent; + case SoundComponent: + return OpenSoundComponent; + default: + // shouldnt be called so just null + return OpenReactComponent; + } +} + +export function ValidateComponentArgs( + name: React.ComponentType<any>, + args: string[], + types: string[] +): boolean { + switch (name) { + case MicrobitMultiplePixelComponent: + return true; + case MicrobitSinglePixelComponent: + // If more than 3 arguments, don't open + if (args.length > 3) { + return false; + } + // If some arguments are not numbers or empty, don't open + for (let i = 0; i < args.length; i++) { + if (types[i] !== "Number" && args[i] !== ",") { + return false; + } + } + return true; + case SoundComponent: + return true; + default: + return false; + } +}