diff --git a/extensions/extensions.json b/extensions/extensions.json index d8ed71e33d..f57e0c3c11 100644 --- a/extensions/extensions.json +++ b/extensions/extensions.json @@ -7,6 +7,7 @@ "files", "pointerlock", "cursor", + "legobrainbiker/MoreMouseControls", "runtime-options", "fetch", "text", diff --git a/extensions/legobrainbiker/MoreMouseControls.js b/extensions/legobrainbiker/MoreMouseControls.js new file mode 100644 index 0000000000..47c7f272c4 --- /dev/null +++ b/extensions/legobrainbiker/MoreMouseControls.js @@ -0,0 +1,433 @@ +// Name: More Mouse Controls +// ID: legobrainbikerMoreMouseControls +// Description: Adds more options to detect properties of the mouse, including it's position, clicking, and wheel scrolling. +// By: legobrainbiker +// By: Den4ik-12 +// License: MPL-2.0 + +(function (Scratch) { + "use strict"; + + if (!Scratch.extensions.unsandboxed) { + throw new Error( + 'Extension "More Mouse Controls" must run unsandboxed! Please enable the unsandboxed mode when loading the extension.' + ); + } + + const vm = Scratch.vm; + const runtime = vm.runtime; + const canvas = Scratch.renderer.canvas; + const Cast = Scratch.Cast; + + const EXT_ID = "legobrainbikerMoreMouseControls"; + const EXT_ICON = + "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgdmVyc2lvbj0iMS4xIgogICB3aWR0aD0iMjA1IgogICBoZWlnaHQ9IjIwNSIKICAgdmlld0JveD0iMCAwIDIwNSAyMDUiCiAgIGlkPSJzdmc4IgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxkZWZzCiAgICAgaWQ9ImRlZnM4IiAvPgogIDxwYXRoCiAgICAgZD0ibSAyLjUsMTAyLjUgYyAwLC01NS4yMjg0NyA0NC43NzE1MywtMTAwIDEwMCwtMTAwIDU1LjIyODQ3LDAgMTAwLDQ0Ljc3MTUzIDEwMCwxMDAgMCw1NS4yMjg0NyAtNDQuNzcxNTMsMTAwIC0xMDAsMTAwIC01NS4yMjg0NywwIC0xMDAsLTQ0Ljc3MTUzIC0xMDAsLTEwMCB6IgogICAgIGZpbGw9IiNlNzRjM2MiCiAgICAgc3Ryb2tlPSIjYjAzYTJlIgogICAgIHN0cm9rZS13aWR0aD0iNSIKICAgICBpZD0icGF0aDEiCiAgICAgc3R5bGU9Im1peC1ibGVuZC1tb2RlOm5vcm1hbDtmaWxsOiM5OWJiYmI7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOiM2MzdhN2E7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6MTA7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MSIgLz4KICA8cGF0aAogICAgIGQ9Ik0gOTcuMTY3OTY5LDIzLjc1NzgxMiBDIDczLjI0MDQ5OSwyNi40Mzk0NjMgNTQuNjQ0NTMxLDQ2LjczODYxOSA1NC42NDQ1MzEsNzEuMzgwODU5IGwgLTAuMTk5MjE5LDIxLjMwNDY4OCA0Mi41NTg1OTQsMC4xNjAxNTYgMC4wMzUxNiwtNS41MjczNDQgYyAwLDAgLTEwLjQxNTQxMiwtNC4wMzk0NDggLTEwLjQ0NTMxMiwtMTEuNzM2MzI4IC0wLjAxNTc4LC00LjA2NTI0IC0wLjE3MDg2MiwtMTQuNTgwOTA4IDAuMDg1OTQsLTIzLjUxNzU3OCAwLjI0NywtOC41OTU1MiAxMS41NTQ2ODgsLTEyLjM4ODY3MiAxMS41NTQ2ODgsLTEyLjM4ODY3MiAwLDAgLTAuNTEzMTM2LC0xNS45Nzk5NjkgLTEuMDY2NDA2LC0xNS45MTc5NjkgeiBtIDEwLjY2NDA2MSwwIGMgLTAuNTUzMjgsLTAuMDYyMDEgLTEuMDY2NCwxNS45MTc5NjkgLTEuMDY2NCwxNS45MTc5NjkgLTFlLTUsMCAxMS4zMDc2OCwzLjc5MzE3MiAxMS41NTQ2OCwxMi4zODg2NzIgMC4yNTY4Myw4LjkzNjY3IDAuMTAxNzQsMTkuNDUyMzQ4IDAuMDg1OSwyMy41MTc1NzggLTAuMDI5OSw3LjY5Njg5IC0xMC40NDUzMSwxMS43MzYzMjggLTEwLjQ0NTMxLDExLjczNjMyOCBsIDAuMDM1Miw1LjUyNzM0NCA0Mi41NTg2LC0wLjE2MDE1NiAtMC4xOTkyMiwtMjEuMzA0Njg4IGMgMCwtMjQuNjQyMjYgLTE4LjU5NTk3LC00NC45NDEzODYgLTQyLjUyMzQ0LC00Ny42MjMwNDcgeiBtIC01LjI2NTYyLDI0LjgwMDc4MiBjIC0zLjM4MTQxNCwwIC02LjEyMzA1MSwyLjc0MTkzNyAtNi4xMjMwNTEsNi4xMjMwNDcgdiAxNy45MTYwMTUgYyAwLDMuMzgxMTEgMi43NDIyMzcsNi4xMjEwOTQgNi4xMjMwNTEsNi4xMjEwOTQgMy4zODA4MiwwIDYuMTIxMDksLTIuNzM5OTg0IDYuMTIxMDksLTYuMTIxMDk0IFYgNTQuNjgxNjQxIGMgMCwtMy4zODE0MSAtMi43Mzk3LC02LjEyMzA0NyAtNi4xMjEwOSwtNi4xMjMwNDcgeiBNIDU0LjY0NDUzMSwxMDIuODU5MzggdiAzMC40NjI4OSBjIDAsMjYuNDY2NyAyMS40NTU0MjIsNDcuOTE5OTIgNDcuOTE5OTE5LDQ3LjkxOTkyIDI2LjQ2NDUsMCA0Ny45MjE4OCwtMjEuNDUzMiA0Ny45MjE4OCwtNDcuOTE5OTIgdiAtMzAuNDYyODkgeiIKICAgICBzdHlsZT0iZmlsbDojZmZmZmZmO3N0cm9rZS1taXRlcmxpbWl0OjEwIgogICAgIGlkPSJwYXRoOCIgLz4KPC9zdmc+CjwhLS1yb3RhdGlvbkNlbnRlcjoxMDIuNToxMDIuNS0tPgoK"; + const BLOCK_ICON = + "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgdmVyc2lvbj0iMS4xIgogICB3aWR0aD0iMTE3LjMzMzMzIgogICBoZWlnaHQ9IjExNy4zMzMzMyIKICAgdmlld0JveD0iMCAwIDExNy4zMzMzMyAxMTcuMzMzMzMiCiAgIGlkPSJzdmcxNCIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8ZGVmcwogICAgIGlkPSJkZWZzMTQiIC8+CiAgPHBhdGgKICAgICBkPSJNIDU1LjA4OTUxNSw1LjgzNzQ4MyBDIDM5LjAzNjk1NSw3LjYzNjU2MzEgMjYuNTYyMTcxLDIxLjI1NDYwMSAyNi41NjIxNzEsMzcuNzg2NzAxIGwgLTAuMTM0NzY1LDE0LjI5Mjk2OSAyOC41NTI3MzQsMC4xMDc0MjIgMC4wMjM0NCwtMy43MDcwMzEgYyAwLDAgLTYuOTg3NzEzLC0yLjcxMTI4IC03LjAwNzgxMywtNy44NzUgLTAuMDEwNywtMi43MjczMSAtMC4xMTU2NDksLTkuNzgxODc0IDAuMDU2NjQsLTE1Ljc3NzM0NCAwLjE2NTcxLC01Ljc2NjYxIDcuNzUxOTUzLC04LjMxMDU0NyA3Ljc1MTk1MywtOC4zMTA1NDcgMCwwIC0wLjM0MzY2NCwtMTAuNzIxMjg2OSAtMC43MTQ4NDQsLTEwLjY3OTY4NyB6IG0gNy4xNTQyOTcsMCBjIC0wLjM3MTE5LC0wLjA0MTU5IC0wLjcxNDg0NCwxMC42Nzk2ODcgLTAuNzE0ODQ0LDEwLjY3OTY4NyAwLDAgNy41ODYyNDQsMi41NDM5NDcgNy43NTE5NTMsOC4zMTA1NDcgMC4xNzIyNCw1Ljk5NTQ3IDAuMDY3MjQsMTMuMDUwMDQ0IDAuMDU2NjQsMTUuNzc3MzQ0IC0wLjAyMDA4LDUuMTYzNzIgLTcuMDA3ODEyLDcuODc1IC03LjAwNzgxMiw3Ljg3NSBsIDAuMDIzNDQsMy43MDcwMzEgMjguNTUyNzM0LC0wLjEwNzQyMiAtMC4xMzQ3NjUsLTE0LjI5Mjk2OSBjIDAsLTE2LjUzMjA4OSAtMTIuNDc0Nzg0LC0zMC4xNTAxMzggLTI4LjUyNzM0NCwtMzEuOTQ5MjE4IHogbSAtMy41MzMyMDMsMTYuNjM4NjcyIGMgLTIuMjY4NTMsMCAtNC4xMDc0MjIsMS44MzkwODIgLTQuMTA3NDIyLDQuMTA3NDIxIHYgMTIuMDE5NTMyIGMgMCwyLjI2ODMyIDEuODM5MjkyLDQuMTA3NDIyIDQuMTA3NDIyLDQuMTA3NDIyIDIuMjY4MTQsMCA0LjEwNzQyMiwtMS44MzkwODIgNC4xMDc0MjIsLTQuMTA3NDIyIFYgMjYuNTgzNTc2IGMgMCwtMi4yNjg1MjkgLTEuODM4OTAyLC00LjEwNzQyMSAtNC4xMDc0MjIsLTQuMTA3NDIxIHogTSAyNi41NjIxNzEsNTguOTA1ODQyIHYgMjAuNDM3NSBjIDAsMTcuNzU2MTIgMTQuMzkzODA4LDMyLjE0ODQzOCAzMi4xNDg0MzgsMzIuMTQ4NDM4IDE3Ljc1NDYzLDAgMzIuMTUwMzkxLC0xNC4zOTIzMjggMzIuMTUwMzkxLC0zMi4xNDg0MzggdiAtMjAuNDM3NSB6IgogICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7c3Ryb2tlOiMwMDAwMDA7c3Ryb2tlLXdpZHRoOjEwO3N0cm9rZS1taXRlcmxpbWl0OjEwO3N0cm9rZS1vcGFjaXR5OjAuMTQ5MDI7cGFpbnQtb3JkZXI6c3Ryb2tlIGZpbGwgbWFya2VycyIKICAgICBpZD0icGF0aDE0IiAvPgo8L3N2Zz4KPCEtLXJvdGF0aW9uQ2VudGVyOjU4LjY2NjY2NjY2NjY2NjY2OjU4LjY2NjY2NjY2NjY2NjY2LS0+Cgo="; + + const DELTA_MODES = ["pixels", "lines", "pages"]; + const SCROLL_AXISES = { X_AXIS: 0, Y_AXIS: 1, Z_AXIS: 2 }; + const SCROLL_DIRS = [ + { AXIS: "ax", NEG: "l", POS: "r" }, + { AXIS: "ay", NEG: "u", POS: "d" }, + { AXIS: "az", NEG: "o", POS: "i" }, + ]; + + class MoreMouseControls { + constructor() { + this.mouseX = 0; + this.mouseY = 0; + this.wheelScrolled = false; + this.wheelDelta = Object.assign(Object.create(null), { + x: 0, + y: 0, + zoom: 0, + }); + this.wheelDeltaMode = undefined; + this.scrollDisabled = false; + + runtime.on("PROJECT_LOADED", () => { + const stored = runtime.extensionStorage[EXT_ID]; + this.scrollDisabled = stored.scrollDisabled || false; + runtime.ioDevices.mouse.usesRightClickDown = + stored.contextMenuDisabled || false; + }); + + // An event listener is used instead of Mouse Wheel I/O API, + // as the latter doesn't provide deltaZ and deltaMode. + canvas.addEventListener( + "wheel", + (e) => { + if (this.scrollDisabled) { + e.preventDefault(); + } + + this.wheelScrolled = true; + this.wheelDelta.x += e.deltaX; + this.wheelDelta.y += e.deltaY; + this.wheelDelta.zoom += e.deltaZ; + this.wheelDeltaMode = DELTA_MODES[e.deltaMode]; + + runtime.startHats(`${EXT_ID}_onScroll`); + this._startOnScrollHats(e.deltaX, SCROLL_AXISES.X_AXIS); + this._startOnScrollHats(e.deltaY, SCROLL_AXISES.Y_AXIS); + this._startOnScrollHats(e.deltaZ, SCROLL_AXISES.Z_AXIS); + }, + { passive: true } + ); + runtime.on("AFTER_EXECUTE", () => { + this.wheelScrolled = false; + Object.assign(this.wheelDelta, { + x: 0, + y: 0, + zoom: 0, + }); + }); + + const ogIoMousePostData = runtime.ioDevices.mouse.postData; + // https://github.com/TurboWarp/scratch-vm/blob/develop/src/io/mouse.js + runtime.ioDevices.mouse.postData = function (data) { + if (typeof data.isDown !== "undefined") { + // Do not trigger if down state has not changed. + if (data.isDown === this._isDown) { + ogIoMousePostData.call(this, data); + return; + } + + const button = data.button ?? 0; + if (data.isDown) { + runtime.startHats(`${EXT_ID}_onMouseDown`, { + BUTTON: Cast.toString(button), + }); + } else { + runtime.startHats(`${EXT_ID}_onMouseUp`, { + BUTTON: Cast.toString(button), + }); + } + } + ogIoMousePostData.call(this, data); + }; + + document.addEventListener("mousemove", (e) => { + this.mouseX = e.clientX; + this.mouseY = e.clientY; + }); + document.addEventListener("mouseleave", (e) => { + this.mouseX = NaN; + this.mouseY = NaN; + // This could be problematic for some projects. + // It's not great to add another edge case. + }); + } + + getInfo() { + return { + id: EXT_ID, + name: Scratch.translate("More Mouse Controls"), + menuIconURI: EXT_ICON, + blockIconURI: BLOCK_ICON, + color1: "#99bbbb", + color2: "#7a9696", + color3: "#637a7a", + blocks: [ + { + opcode: "onMouseUp", + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: true, + text: Scratch.translate("when [BUTTON] mouse button released"), + arguments: { + BUTTON: { + menu: "BUTTON_TYPE", + defaultValue: "0", + }, + }, + }, + { + opcode: "onMouseDown", + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: true, + text: Scratch.translate("when [BUTTON] mouse button pressed"), + arguments: { + BUTTON: { + menu: "BUTTON_TYPE", + defaultValue: "0", + }, + }, + }, + "---", + { + opcode: "onScrollInDir", + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + text: Scratch.translate("when mouse wheel scrolled [SCROLL_DIR]"), + arguments: { + SCROLL_DIR: { + menu: "SCROLL_DIRS", + defaultValue: SCROLL_DIRS[SCROLL_AXISES.Y_AXIS].NEG, + }, + }, + }, + { + opcode: "onScroll", + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + text: Scratch.translate("when mouse wheel scrolled"), + }, + { + opcode: "isScrolledInDir", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("mouse wheel scrolled [SCROLL_DIR]?"), + arguments: { + SCROLL_DIR: { + menu: "SCROLL_DIRS", + defaultValue: SCROLL_DIRS[SCROLL_AXISES.Y_AXIS].NEG, + }, + }, + }, + { + opcode: "isScrolled", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("mouse wheel scrolled?"), + }, + { + opcode: "getDelta", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("scroll delta [DELTA_TYPE]"), + disableMonitor: true, + arguments: { + DELTA_TYPE: { + menu: "DELTA_TYPE", + defaultValue: "y", + }, + }, + }, + { + opcode: "getDeltaMode", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("scroll delta mode"), + }, + "---", + { + opcode: "mouseGlobalX", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("global mouse x"), + }, + { + opcode: "mouseGlobalY", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("global mouse y"), + }, + "---", + { + opcode: "toggleContextMenu", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("[TOGGLE] context menu"), + arguments: { + TOGGLE: { + menu: "TOGGLE_TYPE", + }, + }, + }, + { + opcode: "toggleScroll", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("[TOGGLE] scrolling"), + arguments: { + TOGGLE: { + menu: "TOGGLE_TYPE", + }, + }, + }, + ], + menus: { + BUTTON_TYPE: { + items: [ + { + text: Scratch.translate("(0) primary"), + value: "0", + }, + { + text: Scratch.translate("(1) middle"), + value: "1", + }, + { + text: Scratch.translate("(2) secondary"), + value: "2", + }, + { + text: Scratch.translate("(3) tertiary"), + value: "3", + }, + { + text: Scratch.translate("(4) quaternary"), + value: "4", + }, + ], + }, + SCROLL_DIRS: { + items: [ + { + text: Scratch.translate("up"), + value: SCROLL_DIRS[SCROLL_AXISES.Y_AXIS].NEG, + }, + { + text: Scratch.translate("down"), + value: SCROLL_DIRS[SCROLL_AXISES.Y_AXIS].POS, + }, + { + text: Scratch.translate("to the left"), + value: SCROLL_DIRS[SCROLL_AXISES.X_AXIS].NEG, + }, + { + text: Scratch.translate("to the right"), + value: SCROLL_DIRS[SCROLL_AXISES.X_AXIS].POS, + }, + { + text: Scratch.translate("out"), + value: SCROLL_DIRS[SCROLL_AXISES.Z_AXIS].NEG, + }, + { + text: Scratch.translate("in"), + value: SCROLL_DIRS[SCROLL_AXISES.Z_AXIS].POS, + }, + { + text: Scratch.translate("along the x-axis"), + value: SCROLL_DIRS[SCROLL_AXISES.X_AXIS].AXIS, + }, + { + text: Scratch.translate("along the y-axis"), + value: SCROLL_DIRS[SCROLL_AXISES.Y_AXIS].AXIS, + }, + { + text: Scratch.translate("along the z-axis (zoom)"), + value: SCROLL_DIRS[SCROLL_AXISES.Z_AXIS].AXIS, + }, + ], + }, + DELTA_TYPE: { + acceptReporters: true, + items: [ + "x", + "y", + { + text: Scratch.translate("zoom"), + value: "zoom", + }, + ], + }, + TOGGLE_TYPE: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("enable"), + value: "enable", + }, + { + text: Scratch.translate("disable"), + value: "disable", + }, + ], + }, + }, + }; + } + + _startOnScrollHats(delta, axis) { + if (delta === 0) return; + runtime.startHats(`${EXT_ID}_onScrollInDir`, { + SCROLL_DIR: SCROLL_DIRS[axis].AXIS, + }); + runtime.startHats(`${EXT_ID}_onScrollInDir`, { + SCROLL_DIR: delta > 0 ? SCROLL_DIRS[axis].POS : SCROLL_DIRS[axis].NEG, + }); + } + + buttonPressed(args, util) { + const button = Cast.toNumber(args.BUTTON); + return util.ioQuery("mouse", "getButtonIsDown", [button]); + } + + isScrolledInDir(args) { + if (!this.wheelScrolled) return false; + const scrollDir = Cast.toString(args.SCROLL_DIR); + const wheelDeltaArray = [ + this.wheelDelta.x, + this.wheelDelta.y, + this.wheelDelta.zoom, + ]; + SCROLL_DIRS.forEach((axisEnums, i) => { + if (scrollDir === axisEnums.AXIS && wheelDeltaArray[i] !== 0) + return true; + if (scrollDir === axisEnums.NEG && wheelDeltaArray[i] < 0) return true; + if (scrollDir === axisEnums.POS && wheelDeltaArray[i] > 0) return true; + }); + return false; + } + + isScrolled() { + return this.wheelScrolled; + } + + getDelta(args) { + const deltaType = Cast.toString(args.DELTA_TYPE); + return this.wheelDelta[deltaType] ?? NaN; + } + + getDeltaMode() { + return this.wheelDeltaMode || ""; + } + + mouseGlobalX() { + if (isNaN(this.mouseX)) return NaN; + const canvasRect = canvas.getBoundingClientRect(); + const k = canvasRect.width / runtime.stageWidth; + return Math.round( + (this.mouseX - canvasRect.x - canvasRect.width / 2) / k + ); + } + + mouseGlobalY() { + if (isNaN(this.mouseY)) return NaN; + const canvasRect = canvas.getBoundingClientRect(); + const k = canvasRect.height / runtime.stageHeight; + return Math.round( + -(this.mouseY - canvasRect.y - canvasRect.height / 2) / k + ); + } + + toggleContextMenu(args) { + const strToogle = Cast.toString(args.TOGGLE); + const boolToggle = Cast.toBoolean(args.TOGGLE); + if (strToogle === "enable") { + runtime.ioDevices.mouse.usesRightClickDown = false; + } else if (strToogle === "disable") { + runtime.ioDevices.mouse.usesRightClickDown = true; + } else { + runtime.ioDevices.mouse.usesRightClickDown = !boolToggle; + } + runtime.extensionStorage[EXT_ID] = { + ...(runtime.extensionStorage[EXT_ID] || {}), + contextMenuDisabled: runtime.ioDevices.mouse.usesRightClickDown, + }; + } + + toggleScroll(args) { + const strToogle = Cast.toString(args.TOGGLE); + const boolToggle = Cast.toBoolean(args.TOGGLE); + if (strToogle === "enable") { + this.scrollDisabled = false; + } else if (strToogle === "disable") { + this.scrollDisabled = true; + } else { + this.scrollDisabled = !boolToggle; + } + runtime.extensionStorage[EXT_ID] = { + ...(runtime.extensionStorage[EXT_ID] || {}), + scrollDisabled: this.scrollDisabled, + }; + } + } + Scratch.extensions.register(new MoreMouseControls()); +})(Scratch); diff --git a/images/legobrainbiker/MoreMouseControls.svg b/images/legobrainbiker/MoreMouseControls.svg new file mode 100644 index 0000000000..0d46c87595 --- /dev/null +++ b/images/legobrainbiker/MoreMouseControls.svg @@ -0,0 +1 @@ + \ No newline at end of file