diff --git a/Extensions/TextInput/JsExtension.js b/Extensions/TextInput/JsExtension.js index d77a9f3d0bcb..e89167677b4c 100644 --- a/Extensions/TextInput/JsExtension.js +++ b/Extensions/TextInput/JsExtension.js @@ -78,6 +78,15 @@ module.exports = { } else if (propertyName === 'disabled') { objectContent.disabled = newValue === '1'; return true; + } else if (propertyName === 'maxLength') { + objectContent.maxLength = newValue; + return true; + } else if (propertyName === 'padding') { + objectContent.padding = newValue; + return true; + } else if (propertyName === 'textAlign') { + objectContent.textAlign = newValue; + return true; } return false; @@ -200,6 +209,34 @@ module.exports = { .setLabel(_('Width')) .setGroup(_('Border appearance')); + objectProperties + .getOrCreate('padding') + .setValue((objectContent.padding || 0).toString()) + .setType('number') + .setLabel(_('Padding')) + .setGroup(_('Font')); + objectProperties + .getOrCreate('maxLength') + .setValue((objectContent.maxLength || 0).toString()) + .setType('number') + .setLabel(_('Max length')) + .setDescription( + _( + 'The maximum length of the input value (this property will be ignored if the input type is a number).' + ) + ) + .setAdvanced(true); + + objectProperties + .getOrCreate('textAlign') + .setValue(objectContent.textAlign || 'left') + .setType('choice') + .addExtraInfo('left') + .addExtraInfo('center') + .addExtraInfo('right') + .setLabel(_('Text alignment')) + .setGroup(_('Font')); + return objectProperties; }; textInputObject.content = { @@ -216,6 +253,9 @@ module.exports = { borderWidth: 1, readOnly: false, disabled: false, + padding: 0, + textAlign: 'left', + maxLength: 0, }; textInputObject.updateInitialInstanceProperty = function ( @@ -572,6 +612,22 @@ module.exports = { .getCodeExtraInformation() .setFunctionName('isFocused'); + object + .addScopedCondition( + 'IsInputSubmitted', + _('Input is submitted'), + _( + 'Check if the input is submitted, which usually happens when the Enter key is pressed on a keyboard, or a specific button on mobile virtual keyboards.' + ), + _('_PARAM0_ value was submitted'), + '', + 'res/conditions/surObject24.png', + 'res/conditions/surObject.png' + ) + .addParameter('object', _('Text input'), 'TextInputObject', false) + .getCodeExtraInformation() + .setFunctionName('isSubmitted'); + object .addScopedAction( 'Focus', @@ -755,8 +811,23 @@ module.exports = { this._pixiTextMask.endFill(); const isTextArea = object.content.inputType === 'text area'; + const textAlign = object.content.textAlign + ? object.content.textAlign + : 'left'; + const padding = object.content.padding + ? parseFloat(object.content.padding) + : 0; + + if (textAlign === 'left') + this._pixiText.position.x = textOffset + padding; + else if (textAlign === 'right') + this._pixiText.position.x = + 0 + width - this._pixiText.width - textOffset - padding; + else if (textAlign === 'center') { + this._pixiText.align = 'center'; + this._pixiText.position.x = 0 + width / 2 - this._pixiText.width / 2; + } - this._pixiText.position.x = textOffset; this._pixiText.position.y = isTextArea ? textOffset : height / 2 - this._pixiText.height / 2; diff --git a/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js b/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js index adfe65f23a69..0bc9b1d5c084 100644 --- a/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js +++ b/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js @@ -25,6 +25,9 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme borderWidth: 2, disabled: false, readOnly: false, + padding: 0, + textAlign: 'left', + maxLength: 20, }, }); @@ -166,33 +169,33 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme object, } = await setupObjectAndGetDomElementContainer(); - const inputElement = gameDomElementContainer.querySelector('input'); - if (!inputElement) throw new Error('Expected input element to be found'); + const formElement = gameDomElementContainer.querySelector('form'); + if (!formElement) throw new Error('Expected form element to be found'); // Check visibility of the DOM element is visible by default, if it should be visible // on the screen. runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('initial'); + expect(formElement.style.display).to.be('initial'); // Check visibility of the DOM element is updated at each frame, // according to the object visibility. object.hide(true); runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('none'); + expect(formElement.style.display).to.be('none'); object.hide(false); runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('initial'); + expect(formElement.style.display).to.be('initial'); // Check visibility of the DOM element is updated at each frame, // according to the layer visibility. runtimeScene.getLayer('').show(false); runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('none'); + expect(formElement.style.display).to.be('none'); runtimeScene.getLayer('').show(true); runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('initial'); + expect(formElement.style.display).to.be('initial'); // Clean up - not mandatory but to avoid overloading the testing browser. runtimeScene.unloadScene(); @@ -205,31 +208,31 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme object, } = await setupObjectAndGetDomElementContainer(); - const inputElement = gameDomElementContainer.querySelector('input'); - if (!inputElement) throw new Error('Expected input element to be found'); + const formElement = gameDomElementContainer.querySelector('form'); + if (!formElement) throw new Error('Expected input element to be found'); // Check visibility of the DOM element is visible by default, if it should be visible // on the screen. runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('initial'); + expect(formElement.style.display).to.be('initial'); // Check visibility of the DOM element is updated at each frame, // according to the object position of screen. object.setX(-500); // -500 + 300 (object default width) = -200, still outside the camera. runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('none'); + expect(formElement.style.display).to.be('none'); object.setWidth(600); // -500 + 600 = 100, inside the camera runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('initial'); + expect(formElement.style.display).to.be('initial'); runtimeScene.getLayer('').setCameraX(900); runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('none'); + expect(formElement.style.display).to.be('none'); runtimeScene.getLayer('').setCameraX(400); runtimeScene.renderAndStep(1000 / 60); - expect(inputElement.style.display).to.be('initial'); + expect(formElement.style.display).to.be('initial'); // Clean up - not mandatory but to avoid overloading the testing browser. runtimeScene.unloadScene(); diff --git a/Extensions/TextInput/textinputruntimeobject-pixi-renderer.ts b/Extensions/TextInput/textinputruntimeobject-pixi-renderer.ts index 1e49d0cbbd87..1a03b98a1336 100644 --- a/Extensions/TextInput/textinputruntimeobject-pixi-renderer.ts +++ b/Extensions/TextInput/textinputruntimeobject-pixi-renderer.ts @@ -31,6 +31,7 @@ namespace gdjs { private _input: HTMLInputElement | HTMLTextAreaElement | null = null; private _instanceContainer: gdjs.RuntimeInstanceContainer; private _runtimeGame: gdjs.RuntimeGame; + private _form: HTMLFormElement | null = null; constructor( runtimeObject: gdjs.TextInputRuntimeObject, @@ -47,18 +48,32 @@ namespace gdjs { if (!!this._input) throw new Error('Tried to recreate an input while it already exists.'); + this._form = document.createElement('form'); + const isTextArea = this._object.getInputType() === 'text area'; this._input = document.createElement(isTextArea ? 'textarea' : 'input'); - this._input.style.border = '1px solid black'; + + this._form.style.border = '0px'; + this._form.style.borderRadius = '0px'; + this._form.style.backgroundColor = 'transparent'; + this._form.style.position = 'absolute'; + this._form.style.outline = 'none'; + this._form.style.resize = 'none'; + this._form.style.pointerEvents = 'auto'; // Element can be clicked/touched. + this._form.style.display = 'none'; // Hide while object is being set up. + this._form.style.boxSizing = 'border-box'; + this._form.style.textAlign = this._object.getTextAlign(); + this._input.autocomplete = 'off'; - this._input.style.borderRadius = '0px'; this._input.style.backgroundColor = 'white'; - this._input.style.position = 'absolute'; - this._input.style.resize = 'none'; - this._input.style.outline = 'none'; - this._input.style.pointerEvents = 'auto'; // Element can be clicked/touched. - this._input.style.display = 'none'; // Hide while object is being set up. - this._input.style.boxSizing = 'border-box'; // Important for iOS, because border is added to width/height. + this._input.style.border = '1px solid black'; + this._input.style.boxSizing = 'border-box'; + this._input.style.width = '100%'; + this._input.style.height = '100%'; + this._input.maxLength = this._object.getMaxLength(); + this._input.style.padding = this._object.getPadding() + 'px'; + + this._form.appendChild(this._input); this._input.addEventListener('input', () => { if (!this._input) return; @@ -72,6 +87,11 @@ namespace gdjs { if (document.activeElement !== this._input) this._input.focus(); }); + this._form.addEventListener('submit', (event) => { + event.preventDefault(); + this._object.onRendererFormSubmitted(); + }); + this.updateString(); this.updateFont(); this.updatePlaceholder(); @@ -83,11 +103,14 @@ namespace gdjs { this.updateBorderWidth(); this.updateDisabled(); this.updateReadOnly(); + this.updateTextAlign(); + this.updateMaxLength(); + this.updatePadding(); this._runtimeGame .getRenderer() .getDomElementContainer()! - .appendChild(this._input); + .appendChild(this._form); } _destroyElement() { @@ -116,13 +139,12 @@ namespace gdjs { } updatePreRender() { - if (!this._input) return; - + if (!this._input || !this._form) return; // Hide the input entirely if the object is hidden. // Because this object is rendered as a DOM element (and not part of the PixiJS // scene graph), we have to do this manually. if (this._object.isHidden()) { - this._input.style.display = 'none'; + this._form.style.display = 'none'; return; } @@ -136,7 +158,7 @@ namespace gdjs { do { const layer = instanceContainer.getLayer(object.getLayer()); if (!layer.isVisible() || !object.isVisible()) { - this._input.style.display = 'none'; + this._form.style.display = 'none'; return; } // TODO Declare an interface to move up in the object tree. @@ -184,7 +206,7 @@ namespace gdjs { canvasLeft > runtimeGame.getGameResolutionWidth() || canvasTop > runtimeGame.getGameResolutionHeight(); if (isOutsideCanvas) { - this._input.style.display = 'none'; + this._form.style.display = 'none'; return; } @@ -210,12 +232,15 @@ namespace gdjs { const widthInContainer = pageRight - pageLeft; const heightInContainer = pageBottom - pageTop; - this._input.style.left = pageLeft + 'px'; - this._input.style.top = pageTop + 'px'; - this._input.style.width = widthInContainer + 'px'; - this._input.style.height = heightInContainer + 'px'; - this._input.style.transform = + this._form.style.left = pageLeft + 'px'; + this._form.style.top = pageTop + 'px'; + this._form.style.width = widthInContainer + 'px'; + this._form.style.height = heightInContainer + 'px'; + this._form.style.transform = 'rotate3d(0,0,1,' + (this._object.getAngle() % 360) + 'deg)'; + this._form.style.textAlign = this._object.getTextAlign(); + + this._input.style.padding = this._object.getPadding() + 'px'; // Automatically adjust the font size to follow the game scale. this._input.style.fontSize = @@ -224,7 +249,7 @@ namespace gdjs { 'px'; // Display after the object is positioned. - this._input.style.display = 'initial'; + this._form.style.display = 'initial'; } updateString() { @@ -246,8 +271,8 @@ namespace gdjs { } updateOpacity() { - if (!this._input) return; - this._input.style.opacity = '' + this._object.getOpacity() / 255; + if (!this._form) return; + this._form.style.opacity = (this._object.getOpacity() / 255).toFixed(3); } updateInputType() { @@ -297,14 +322,37 @@ namespace gdjs { this._input.style.borderWidth = this._object.getBorderWidth() + 'px'; } updateDisabled() { - if (!this._input) return; + if (!this._form) return; - this._input.disabled = this._object.isDisabled(); + this._form.disabled = this._object.isDisabled(); } updateReadOnly() { + if (!this._form) return; + + this._form.readOnly = this._object.isReadOnly(); + } + + updateMaxLength() { + const input = this._input; + if (!input) return; + if (this._object.getMaxLength() <= 0) { + input.removeAttribute('maxLength'); + return; + } + input.maxLength = this._object.getMaxLength(); + } + + updatePadding() { + if (!this._input) return; + + this._input.style.padding = this._object.getPadding() + 'px'; + } + + updateTextAlign() { if (!this._input) return; - this._input.readOnly = this._object.isReadOnly(); + const newTextAlign = this._object.getTextAlign(); + this._input.style.textAlign = newTextAlign; } isFocused() { @@ -317,7 +365,6 @@ namespace gdjs { this._input.focus(); } } - export const TextInputRuntimeObjectRenderer = TextInputRuntimeObjectPixiRenderer; export type TextInputRuntimeObjectRenderer = TextInputRuntimeObjectPixiRenderer; } diff --git a/Extensions/TextInput/textinputruntimeobject.ts b/Extensions/TextInput/textinputruntimeobject.ts index d9c0df39c924..3520ee4d8523 100644 --- a/Extensions/TextInput/textinputruntimeobject.ts +++ b/Extensions/TextInput/textinputruntimeobject.ts @@ -9,9 +9,10 @@ namespace gdjs { 'search', 'text area', ] as const; + const supportedTextAlign = ['left', 'center', 'right'] as const; type SupportedInputType = typeof supportedInputTypes[number]; - + type SupportedTextAlign = typeof supportedTextAlign[number]; const parseInputType = (potentialInputType: string): SupportedInputType => { const lowercasedNewInputType = potentialInputType.toLowerCase(); @@ -22,6 +23,19 @@ namespace gdjs { return 'text'; }; + const parseTextAlign = ( + potentialTextAlign: string | undefined + ): SupportedTextAlign => { + if (!potentialTextAlign) return 'left'; + const lowercasedNewTextAlign = potentialTextAlign.toLowerCase(); + + // @ts-ignore - we're actually checking that this value is correct. + if (supportedTextAlign.includes(lowercasedNewTextAlign)) + return potentialTextAlign as SupportedTextAlign; + + return 'left'; + }; + /** Base parameters for {@link gdjs.TextInputRuntimeObject} */ export interface TextInputObjectData extends ObjectData { /** The base parameters of the TextInput */ @@ -34,6 +48,9 @@ namespace gdjs { textColor: string; fillColor: string; fillOpacity: float; + padding?: float; + textAlign?: SupportedTextAlign; + maxLength?: integer; borderColor: string; borderOpacity: float; borderWidth: float; @@ -84,12 +101,15 @@ namespace gdjs { private _textColor: [float, float, float]; private _fillColor: [float, float, float]; private _fillOpacity: float; + private _padding: integer; + private _textAlign: SupportedTextAlign; + private _maxLength: integer; private _borderColor: [float, float, float]; private _borderOpacity: float; private _borderWidth: float; private _disabled: boolean; private _readOnly: boolean; - + private _isSubmitted: boolean; _renderer: TextInputRuntimeObjectRenderer; constructor( @@ -113,7 +133,10 @@ namespace gdjs { this._borderWidth = objectData.content.borderWidth; this._disabled = objectData.content.disabled; this._readOnly = objectData.content.readOnly; - + this._textAlign = parseTextAlign(objectData.content.textAlign); //textAlign is defaulted to 'left' by the parser if undefined. + this._maxLength = objectData.content.maxLength || 0; //maxlength and padding require a default value as they can be undefined in older projects. + this._padding = objectData.content.padding || 0; + this._isSubmitted = false; this._renderer = new gdjs.TextInputRuntimeObjectRenderer( this, instanceContainer @@ -189,6 +212,25 @@ namespace gdjs { if (oldObjectData.content.readOnly !== newObjectData.content.readOnly) { this.setReadOnly(newObjectData.content.readOnly); } + if ( + newObjectData.content.maxLength && + oldObjectData.content.maxLength !== newObjectData.content.maxLength + ) { + this.setMaxLength(newObjectData.content.maxLength); + } + if ( + newObjectData.content.textAlign && + oldObjectData.content.textAlign !== newObjectData.content.textAlign + ) { + this._textAlign = newObjectData.content.textAlign; + } + if ( + newObjectData.content.padding && + oldObjectData.content.padding !== newObjectData.content.padding + ) { + this.setPadding(newObjectData.content.padding); + } + return true; } @@ -236,6 +278,7 @@ namespace gdjs { } updatePreRender(instanceContainer: RuntimeInstanceContainer): void { + this._isSubmitted = false; this._renderer.updatePreRender(); } @@ -349,6 +392,10 @@ namespace gdjs { this._string = inputValue; } + onRendererFormSubmitted() { + this._isSubmitted = true; + } + getFontResourceName() { return this._fontResourceName; } @@ -500,6 +547,44 @@ namespace gdjs { isFocused(): boolean { return this._renderer.isFocused(); } + isSubmitted(): boolean { + return this._isSubmitted; + } + + getMaxLength(): integer { + return this._maxLength; + } + setMaxLength(value: integer) { + if (this._maxLength === value) return; + + this._maxLength = value; + this._renderer.updateMaxLength(); + } + getPadding(): integer { + return this._padding; + } + setPadding(value: integer) { + if (this._padding === value) return; + if (value < 0) { + this._padding = 0; + return; + } + + this._padding = value; + this._renderer.updatePadding(); + } + + getTextAlign(): SupportedTextAlign { + return this._textAlign; + } + + setTextAlign(newTextAlign: string) { + const parsedTextAlign = parseTextAlign(newTextAlign); + if (parsedTextAlign === this._textAlign) return; + + this._textAlign = parsedTextAlign; + this._renderer.updateTextAlign(); + } focus(): void { if (!this.isFocused()) {