From a786afaaad6b61ea69003c46854db09498f4708a Mon Sep 17 00:00:00 2001 From: hanbollar Date: Mon, 15 Apr 2024 13:56:19 -0700 Subject: [PATCH 01/25] temp save Signed-off-by: hanbollar --- src/core/entities/MRTextInputEntity.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/entities/MRTextInputEntity.js b/src/core/entities/MRTextInputEntity.js index e8c2f2d0..4ab86e80 100644 --- a/src/core/entities/MRTextInputEntity.js +++ b/src/core/entities/MRTextInputEntity.js @@ -309,8 +309,9 @@ export class MRTextInputEntity extends MRTextEntity { // does the browser handle this for us? const updateBasedOnSelectionRects = (cursorIndex) => { - // Setup variables for calculations. // XXX - handle cursor position change for visible lines for scrolloffset here in future + + // Setup variables for calculations. let textBeforeCursor = this.hiddenInput.value.substring(0, cursorIndex); let textAfterCursor = this.hiddenInput.value.substring(cursorIndex); let allLines = this.hiddenInput.value.split('\n'); From 062450ba8c34ab0c798595c95e01b079c5e65f34 Mon Sep 17 00:00:00 2001 From: hanbollar Date: Mon, 15 Apr 2024 15:20:39 -0700 Subject: [PATCH 02/25] some cleanup of cursor setup, for easier build on top Signed-off-by: hanbollar --- dist/mr.js | 2 +- src/core/entities/MRTextAreaEntity.js | 43 +++++++++++++++++++++- src/core/entities/MRTextFieldEntity.js | 10 +++++ src/core/entities/MRTextInputEntity.js | 51 ++++++++++++++++++-------- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/dist/mr.js b/dist/mr.js index 49bcb2b0..9ae04458 100644 --- a/dist/mr.js +++ b/dist/mr.js @@ -806,7 +806,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ MRTextInputEntity: () => (/* binding */ MRTextInputEntity)\n/* harmony export */ });\n/* harmony import */ var troika_three_text__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! troika-three-text */ \"./node_modules/troika-three-text/dist/troika-three-text.esm.js\");\n/* harmony import */ var three__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! three */ \"./node_modules/three/build/three.module.js\");\n/* harmony import */ var mrjs_core_entities_MRTextEntity__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! mrjs/core/entities/MRTextEntity */ \"./src/core/entities/MRTextEntity.js\");\n\n\n\n\n\n\n/**\n * @class MRTextInputEntity\n * @classdesc Base text inpu entity represented in 3D space. `mr-text-input`\n * @augments MRTextEntity\n */\nclass MRTextInputEntity extends mrjs_core_entities_MRTextEntity__WEBPACK_IMPORTED_MODULE_0__.MRTextEntity {\n /**\n * @class\n * @description Constructor for the MRTextInputEntity entity component.\n */\n constructor() {\n super();\n this.attachShadow({ mode: 'open' });\n }\n\n /**\n * @function\n * @description Gets the value of the text for the current hiddenInput DOM object\n * @returns {string} value - the text value of the current hiddenInput DOM object\n */\n get value() {\n return this.hiddenInput.value;\n }\n\n /**\n * @function\n * @description Sets the value of the text for the current hiddenInput DOM object\n */\n set value(val) {\n this.hiddenInput.value = val;\n }\n\n /**\n * @function\n * @description Function to be overwritten by children. Called by connected to make sure\n * the hiddenInput dom element is created as expected.\n */\n createHiddenInputElement() {\n mrjsUtils.error.emptyParentFunction();\n }\n\n /**\n * @function\n * @description Function to be overwritten by children. Called by connected after\n * createHiddenInputElement to fill it in with the user's given\n * attribute information.\n */\n fillInHiddenInputElementWithUserData() {\n mrjsUtils.error.emptyParentFunction();\n }\n\n /**\n * @function\n * @description Function to be overwritten by children. Used on event trigger to\n * update the textObj visual based on the hiddenInput DOM element.\n */\n updateTextDisplay() {\n mrjsUtils.error.emptyParentFunction();\n }\n\n /**\n * @function\n * @description (async) Handles setting up this textarea once it is connected to run as an entity component.\n */\n async connected() {\n await super.connected();\n\n // Cursor Setup\n this._createCursorObject();\n this.object3D.add(this.cursor);\n\n // DOM\n this.createHiddenInputElement();\n // this.fillInHiddenInputElementWithUserData(); // TODO - need good list of defaults\n\n // Make it trigger happy\n this.setupEventListeners();\n\n // Updates for baseline visual\n this.triggerGeometryStyleUpdate();\n this.triggerTextStyleUpdate();\n\n // All items should start out as 'not selected'\n // unless noted otherwise.\n if (!this.hiddenInput.getAttribute('autofocus') ?? false) {\n this._blur();\n }\n\n // Handle any placeholder setup s.t. it can be overwritten easily.\n if (this.hiddenInput.getAttribute('placeholder') ?? false) {\n this.textObj.text = this.hiddenInput.getAttribute('placeholder');\n }\n }\n\n /**\n * @function\n * @description Internal function used to setup the cursor object and associated variables\n * needed during runtime.\n */\n _createCursorObject() {\n this.cursorWidth = 0.002;\n this.cursorHeight = 0.015;\n const geometry = new three__WEBPACK_IMPORTED_MODULE_1__.PlaneGeometry(this.cursorWidth, this.cursorHeight);\n const material = new three__WEBPACK_IMPORTED_MODULE_1__.MeshBasicMaterial({\n color: 0x000000,\n side: three__WEBPACK_IMPORTED_MODULE_1__.DoubleSide,\n });\n this.cursor = new three__WEBPACK_IMPORTED_MODULE_1__.Mesh(geometry, material);\n this.cursor.position.z += 0.001;\n this.cursor.visible = false;\n\n // We store this for the geometry so we can do our geometry vs web origin calculations\n // more easily as well. We update this based on the geometry's own changes.\n //\n // Set as 0,0,0 to start, and updated when the geometry updates in case it changes in 3d space.\n this.cursorStartingPosition = new three__WEBPACK_IMPORTED_MODULE_1__.Vector3(0, 0, 0);\n }\n\n /**\n * @function\n * @description Function to be overwritten by children. Called by the keydown event trigger.\n * @param {event} event - the keydown event\n */\n handleKeydown(event) {\n mrjsUtils.error.emptyParentFunction();\n }\n\n /**\n * @function\n * @description Called by the mouse click event trigger. Handles determining the\n * caret position based on the 3D textObj to hiddenInput DOM position conversion.\n * @param {event} event - the mouseclick event\n */\n handleMouseClick(event) {\n console.log(event);\n // Convert isx position from world position to local:\n // - make sure textObj has updated matrices so we're not calculating info wrong\n // - note: textObj doesnt need sync\n this.textObj.updateMatrixWorld(true);\n const inverseMatrixWorld = new three__WEBPACK_IMPORTED_MODULE_1__.Matrix4().copy(this.textObj.matrixWorld).invert();\n const localPosition = inverseMatrixWorld * event.worldPosition;\n\n // update cursor position based on click\n const caret = (0,troika_three_text__WEBPACK_IMPORTED_MODULE_2__.getCaretAtPoint)(this.textObj.textRenderInfo, localPosition.x, localPosition.y);\n this.hiddenInput.selectionStart = caret.charIndex;\n this.updateCursorPosition();\n }\n\n /**\n * @function\n * @description Called by the focus event trigger and in other 'focus' situations. We use the\n * private version of this function signature to not hit the intersection of the actual 'focus()'\n * event naming that we have connected. See 'setupEventListeners()' description for more info.\n * @param {boolean} isPureFocusEvent - Boolean to allow us to update the cursor position with this function\n * directly. Otherwise, we assume there's other things happening after focus was called as part of the event\n * and that the cursor position will be handled there instead.\n */\n _focus(isPureFocusEvent = false) {\n if (!this.hiddenInput) {\n return;\n }\n this.hiddenInput.focus();\n\n if (isPureFocusEvent) {\n // Only want to update cursor and selection position if\n // this is a pure focus event; otherwise, we're assuming\n // the other event will position those properly (so that\n // we dont do redundant positioning here and then there as well).\n this.hiddenInput.selectionStart = this.hiddenInput.value.length;\n this.updateCursorPosition();\n }\n\n this.cursor.visible = true;\n }\n\n /**\n * @function\n * @description Called by the blur event trigger and in other 'blur' situations. We use the\n * private version of this function signature to not hit the intersection of the actual 'blur()'\n * event naming that we have connected. See 'setupEventListeners()' description for more info.\n */\n _blur() {\n if (!this.hiddenInput) {\n return;\n }\n this.hiddenInput.blur();\n\n this.cursor.visible = false;\n }\n\n /**\n * @function\n * @description Getter for a commonly needed attribute: 'disabled' for whether this input is still being updated.\n * @returns {boolean} true if disabled, false otherwise\n */\n get inputIsDisabled() {\n return this.hiddenInput.getAttribute('disabled') ?? false;\n }\n\n /**\n * @function\n * @description Getter for a commonly needed attribute: 'readonly' for whether this input's text can still be changed.\n * @returns {boolean} true if readonly, false otherwise\n */\n get inputIsReadOnly() {\n return this.hiddenInput.getAttribute('readonly') ?? false;\n }\n\n /**\n * @function\n * @description Connecting the event listeners to the actual functions that handle them. Includes\n * additional calls where necessary.\n *\n * Since we want the text input children to be able\n * to override the parent function event triggers,\n * separating them into an actual function here\n * and calling them manually instead of doing the pure\n * 'functionname () => {} event type setup'. This manual\n * connection allows us to call super.func() for event\n * functions; otherwise, theyre not accessible nor implemented\n * in the subclasses.\n */\n setupEventListeners() {\n // Blur events\n this.addEventListener('blur', () => {\n this._blur();\n });\n\n // Pure Focus Events\n this.addEventListener('focus', () => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n this._focus(true);\n });\n this.addEventListener('click', () => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n this._focus(true);\n });\n\n // Focus and Handle Event\n this.addEventListener('touchstart', (event) => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n this._focus();\n this.handleMouseClick(event);\n });\n\n // Keyboard events to capture text in the\n // hidden input.\n this.hiddenInput.addEventListener('input', (event) => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n\n // Input captures all main text character inputs\n // BUT it does not capture arrow keys, so we handle\n // those specifically by the 'keydown' event.\n //\n // We handle all the rest by relying on the internal\n // 'hiddenInput's update system so we dont have to\n // manage as many things directly ourselves.\n\n this.updateTextDisplay();\n this.updateCursorPosition(false);\n });\n this.hiddenInput.addEventListener('keydown', (event) => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n\n // Only using keydown for arrow keys, everything else is\n // handled by the input event - check the comment there\n // for more reasoning.\n\n if (event.key == 'ArrowUp' || event.key == 'ArrowDown' || event.key == 'ArrowLeft' || event.key == 'ArrowRight') {\n this.handleKeydown(event);\n }\n });\n\n // Separate trigger call just in case.\n this.addEventListener('update-cursor-position', () => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n\n this.updateCursorPosition();\n });\n }\n\n /**\n * @function\n * @description Updates the cursor position based on click and selection location.\n * @param {boolean} fromCursorMove - false by default. Used to determine if we need to run\n * based off a text object update sync or we can directly grab information. This requirement\n * occurs because the sync isnt usable if no text content changed.\n */\n updateCursorPosition(fromCursorMove = false) {\n // TODO - QUESTION: handle '\\n' --> as '/\\r?\\n/' for crossplatform compat\n // does the browser handle this for us?\n\n const updateBasedOnSelectionRects = (cursorIndex) => {\n // Setup variables for calculations.\n // XXX - handle cursor position change for visible lines for scrolloffset here in future\n let textBeforeCursor = this.hiddenInput.value.substring(0, cursorIndex);\n let textAfterCursor = this.hiddenInput.value.substring(cursorIndex);\n let allLines = this.hiddenInput.value.split('\\n');\n let linesBeforeCursor = textBeforeCursor.split('\\n');\n let cursorIsOnLineIndex = linesBeforeCursor.length - 1;\n\n let cursorXOffsetPosition = 0;\n let cursorYOffsetPosition = 0;\n\n let rectX = undefined;\n let rectY = undefined;\n let rect = undefined;\n\n const prevIsNewlineChar = '\\n' === textBeforeCursor.charAt(textBeforeCursor.length - 1);\n if (prevIsNewlineChar) {\n // When on newline char, hiddenInput puts selection at end of newline char,\n // not beg of next line. Make sure cursor visual is at beg of next line\n // without moving selection point.\n //\n // Also handle special case where next line doesnt exist yet, fake it with our\n // current line's information.\n const isLastLine = cursorIsOnLineIndex == allLines.length - 1;\n if (isLastLine) {\n const indexOfBegOfLine = textBeforeCursor.substring(0, textBeforeCursor.length - 1).lastIndexOf('\\n') + 1;\n let selectionRects = (0,troika_three_text__WEBPACK_IMPORTED_MODULE_2__.getSelectionRects)(this.textObj.textRenderInfo, indexOfBegOfLine, cursorIndex);\n rect = selectionRects[0];\n rectX = rect.left;\n rectY = rect.bottom - this.cursorHeight;\n } else {\n let selectionRects = (0,troika_three_text__WEBPACK_IMPORTED_MODULE_2__.getSelectionRects)(this.textObj.textRenderInfo, textBeforeCursor.length - 1, cursorIndex + 1);\n rect = selectionRects[selectionRects.length - 1];\n rectX = rect.left;\n rectY = rect.bottom;\n }\n } else {\n // default\n let selectionRects = (0,troika_three_text__WEBPACK_IMPORTED_MODULE_2__.getSelectionRects)(this.textObj.textRenderInfo, textBeforeCursor.length - 1, cursorIndex);\n let rectIndex = selectionRects.length - 1;\n rect = selectionRects[rectIndex];\n rectX = rect.right;\n rectY = rect.bottom;\n }\n\n // Check if cursor matches our font size before using values.\n const cursorVisibleHeight = rect.top - rect.bottom;\n if (this.cursor.geometry.height != cursorVisibleHeight) {\n this.cursor.geometry.height = cursorVisibleHeight;\n this.cursor.geometry.needsUpdate = true;\n this.cursorHeight = cursorVisibleHeight;\n }\n\n // Add the cursor dimension info to the position s.t. it doesnt touch the text itself. We want\n // a little bit of buffer room.\n cursorXOffsetPosition = rectX + this.cursorWidth;\n cursorYOffsetPosition = rectY + this.cursorHeight;\n\n // Update the cursor's 3D position\n this.cursor.position.x = this.cursorStartingPosition.x + cursorXOffsetPosition;\n this.cursor.position.y = this.cursorStartingPosition.y + cursorYOffsetPosition;\n this.cursor.visible = true;\n };\n\n // Check if we have any DOM element to work with.\n if (!this.hiddenInput) {\n return;\n }\n\n // Since no text is selected, this and selectionEnd are just the cursor position.\n // XXX - when we actually allow for seleciton in future, some of the below will need to\n // be thought through again.\n const cursorIndex = this.hiddenInput.selectionStart;\n\n // early escape for empty text\n if (cursorIndex == 0) {\n this.cursor.position.x = this.cursorStartingPosition.x;\n this.cursor.position.y = this.cursorStartingPosition.y;\n this.cursor.visible = true;\n return;\n }\n\n // Separating textObj sync from the cursor update based on rects\n // since textObj sync resolves when there's actual changes to the\n // object. Otherwise, it'll hang and never hit the update function.\n if (fromCursorMove) {\n updateBasedOnSelectionRects(cursorIndex);\n } else {\n this.textObj.sync(() => {\n updateBasedOnSelectionRects(cursorIndex);\n });\n }\n }\n}\n\n\n//# sourceURL=webpack://mrjs/./src/core/entities/MRTextInputEntity.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ MRTextInputEntity: () => (/* binding */ MRTextInputEntity)\n/* harmony export */ });\n/* harmony import */ var troika_three_text__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! troika-three-text */ \"./node_modules/troika-three-text/dist/troika-three-text.esm.js\");\n/* harmony import */ var three__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! three */ \"./node_modules/three/build/three.module.js\");\n/* harmony import */ var mrjs_core_entities_MRTextEntity__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! mrjs/core/entities/MRTextEntity */ \"./src/core/entities/MRTextEntity.js\");\n\n\n\n\n\n\n/**\n * @class MRTextInputEntity\n * @classdesc Base text inpu entity represented in 3D space. `mr-text-input`\n * @augments MRTextEntity\n */\nclass MRTextInputEntity extends mrjs_core_entities_MRTextEntity__WEBPACK_IMPORTED_MODULE_0__.MRTextEntity {\n /**\n * @class\n * @description Constructor for the MRTextInputEntity entity component.\n */\n constructor() {\n super();\n this.attachShadow({ mode: 'open' });\n }\n\n /**\n * @function\n * @description Gets the value of the text for the current hiddenInput DOM object\n * @returns {string} value - the text value of the current hiddenInput DOM object\n */\n get value() {\n return this.hiddenInput.value;\n }\n\n /**\n * @function\n * @description Sets the value of the text for the current hiddenInput DOM object\n */\n set value(val) {\n this.hiddenInput.value = val;\n }\n\n /**\n * @function\n * @description Function to be overwritten by children. Called by connected to make sure\n * the hiddenInput dom element is created as expected.\n */\n createHiddenInputElement() {\n mrjsUtils.error.emptyParentFunction();\n }\n\n /**\n * @function\n * @description Function to be overwritten by children. Called by connected after\n * createHiddenInputElement to fill it in with the user's given\n * attribute information.\n */\n fillInHiddenInputElementWithUserData() {\n mrjsUtils.error.emptyParentFunction();\n }\n\n /**\n * @function\n * @description Function to be overwritten by children. Used on event trigger to\n * update the textObj visual based on the hiddenInput DOM element.\n */\n updateTextDisplay() {\n mrjsUtils.error.emptyParentFunction();\n }\n\n /**\n * @function\n * @description (async) Handles setting up this textarea once it is connected to run as an entity component.\n */\n async connected() {\n await super.connected();\n\n // Cursor Setup\n this._createCursorObject();\n this.object3D.add(this.cursor);\n\n // DOM\n this.createHiddenInputElement();\n // this.fillInHiddenInputElementWithUserData(); // TODO - need good list of defaults\n\n // Make it trigger happy\n this.setupEventListeners();\n\n // Updates for baseline visual\n this.triggerGeometryStyleUpdate();\n this.triggerTextStyleUpdate();\n\n // All items should start out as 'not selected'\n // unless noted otherwise.\n if (!this.hiddenInput.getAttribute('autofocus') ?? false) {\n this._blur();\n }\n\n // Handle any placeholder setup s.t. it can be overwritten easily.\n if (this.hiddenInput.getAttribute('placeholder') ?? false) {\n this.textObj.text = this.hiddenInput.getAttribute('placeholder');\n }\n }\n\n /**\n * @function\n * @description Internal function used to setup the cursor object and associated variables\n * needed during runtime.\n */\n _createCursorObject() {\n this.cursorWidth = 0.002;\n this.cursorHeight = 0.015;\n const geometry = new three__WEBPACK_IMPORTED_MODULE_1__.PlaneGeometry(this.cursorWidth, this.cursorHeight);\n const material = new three__WEBPACK_IMPORTED_MODULE_1__.MeshBasicMaterial({\n color: 0x000000,\n side: three__WEBPACK_IMPORTED_MODULE_1__.DoubleSide,\n });\n this.cursor = new three__WEBPACK_IMPORTED_MODULE_1__.Mesh(geometry, material);\n this.cursor.position.z += 0.001;\n this.cursor.visible = false;\n\n // We store this for the geometry so we can do our geometry vs web origin calculations\n // more easily as well. We update this based on the geometry's own changes.\n //\n // Set as 0,0,0 to start, and updated when the geometry updates in case it changes in 3d space.\n this.cursorStartingPosition = new three__WEBPACK_IMPORTED_MODULE_1__.Vector3(0, 0, 0);\n }\n\n /**\n * @function\n * @description Function to be overwritten by children. Called by the keydown event trigger.\n * @param {event} event - the keydown event\n */\n handleKeydown(event) {\n mrjsUtils.error.emptyParentFunction();\n }\n\n /**\n * @function\n * @description Called by the mouse click event trigger. Handles determining the\n * caret position based on the 3D textObj to hiddenInput DOM position conversion.\n * @param {event} event - the mouseclick event\n */\n handleMouseClick(event) {\n console.log(event);\n // Convert isx position from world position to local:\n // - make sure textObj has updated matrices so we're not calculating info wrong\n // - note: textObj doesnt need sync\n this.textObj.updateMatrixWorld(true);\n const inverseMatrixWorld = new three__WEBPACK_IMPORTED_MODULE_1__.Matrix4().copy(this.textObj.matrixWorld).invert();\n const localPosition = inverseMatrixWorld * event.worldPosition;\n\n // update cursor position based on click\n const caret = (0,troika_three_text__WEBPACK_IMPORTED_MODULE_2__.getCaretAtPoint)(this.textObj.textRenderInfo, localPosition.x, localPosition.y);\n this.hiddenInput.selectionStart = caret.charIndex;\n this.updateCursorPosition();\n }\n\n /**\n * @function\n * @description Called by the focus event trigger and in other 'focus' situations. We use the\n * private version of this function signature to not hit the intersection of the actual 'focus()'\n * event naming that we have connected. See 'setupEventListeners()' description for more info.\n * @param {boolean} isPureFocusEvent - Boolean to allow us to update the cursor position with this function\n * directly. Otherwise, we assume there's other things happening after focus was called as part of the event\n * and that the cursor position will be handled there instead.\n */\n _focus(isPureFocusEvent = false) {\n if (!this.hiddenInput) {\n return;\n }\n this.hiddenInput.focus();\n\n if (isPureFocusEvent) {\n // Only want to update cursor and selection position if\n // this is a pure focus event; otherwise, we're assuming\n // the other event will position those properly (so that\n // we dont do redundant positioning here and then there as well).\n this.hiddenInput.selectionStart = this.hiddenInput.value.length;\n this.updateCursorPosition();\n }\n\n this.cursor.visible = true;\n }\n\n /**\n * @function\n * @description Called by the blur event trigger and in other 'blur' situations. We use the\n * private version of this function signature to not hit the intersection of the actual 'blur()'\n * event naming that we have connected. See 'setupEventListeners()' description for more info.\n */\n _blur() {\n if (!this.hiddenInput) {\n return;\n }\n this.hiddenInput.blur();\n\n this.cursor.visible = false;\n }\n\n /**\n * @function\n * @description Getter for a commonly needed attribute: 'disabled' for whether this input is still being updated.\n * @returns {boolean} true if disabled, false otherwise\n */\n get inputIsDisabled() {\n return this.hiddenInput.getAttribute('disabled') ?? false;\n }\n\n /**\n * @function\n * @description Getter for a commonly needed attribute: 'readonly' for whether this input's text can still be changed.\n * @returns {boolean} true if readonly, false otherwise\n */\n get inputIsReadOnly() {\n return this.hiddenInput.getAttribute('readonly') ?? false;\n }\n\n /**\n * @function\n * @description Connecting the event listeners to the actual functions that handle them. Includes\n * additional calls where necessary.\n *\n * Since we want the text input children to be able\n * to override the parent function event triggers,\n * separating them into an actual function here\n * and calling them manually instead of doing the pure\n * 'functionname () => {} event type setup'. This manual\n * connection allows us to call super.func() for event\n * functions; otherwise, theyre not accessible nor implemented\n * in the subclasses.\n */\n setupEventListeners() {\n // Blur events\n this.addEventListener('blur', () => {\n this._blur();\n });\n\n // Pure Focus Events\n this.addEventListener('focus', () => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n this._focus(true);\n });\n this.addEventListener('click', () => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n this._focus(true);\n });\n\n // Focus and Handle Event\n this.addEventListener('touchstart', (event) => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n this._focus();\n this.handleMouseClick(event);\n });\n\n // Keyboard events to capture text in the\n // hidden input.\n this.hiddenInput.addEventListener('input', (event) => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n\n // Input captures all main text character inputs\n // BUT it does not capture arrow keys, so we handle\n // those specifically by the 'keydown' event.\n //\n // We handle all the rest by relying on the internal\n // 'hiddenInput's update system so we dont have to\n // manage as many things directly ourselves.\n\n this.updateTextDisplay();\n this.updateCursorPosition(false);\n });\n this.hiddenInput.addEventListener('keydown', (event) => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n\n // Only using keydown for arrow keys, everything else is\n // handled by the input event - check the comment there\n // for more reasoning.\n\n if (event.key == 'ArrowUp' || event.key == 'ArrowDown' || event.key == 'ArrowLeft' || event.key == 'ArrowRight') {\n this.handleKeydown(event);\n }\n });\n\n // Separate trigger call just in case.\n this.addEventListener('update-cursor-position', () => {\n if (this.inputIsDisabled || this.inputIsReadOnly) {\n return;\n }\n\n this.updateCursorPosition();\n });\n }\n\n /**\n * @function\n * @description Updates the cursor position based on click and selection location.\n * @param {boolean} fromCursorMove - false by default. Used to determine if we need to run\n * based off a text object update sync or we can directly grab information. This requirement\n * occurs because the sync isnt usable if no text content changed.\n */\n updateCursorPosition(fromCursorMove = false) {\n // TODO - QUESTION: handle '\\n' --> as '/\\r?\\n/' for crossplatform compat\n // does the browser handle this for us?\n\n const updateBasedOnSelectionRects = (cursorIndex) => {\n // XXX - handle cursor position change for visible lines for scrolloffset here in future\n\n // Setup variables for calculations.\n let textBeforeCursor = this.hiddenInput.value.substring(0, cursorIndex);\n let textAfterCursor = this.hiddenInput.value.substring(cursorIndex);\n let allLines = this.hiddenInput.value.split('\\n');\n let linesBeforeCursor = textBeforeCursor.split('\\n');\n let cursorIsOnLineIndex = linesBeforeCursor.length - 1;\n\n let cursorXOffsetPosition = 0;\n let cursorYOffsetPosition = 0;\n\n let rectX = undefined;\n let rectY = undefined;\n let rect = undefined;\n\n const prevIsNewlineChar = '\\n' === textBeforeCursor.charAt(textBeforeCursor.length - 1);\n if (prevIsNewlineChar) {\n // When on newline char, hiddenInput puts selection at end of newline char,\n // not beg of next line. Make sure cursor visual is at beg of next line\n // without moving selection point.\n //\n // Also handle special case where next line doesnt exist yet, fake it with our\n // current line's information.\n const isLastLine = cursorIsOnLineIndex == allLines.length - 1;\n if (isLastLine) {\n const indexOfBegOfLine = textBeforeCursor.substring(0, textBeforeCursor.length - 1).lastIndexOf('\\n') + 1;\n let selectionRects = (0,troika_three_text__WEBPACK_IMPORTED_MODULE_2__.getSelectionRects)(this.textObj.textRenderInfo, indexOfBegOfLine, cursorIndex);\n rect = selectionRects[0];\n rectX = rect.left;\n rectY = rect.bottom - this.cursorHeight;\n } else {\n let selectionRects = (0,troika_three_text__WEBPACK_IMPORTED_MODULE_2__.getSelectionRects)(this.textObj.textRenderInfo, textBeforeCursor.length - 1, cursorIndex + 1);\n rect = selectionRects[selectionRects.length - 1];\n rectX = rect.left;\n rectY = rect.bottom;\n }\n } else {\n // default\n let selectionRects = (0,troika_three_text__WEBPACK_IMPORTED_MODULE_2__.getSelectionRects)(this.textObj.textRenderInfo, textBeforeCursor.length - 1, cursorIndex);\n let rectIndex = selectionRects.length - 1;\n rect = selectionRects[rectIndex];\n rectX = rect.right;\n rectY = rect.bottom;\n }\n\n // Check if cursor matches our font size before using values.\n const cursorVisibleHeight = rect.top - rect.bottom;\n if (this.cursor.geometry.height != cursorVisibleHeight) {\n this.cursor.geometry.height = cursorVisibleHeight;\n this.cursor.geometry.needsUpdate = true;\n this.cursorHeight = cursorVisibleHeight;\n }\n\n // Add the cursor dimension info to the position s.t. it doesnt touch the text itself. We want\n // a little bit of buffer room.\n cursorXOffsetPosition = rectX + this.cursorWidth;\n cursorYOffsetPosition = rectY + this.cursorHeight;\n\n // Update the cursor's 3D position\n this.cursor.position.x = this.cursorStartingPosition.x + cursorXOffsetPosition;\n this.cursor.position.y = this.cursorStartingPosition.y + cursorYOffsetPosition;\n this.cursor.visible = true;\n };\n\n // Check if we have any DOM element to work with.\n if (!this.hiddenInput) {\n return;\n }\n\n // Since no text is selected, this and selectionEnd are just the cursor position.\n // XXX - when we actually allow for seleciton in future, some of the below will need to\n // be thought through again.\n const cursorIndex = this.hiddenInput.selectionStart;\n\n // early escape for empty text\n if (cursorIndex == 0) {\n this.cursor.position.x = this.cursorStartingPosition.x;\n this.cursor.position.y = this.cursorStartingPosition.y;\n this.cursor.visible = true;\n return;\n }\n\n // Separating textObj sync from the cursor update based on rects\n // since textObj sync resolves when there's actual changes to the\n // object. Otherwise, it'll hang and never hit the update function.\n if (fromCursorMove) {\n updateBasedOnSelectionRects(cursorIndex);\n } else {\n this.textObj.sync(() => {\n updateBasedOnSelectionRects(cursorIndex);\n });\n }\n }\n}\n\n\n//# sourceURL=webpack://mrjs/./src/core/entities/MRTextInputEntity.js?"); /***/ }), diff --git a/src/core/entities/MRTextAreaEntity.js b/src/core/entities/MRTextAreaEntity.js index 86696952..8cacc953 100644 --- a/src/core/entities/MRTextAreaEntity.js +++ b/src/core/entities/MRTextAreaEntity.js @@ -66,6 +66,16 @@ export class MRTextAreaEntity extends MRTextInputEntity { this.hiddenInput.setAttribute('whitespace', this.getAttribute('whitespace') ?? undefined); } + get hasTextSubsetForVerticalScrolling() { + return true; + } + + // todo - better name + get hasTextSubsetForHorizontalScrolling() { + // todo - handle wrapping etc lol + mrjsUtils.error.emptyParentFunction(); + } + /** * @function * @description Used on event trigger to update the textObj visual based on @@ -74,6 +84,30 @@ export class MRTextAreaEntity extends MRTextInputEntity { updateTextDisplay() { // XXX - add scrolling logic in here for areas where text is greater than // the width/domain the user creates visually + + // console.log('--- updating text display:'); + + // // check if a new line was added - if so, handle offset + // // check if a line was removed - if so, handle offset + // // const numHiddenInputLines = this.verticalEndLineIndex - this. + // const allLines = this.hiddenInput.value.split('\n'); + // const maxHiddenInputLineIndex = allLines.length - 1; + // if (maxHiddenInputLineIndex < this.verticalTextObjStartLineIndex && this.verticalTextObjStartLineIndex != 0) { + // --this.verticalTextObjEndLineIndex; + // --this.verticalTextObjStartLineIndex; + // } else if (maxHiddenInputLineIndex > this.verticalTextObjEndLineIndex && this.verticalTextObjEndLineIndex != maxHiddenInputLineIndex) { + // ++this.verticalTextObjEndLineIndex; + // ++this.verticalTextObjStartLineIndex; + // } + + // let text = ""; + // for (let lineIdx = this.verticalTextObjStartLineIndex; lineIdx <= this.verticalTextObjEndLineIndex; ++lineIdx) { + // text += allLines[lineIdx] ?? ""; + // } + + // console.log('new text was:', text); + + // this.textObj.text = text; this.textObj.text = this.hiddenInput.value; } @@ -145,7 +179,14 @@ export class MRTextAreaEntity extends MRTextInputEntity { this.hiddenInput.selectionEnd = this.hiddenInput.selectionStart; // Ensure the cursor position is updated to reflect the current caret position - this.updateCursorPosition(true); + // This is actually needed otherwise the cursor event's are off by a count (ie + // press left 2x, the right 1x and the first press and third press wont function + // as the user expects and it'll still be waiting for a 4th press. That is - + // it'll go: 1)nothing 2)left 3)left 4)right + // instead of expected: 1)left 2) left 3) right + setTimeout(() => { + this.updateCursorPosition(true); + }, 0); } } diff --git a/src/core/entities/MRTextFieldEntity.js b/src/core/entities/MRTextFieldEntity.js index 9e924d96..89625426 100644 --- a/src/core/entities/MRTextFieldEntity.js +++ b/src/core/entities/MRTextFieldEntity.js @@ -75,6 +75,16 @@ export class MRTextFieldEntity extends MRTextInputEntity { this.hiddenInput.setAttribute('id', this.getAttribute('id') ?? undefined); } + get hasTextSubsetForVerticalScrolling() { + return false; + } + + // todo - better name + get hasTextSubsetForHorizontalScrolling() { + // todo - handle wrapping etc lol + mrjsUtils.error.emptyParentFunction(); + } + /** * @function * @description Used on event trigger to update the textObj visual based on diff --git a/src/core/entities/MRTextInputEntity.js b/src/core/entities/MRTextInputEntity.js index 4ab86e80..96c709d3 100644 --- a/src/core/entities/MRTextInputEntity.js +++ b/src/core/entities/MRTextInputEntity.js @@ -96,6 +96,12 @@ export class MRTextInputEntity extends MRTextEntity { if (this.hiddenInput.getAttribute('placeholder') ?? false) { this.textObj.text = this.hiddenInput.getAttribute('placeholder'); } + + if (this.hasTextSubsetForVerticalScrolling) { + this.verticalTextObjStartLineIndex = 0; + this.verticalTextObjEndLineIndex = 0; + // this.cursorIsOnScrollLineIndex = 0; + } } /** @@ -194,6 +200,17 @@ export class MRTextInputEntity extends MRTextEntity { this.cursor.visible = false; } + // todo - better name + get hasTextSubsetForVerticalScrolling() { + mrjsUtils.error.emptyParentFunction(); + } + + // todo - better name + get hasTextSubsetForHorizontalScrolling() { + // todo - handle wrapping etc lol + mrjsUtils.error.emptyParentFunction(); + } + /** * @function * @description Getter for a commonly needed attribute: 'disabled' for whether this input is still being updated. @@ -303,6 +320,10 @@ export class MRTextInputEntity extends MRTextEntity { * @param {boolean} fromCursorMove - false by default. Used to determine if we need to run * based off a text object update sync or we can directly grab information. This requirement * occurs because the sync isnt usable if no text content changed. + * + * Note: this function does not change anything about the this.hiddenInput.selectionStart nor + * this.hiddenInput.selectionEnd. Those values should be changed prior to this function being + * called. */ updateCursorPosition(fromCursorMove = false) { // TODO - QUESTION: handle '\n' --> as '/\r?\n/' for crossplatform compat @@ -325,6 +346,9 @@ export class MRTextInputEntity extends MRTextEntity { let rectY = undefined; let rect = undefined; + // create specific variables for textObj lines subset given vertical scrolling + let cursorIsOnTextObjLineIndex = cursorIsOnLineIndex - this.verticalTextObjStartLineIndex; + const prevIsNewlineChar = '\n' === textBeforeCursor.charAt(textBeforeCursor.length - 1); if (prevIsNewlineChar) { // When on newline char, hiddenInput puts selection at end of newline char, @@ -333,24 +357,21 @@ export class MRTextInputEntity extends MRTextEntity { // // Also handle special case where next line doesnt exist yet, fake it with our // current line's information. + + // note: doing it this way to not have to sum up all the lines of text before this one. const isLastLine = cursorIsOnLineIndex == allLines.length - 1; - if (isLastLine) { - const indexOfBegOfLine = textBeforeCursor.substring(0, textBeforeCursor.length - 1).lastIndexOf('\n') + 1; - let selectionRects = getSelectionRects(this.textObj.textRenderInfo, indexOfBegOfLine, cursorIndex); - rect = selectionRects[0]; - rectX = rect.left; - rectY = rect.bottom - this.cursorHeight; - } else { - let selectionRects = getSelectionRects(this.textObj.textRenderInfo, textBeforeCursor.length - 1, cursorIndex + 1); - rect = selectionRects[selectionRects.length - 1]; - rectX = rect.left; - rectY = rect.bottom; - } + const indexOfBegOfLine = textBeforeCursor.substring(0, textBeforeCursor.length - 1).lastIndexOf('\n') + 1; + let usingIndex = isLastLine ? indexOfBegOfLine : cursorIndex; + let selectionRects = getSelectionRects(this.textObj.textRenderInfo, usingIndex, usingIndex + 1); + // rect information for use in cursor positioning + rect = selectionRects[0]; + rectX = rect.left; + rectY = rect.bottom - (isLastLine ? this.cursorHeight : 0); } else { // default - let selectionRects = getSelectionRects(this.textObj.textRenderInfo, textBeforeCursor.length - 1, cursorIndex); - let rectIndex = selectionRects.length - 1; - rect = selectionRects[rectIndex]; + let selectionRects = getSelectionRects(this.textObj.textRenderInfo, cursorIndex - 1, cursorIndex); + // rect information for use in cursor positioning + rect = selectionRects[0]; rectX = rect.right; rectY = rect.bottom; } From b1314b578e4b1c59d1fb6467bf76845ad9540685 Mon Sep 17 00:00:00 2001 From: hanbollar Date: Mon, 15 Apr 2024 15:32:01 -0700 Subject: [PATCH 03/25] cursor position change for subset impl Signed-off-by: hanbollar --- dist/mr.js | 6 +++--- src/core/entities/MRTextAreaEntity.js | 5 +---- src/core/entities/MRTextInputEntity.js | 15 ++++++++++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/dist/mr.js b/dist/mr.js index 9ae04458..2d7ac9b0 100644 --- a/dist/mr.js +++ b/dist/mr.js @@ -773,7 +773,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ MRTextAreaEntity: () => (/* binding */ MRTextAreaEntity)\n/* harmony export */ });\n/* harmony import */ var mrjs_core_entities_MRTextInputEntity__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! mrjs/core/entities/MRTextInputEntity */ \"./src/core/entities/MRTextInputEntity.js\");\n\n\n\n\n/**\n * @class MRTextAreaEntity\n * @classdesc The text area element that simulates the behavior of an HTML