UNPKG

lazy-widgets

Version:

Typescript retained mode GUI for the HTML canvas API

1,119 lines 47.7 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { layoutField, damageArrayField, watchField } from '../decorators/FlagFields.js'; import { PointerReleaseEvent } from '../events/PointerReleaseEvent.js'; import { TextPasteEvent } from '../events/TextPasteEvent.js'; import { PointerEvent } from '../events/PointerEvent.js'; import { PointerPressEvent } from '../events/PointerPressEvent.js'; import { PointerWheelEvent } from '../events/PointerWheelEvent.js'; import { Widget } from './Widget.js'; import { PointerMoveEvent } from '../events/PointerMoveEvent.js'; import { TextHelper } from '../helpers/TextHelper.js'; import { AutoScrollEvent } from '../events/AutoScrollEvent.js'; import { KeyPressEvent } from '../events/KeyPressEvent.js'; import { FocusType } from '../core/FocusType.js'; import { LeaveEvent } from '../events/LeaveEvent.js'; import { PropagationModel } from '../events/WidgetEvent.js'; import { FocusEvent } from '../events/FocusEvent.js'; import { BlurEvent } from '../events/BlurEvent.js'; import { Variable } from '../state/Variable.js'; /** * A flexbox widget that allows for a single line of text input. * * Supports obscuring the text with {@link TextInput#hideText}, which shows all * characters as black circles like in password fields, text validation and * toggling editing. * * If a {@link TextInputHandler} is set, then that will be used instead of * keyboard input for mobile compatibility. * * @category Widget */ export class TextInput extends Widget { constructor(variable = new Variable(''), properties) { var _a, _b, _c, _d, _e; // TextInputs clear their own background, have no children and don't // propagate events super(properties); /** * At what timestamp did the blinking start. If 0, then the text cursor is * not blinking. */ this.blinkStart = 0; /** * Was the cursor shown last frame due to blinking? If null, then the text * cursor is not blinking. */ this.blinkWasOn = null; /** Current cursor position (index, not offset). */ this.cursorPos = 0; /** Current cursor offset in pixels. */ this.cursorOffset = [0, 0]; /** Current cursor selection start position (index, not offset). */ this.selectPos = 0; /** Current cursor selection start offset in pixels. */ this.selectOffset = [0, 0]; /** Does the cursor offset need to be updated? */ this.cursorOffsetDirty = false; /** Current offset of the text in the text box. Used on overflow. */ this.offset = [0, 0]; /** Is the pointer dragging? */ this.dragging = false; /** When was the last pointer click? For detecting double/triple-clicks */ this.lastClick = 0; /** * The cursor position when dragging was started. Used for * double/triple-click dragging. */ this.dragStart = -1; /** * How many clicks have there been after a first click where the time * between each click is less than 500 ms. Used for detecting double/triple * clicks */ this.successiveClickCount = 0; /** * Should the caret position be {@link AutoScrollEvent | auto-scrolled} * after the layout is finalized? */ this.needsAutoScroll = false; /** * Is tab mode enabled? When enabled, pressing the tab key will type a tab * character instead of changing focus. * * Toggled automatically by pressing ctrl+m, but can be manually toggled by * changing this flag. If the TextInput grabs the keyboard focus, then this * is automatically disabled so that user flow isn't unexpectedly * interrupted. * * Does nothing if {@link TextInput#typeableTab} is disabled. */ this.tabModeEnabled = false; /** * Current text input handler. Make sure to call * {@link TextInputHandler#dismiss} if you want to get rid of it; don't just * set this to null. */ this.currentTextInputHandler = null; this.tabFocusable = true; this.textHelper = new TextHelper(); this.variable = variable; this.callback = this.handleChange.bind(this); this.hideText = (_a = properties === null || properties === void 0 ? void 0 : properties.hideText) !== null && _a !== void 0 ? _a : false; this.wrapText = (_b = properties === null || properties === void 0 ? void 0 : properties.wrapText) !== null && _b !== void 0 ? _b : true; this.inputFilter = (_c = properties === null || properties === void 0 ? void 0 : properties.inputFilter) !== null && _c !== void 0 ? _c : null; this.typeableTab = (_d = properties === null || properties === void 0 ? void 0 : properties.typeableTab) !== null && _d !== void 0 ? _d : false; this._editingEnabled = (_e = properties === null || properties === void 0 ? void 0 : properties.editingEnabled) !== null && _e !== void 0 ? _e : true; } /** * {@link TextInput#hideText} watcher callback method. Sets * {@link TextInput#cursorOffsetDirty} and marks the whole widget as dirty. */ hideTextWatchCallback() { this.cursorOffsetDirty = true; this.markWholeAsDirty(); } /** Internal method for updating the caret rendering. */ markDirtyCaret() { if (this.blinkWasOn) { this.markWholeAsDirty(); } } handleChange() { // clamp cursor positions if the new text has a smaller length // than the old text const textLength = this.variable.value.length; if (this.cursorPos > textLength) { this.cursorPos = textLength; this.cursorOffsetDirty = true; } if (this.selectPos > textLength) { this.selectPos = textLength; this.cursorOffsetDirty = true; } // if the text input is selected (caret enabled), then reset the blink // time if (this.blinkStart !== 0) { this.blinkStart = Date.now(); } this.markWholeAsDirty(); } handleAttachment() { this.variable.watch(this.callback); } handleDetachment() { this.variable.unwatch(this.callback); } activate() { super.activate(); this.blinkStart = 0; this.moveCursorTo(0, false); } deactivate() { if (this.currentTextInputHandler) { this.currentTextInputHandler.dismiss(); } super.deactivate(); } onThemeUpdated(property = null) { super.onThemeUpdated(property); if (property === null || property === 'inputTextInnerPadding' || property === 'inputTextFont' || property === 'inputTextHeight' || property === 'inputTextSpacing') { this._layoutDirty = true; this.markWholeAsDirty(); this.cursorOffsetDirty = true; } else if (property === 'inputBackgroundFill' || property === 'inputTextFill' || property === 'inputTextFillInvalid' || property === 'inputTextFillDisabled') { this.markWholeAsDirty(); } else if (property === 'cursorThickness' || property === 'cursorIndicatorSize') { this.markDirtyCaret(); } else if (property === 'inputTextAlign') { this.cursorOffsetDirty = true; } } /** * Is the text cursor shown? * * @returns Returns true if the text cursor is shown, false if not shown but the text input is in use, or null if the text cursor is not shown due to the text input not being in use. */ get blinkOn() { if (this.blinkStart === 0) { return null; } const blinkRate = this.blinkRate; return Math.trunc(((Date.now() - this.blinkStart) / (500 * blinkRate)) % 2) === 0; } /** * Is editing enabled? * * If changed, the whole widget is marked as dirty. If disabled, blinking * stops and the cursor position is reset to the beginning. */ get editingEnabled() { return this._editingEnabled; } set editingEnabled(editingEnabled) { if (this._editingEnabled !== editingEnabled) { this._editingEnabled = editingEnabled; // Disable blinking and reset cursor position if disabled if (!editingEnabled) { this.blinkStart = 0; this.moveCursorTo(0, false); } // Mark as dirty; the text color changes this.markWholeAsDirty(); } } /** * The current text value. * * Should not be used internally as a setter (but using it as a getter is * fine); if you are extending TextInput, use this.variable.value instead. */ set text(text) { this.variable.value = text; } get text() { return this.variable.value; } /** * Get the text as it is shown. If the text is hidden, all characters are * replaced with a black circle. */ get displayedText() { if (this.hideText) { return '●'.repeat(this.variable.value.length); } else { return this.variable.value; } } /** The current line number, starting from 0. */ get line() { return this.textHelper.getLine(this.cursorPos); } get effectiveTabMode() { return this.typeableTab && this.tabModeEnabled; } /** Auto-scroll to the caret if the {@link blinkStart | caret is shown}. */ autoScrollCaret() { // Auto-scroll if caret is shown if (this.blinkStart !== 0) { this.needsAutoScroll = true; } } /** * Move the cursor to a given index. * * Marks the widget as dirty and sets {@link TextInput#cursorOffsetDirty} to * true. * * @param select - Should this do text selection? */ moveCursorTo(index, select) { // Update cursor position, checking for boundaries this.cursorPos = Math.min(Math.max(index, 0), this.text.length); if (!select) { this.selectPos = this.cursorPos; } if (this.currentTextInputHandler) { this.currentTextInputHandler.select(this.selectPos, this.cursorPos); } // Update cursor offset this.cursorOffsetDirty = true; this.markWholeAsDirty(); this.autoScrollCaret(); } /** * Move the cursor by a given index delta. Calls * {@link TextInput#moveCursorTo} * * @param delta - The change in index; if a positive number, the cursor will be moved right by that amount, else, the cursor will be moved left by that amount. */ moveCursor(delta, select) { this.moveCursorTo(this.cursorPos + delta, select); } /** * Move the cursor given a given pointer offset. * * @param offsetX - The horizontal offset in pixels, relative to the text area with padding removed * @param offsetY - The vertical offset in pixels, relative to the text area with padding removed * @param select - Should this do text selection? */ moveCursorFromOffset(offsetX, offsetY, select) { [this.cursorPos, this.cursorOffset] = this.textHelper.findIndexOffsetFromOffset([offsetX, offsetY]); if (!select) { this.selectPos = this.cursorPos; this.selectOffset = this.cursorOffset; } if (this.currentTextInputHandler) { this.currentTextInputHandler.select(this.selectPos, this.cursorPos); } // Start blinking cursor and mark component as dirty, to make sure that // the cursor blink always resets for better feedback this.blinkStart = Date.now(); this.markWholeAsDirty(); this.autoScrollCaret(); } /** * Move the cursor by a given line delta. Calls * {@link TextInput#moveCursorFromOffset} * * @param delta - The change in line; if a positive number, the cursor will be moved down by that amount, else, the cursor will be moved up by that amount. */ moveCursorLine(delta, select) { this.moveCursorFromOffset(this.cursorOffset[0], this.cursorOffset[1] + (0.5 + delta) * this.textHelper.fullLineHeight, select); } /** * Move the cursor to the start of the line. Calls * {@link TextInput#moveCursorTo} */ moveCursorStart(select) { this.moveCursorTo(this.textHelper.getLineStart(this.line), select); } /** * Move the cursor to the end of the line. Calls * {@link TextInput#moveCursorTo} */ moveCursorEnd(select) { this.moveCursorTo(this.textHelper.getLineEnd(this.line, false), select); } /** * Move the cursor by skipping over a number of words. Calls * {@link TextInput#moveCursorTo} * * @param delta - The change in words; if a positive number, the cursor skip this amount of words, else, it will do the same, but backwards. */ moveCursorWord(delta, select) { if (delta == 0) { return; } const wordRegex = /\w/; const text = this.text; let targetPos = this.cursorPos; if (delta > 0) { while (delta > 0) { let insideWord = false; for (; targetPos <= text.length; targetPos++) { if (targetPos < text.length && wordRegex.test(text[targetPos])) { insideWord = true; } else if (insideWord) { break; } } delta--; } } else { while (delta < 0) { targetPos--; let insideWord = false; for (; targetPos >= 0; targetPos--) { if (targetPos >= 0 && wordRegex.test(text[targetPos])) { insideWord = true; } else if (insideWord) { break; } } targetPos++; delta++; } } this.moveCursorTo(targetPos, select); } /** * Deletes a range of text and moves the cursor to the start of the range. * * @param start - The inclusive index of the start of the text range * @param end - The exclusive index of the end of the text range */ deleteRange(start, end) { if (start === end) { return; } // Delete text this.variable.value = this.text.substring(0, start) + this.text.substring(end); // Update cursor position this.cursorPos = this.selectPos = start; this.cursorOffsetDirty = true; this.autoScrollCaret(); } /** * Like {@link TextInput#moveCursorWord}, but for deleting words. Calls * {@link TextInput#moveCursorWord} and {@link TextInput#deleteRange}. If * text is being selected, delta is ignored and the selection is deleted * instead. Note that a delta of zero doesn't delete anything. */ deleteWord(delta) { if (delta === 0) { return; } // Delete selection if (this.cursorPos !== this.selectPos) { this.deleteRange(Math.min(this.cursorPos, this.selectPos), Math.max(this.cursorPos, this.selectPos)); return; } // Move cursor by wanted words const oldPos = this.cursorPos; this.moveCursorWord(delta, false); // If cursor position is different, delete if (oldPos !== this.cursorPos) { this.deleteRange(Math.min(oldPos, this.cursorPos), Math.max(oldPos, this.cursorPos)); } } /** * Insert text at the current cursor index. Calls * {@link TextInput#moveCursorTo} afterwards. */ insertText(str) { // Abort if input can't be inserted if (this.inputFilter !== null && !this.inputFilter(str)) { return; } if (this.selectPos === this.cursorPos) { // Insert string in current cursor position this.variable.value = this.text.substring(0, this.cursorPos) + str + this.text.substring(this.cursorPos); // Move cursor neccessary amount forward this.moveCursor(str.length, false); } else { const start = Math.min(this.cursorPos, this.selectPos); const end = Math.max(this.cursorPos, this.selectPos); // Replace text in selection with the one being inserted this.variable.value = this.text.substring(0, start) + str + this.text.substring(end); // Move cursor to end of selection after insert this.moveCursorTo(start + str.length, false); } } /** * Deletes a certain amount of characters in a given direction from the * current cursor index. Calls {@link TextInput#deleteRange} or * {@link TextInput#moveCursorTo} if neccessary. If text is being selected, * delta is ignored and the selection is deleted instead. Note that a delta * of zero doesn't delete anything. * * @param delta - The amount and direction of the deletion. For example, if 5, then 5 characters are deleted after the cursor. If -5, then 5 characters are deleted before the cursor and the cursor is moved 5 indices left. */ deleteText(delta) { if (delta === 0) { return; } if (this.cursorPos !== this.selectPos) { // Delete selection this.deleteRange(Math.min(this.cursorPos, this.selectPos), Math.max(this.cursorPos, this.selectPos)); } else if (delta > 0) { // Delete forwards this.variable.value = this.text.substring(0, this.cursorPos) + this.text.substring(this.cursorPos + delta); // XXX normally, deleting forwards doens't require updating the // cursor offset, but when there is text wrapping, delete can change // the cursor offset (pressing delete on a long word in the next // line, causing text wrapping to move the cursor to the previous // line). because of this edge case, mark the cursor offset as dirty this.cursorOffsetDirty = true; this.autoScrollCaret(); } else { // Delete backwards // XXX can't just move cursor back by the delta, because the // variable watcher clamps the cursor the the text's range; if a // single character is deleted at the end, then the cursor will // actually move by 2 positions instead of 1! to fix this, calculate // the wanted cursor index before changing the text const wantedIndex = Math.max(this.cursorPos + delta, 0); this.variable.value = this.text.substring(0, this.cursorPos + delta) + this.text.substring(this.cursorPos); this.moveCursorTo(wantedIndex, false); } } /** * Select a range of text (either word or non-word, but not both) which * includes the given cursor position * * @returns Returns a 2-tuple with, respectively, the start and end of the range */ selectRangeAt(pos) { const text = this.text; const wordRegex = /\w/; const isWord = wordRegex.test(text[pos]); const midPos = pos; // Grow left for (; pos >= 0; pos--) { if (wordRegex.test(text[pos]) !== isWord) { break; } } const startPos = pos + 1; // Grow right pos = midPos; for (; pos < text.length; pos++) { if (wordRegex.test(text[pos]) !== isWord) { break; } } this.autoScrollCaret(); return [startPos, pos]; } requestTextInput() { const root = this.root; if (!this.currentTextInputHandler) { const handler = root.getTextInput((...eventData) => { switch (eventData[0]) { case 0 /* TextInputHandlerEventType.Dismiss */: if (handler === this.currentTextInputHandler) { this.currentTextInputHandler = null; this.root.dropFocus(FocusType.Keyboard, this); } break; case 1 /* TextInputHandlerEventType.Input */: { const val = eventData[1]; if (this.inputFilter !== null && !this.inputFilter(val)) { return; } this.variable.value = val; this.cursorOffsetDirty = true; // falls through } case 2 /* TextInputHandlerEventType.MoveCursor */: { const oldSelectPos = this.selectPos; const oldCursorPos = this.cursorPos; this.selectPos = eventData[eventData.length - 2]; this.cursorPos = eventData[eventData.length - 1]; if (this.selectPos !== oldSelectPos || this.cursorPos !== oldCursorPos) { this.cursorOffsetDirty = true; this.markWholeAsDirty(); } } } }, this.variable.value); this.currentTextInputHandler = handler; } if (this.currentTextInputHandler) { this.currentTextInputHandler.askInput(this.variable.value, this.selectPos, this.cursorPos); root.requestFocus(FocusType.Keyboard, this); } } handleEvent(baseEvent) { if (baseEvent.propagation !== PropagationModel.Trickling) { if (baseEvent.isa(FocusEvent)) { // If keyboard focus is gained and the caret isn't shown yet, select the // last character and start blinking the caret if (baseEvent.focusType === FocusType.Keyboard && this.blinkStart === 0) { this.blinkStart = Date.now(); this.selectPos = this.variable.value.length; this.cursorPos = this.selectPos; this.cursorOffsetDirty = true; this.tabModeEnabled = false; this.autoScrollCaret(); // FIXME this would break text input handlers, so it's // disabled for now. this also means that tabbing into // a widget will NOT open a text input handler, which // is ok 90% of the time, but not perfect // this.requestTextInput(); } return this; } else if (baseEvent.isa(BlurEvent)) { // Stop blinking cursor if keyboard focus lost and stop dragging if // pointer focus is lost if (baseEvent.focusType === FocusType.Keyboard) { this.blinkStart = 0; if (this.currentTextInputHandler) { this.currentTextInputHandler.dismiss(); } } return this; } else { return super.handleEvent(baseEvent); } } // If editing is disabled, abort if (!this._editingEnabled) { return null; } const event = baseEvent; const root = this.root; if (event.isa(LeaveEvent)) { // Stop dragging if the pointer leaves the text input, since it // won't receive pointer release events outside the widget this.dragging = false; this.clearPointerStyle(); return this; } else if (event.isa(PointerWheelEvent)) { // Don't capture wheel events return null; } else if (event instanceof PointerEvent) { // If this is a pointer event, set pointer style and handle clicks this.requestPointerStyle('text'); // Request keyboard focus if this is a pointer press with the // primary button const isPressEvent = event.isa(PointerPressEvent); if (isPressEvent || event.isa(PointerMoveEvent)) { const isPress = isPressEvent && event.isPrimary; if (isPress) { this.dragging = true; const clickTime = (new Date()).getTime(); // Count successive clicks. Clicks counts as successive if // they come after the last click in less than 500 ms if (clickTime - this.lastClick < 500) { this.successiveClickCount++; // Wrap click counter around (there's no action above // triple click) if (this.successiveClickCount > 2) { this.successiveClickCount = 0; } } else { this.successiveClickCount = 0; } this.lastClick = clickTime; } else if (!this.dragging) { return this; } // Update cursor position (and offset) from click position const padding = this.inputTextInnerPadding; this.moveCursorFromOffset(event.x - this.idealX - padding + this.offset[0], event.y - this.idealY - padding + this.offset[1], (!isPress && this.dragging) || (isPress && event.shift)); if (isPress) { // Prevent successive clicks from one cursor position to // another from counting as successive clicks if (this.cursorPos !== this.dragStart) { this.successiveClickCount = 0; } this.dragStart = this.cursorPos; } if (this.successiveClickCount > 0) { let start, end; if (this.successiveClickCount === 1) { // If double-click dragging, select ranges of text // Get the text range at the cursor and at the start of the // double click drag, then mush them together into a single // range const [doubleStart, doubleEnd] = this.selectRangeAt(this.dragStart); const [curStart, curEnd] = this.selectRangeAt(this.cursorPos); start = Math.min(doubleStart, curStart); end = Math.max(doubleEnd, curEnd); } else { // If triple-click dragging, select lines of text const startPos = Math.min(this.cursorPos, this.dragStart); const startLine = this.textHelper.getLine(startPos); start = this.textHelper.getLineStart(startLine); const endPos = Math.max(this.cursorPos, this.dragStart); const endLine = this.textHelper.getLine(endPos); // Include newlines so that deleting a triple-click // selection deletes entire lines end = this.textHelper.getLineEnd(endLine); } // Set cursor positions. Get the drag direction and swap // cursor and select pos depending on the direction if (this.cursorPos >= this.dragStart) { this.selectPos = start; this.cursorPos = end; } else { this.selectPos = end; this.cursorPos = start; } this.cursorOffsetDirty = true; this.requestTextInput(); } // Request focus root.requestFocus(FocusType.Keyboard, this); } else if (event.isa(PointerReleaseEvent) && event.isPrimary) { // Stop dragging this.dragging = false; // Get text input handler if available this.requestTextInput(); } return this; } else if (event.isa(KeyPressEvent)) { // Stop dragging this.dragging = false; this.lastClick = 0; // Ignore all key presses with alt modifier if (event.alt) { return this; } // Ignore most key presses if control is pressed if (event.ctrl) { if (event.key === 'Backspace') { this.deleteWord(-1); // Delete word backwards } else if (event.key === 'Delete') { this.deleteWord(1); // Delete word forwards } else if (event.key === 'ArrowLeft') { this.moveCursorWord(-1, event.shift); // Back-skip a word } else if (event.key === 'ArrowRight') { this.moveCursorWord(1, event.shift); // Skip a word } else if (event.key === 'c' || event.key === 'C') { // Copy selected text to clipboard, if any if (this.cursorPos === this.selectPos) { return this; } const selectedText = this.text.slice(Math.min(this.cursorPos, this.selectPos), Math.max(this.cursorPos, this.selectPos)); if (navigator.clipboard) { navigator.clipboard.writeText(selectedText); } else { return this; } } else if (event.key === 'a' || event.key === 'A') { this.cursorPos = this.text.length; this.selectPos = 0; this.cursorOffsetDirty = true; this.markWholeAsDirty(); } else if (event.key === 'm' || event.key === 'M') { if (this.typeableTab) { this.tabModeEnabled = !this.tabModeEnabled; } } else { // XXX don't capture keys when pressing ctrl. chances are // that this is a keyboard shortcut that does something // special (like ctrl+v for pasting) return null; } // Reset blink time for better feedback this.blinkStart = Date.now(); return this; } // Regular key presses: if (event.key.length === 1) { this.insertText(event.key); // Insert character } else if (event.key === 'Backspace') { this.deleteText(-1); // Delete backwards } else if (event.key === 'Delete') { this.deleteText(1); // Delete forwards } else if (event.key === 'ArrowLeft') { this.moveCursor(-1, event.shift); // Move cursor left } else if (event.key === 'ArrowRight') { this.moveCursor(1, event.shift); // Move cursor right } else if (event.key === 'ArrowUp') { this.moveCursorLine(-1, event.shift); // Move cursor up } else if (event.key === 'ArrowDown') { this.moveCursorLine(1, event.shift); // Move cursor down } else if (event.key === 'PageUp' || event.key === 'PageDown') { // Move cursor up or down by the lines in the viewport height, // or a minimum of 3 lines const mul = event.key === 'PageUp' ? -1 : 1; const [_vpX, _vpY, _vpW, vpH] = this.viewport.rect; const lines = Math.max(Math.floor(vpH / this.textHelper.fullLineHeight), 3); this.moveCursorLine(lines * mul, event.shift); } else if (event.key === 'Home') { this.moveCursorStart(event.shift); // Move cursor to beginning } else if (event.key === 'End') { this.moveCursorEnd(event.shift); // Move cursor to end } else if (event.key === 'Escape') { // Return now so that blink time isn't reset. // Don't capture so that focus is dropped return null; } else if (event.key === 'Enter') { this.insertText('\n'); } else if (event.key === 'Tab') { if (this.typeableTab && (event.virtual || (!event.shift && this.tabModeEnabled))) { this.insertText('\t'); } else { return null; // don't capture, let tab select another widget } } else { return null; // Ignore key if it is unknown } // Reset blink time for better feedback this.blinkStart = Date.now(); } else if (event.isa(TextPasteEvent)) { if (event.target === this) { // Insert pasted text this.insertText(event.text); // Reset blink time for better feedback this.blinkStart = Date.now(); } } else if (event.target !== this) { // unhandled event type. don't capture return null; } return this; } handlePreLayoutUpdate() { // Drop focus if editing is disabled if (!this.editingEnabled) { this.root.dropFocus(FocusType.Keyboard, this); } // Mark as dirty when a blink needs to occur if (this.blinkOn !== this.blinkWasOn) { this.markWholeAsDirty(); } // Update TextHelper variables this.textHelper.text = this.displayedText; this.textHelper.font = this.inputTextFont; this.textHelper.lineHeight = this.inputTextHeight; this.textHelper.lineSpacing = this.inputTextSpacing; this.textHelper.alignMode = this.inputTextAlign; // Mark as dirty if text helper is dirty if (this.textHelper.dirty) { this.markWholeAsDirty(); this._layoutDirty = true; } } handlePostLayoutUpdate() { // Update cursor offset. Needs to be updated post-layout because it is // dependent on maxWidth. Round to nearest integer to avoid // anti-aliasing artifacts (cursor loses sharpness despite being fully // vertical) if (this.cursorOffsetDirty) { this.cursorOffset = this.textHelper.findOffsetFromIndex(this.cursorPos, true); if (this.selectPos === this.cursorPos) { this.selectOffset[0] = this.cursorOffset[0]; this.selectOffset[1] = this.cursorOffset[1]; } else { this.selectOffset = this.textHelper.findOffsetFromIndex(this.selectPos, true); } this.cursorOffsetDirty = false; } // Check if panning is needed const padding = this.inputTextInnerPadding; const innerWidth = this.textHelper.width; const innerHeight = this.textHelper.height; const usableWidth = this.idealWidth - padding * 2; const usableHeight = this.idealHeight - padding * 2; const candidateOffset = this.offset; const [cursorX, cursorY] = this.cursorOffset; if (innerWidth > usableWidth) { // Horizontal panning needed const deadZone = Math.min(20, usableWidth / 2); const left = candidateOffset[0] + deadZone; const right = candidateOffset[0] + usableWidth - deadZone; // Pan right if (cursorX > right) { candidateOffset[0] += cursorX - right; } // Pan left if (cursorX < left) { candidateOffset[0] -= left - cursorX; } // Clamp if (candidateOffset[0] + usableWidth > innerWidth) { candidateOffset[0] = innerWidth - usableWidth; } if (candidateOffset[0] < 0) { candidateOffset[0] = 0; } } else { // Horizontal panning not needed candidateOffset[0] = 0; } if (innerHeight > usableHeight) { // Vertical panning needed const fullLineHeight = this.textHelper.fullLineHeight; if (fullLineHeight >= usableHeight) { // Edge case - TextInput is not tall enough for a single line. // Pan so that at least the bottom of the line is visible candidateOffset[1] = cursorY + Math.max(this.textHelper.actualLineHeight - usableHeight, 0); } else { const deadZone = usableHeight < 2 * fullLineHeight ? 0 : fullLineHeight / 2; const top = candidateOffset[1] + deadZone; const bottom = candidateOffset[1] + usableHeight - deadZone - fullLineHeight; // Pan up or down if (cursorY < top) { candidateOffset[1] -= top - cursorY; } if (cursorY > bottom) { candidateOffset[1] += cursorY - bottom; } // Clamp if (candidateOffset[1] + usableHeight > innerHeight) { candidateOffset[1] = innerHeight - usableHeight; } if (candidateOffset[1] < 0) { candidateOffset[1] = 0; } } } else { // Vertical panning not needed candidateOffset[1] = 0; } this.offset = candidateOffset; if (this.needsAutoScroll) { this.needsAutoScroll = false; this.root.dispatchEvent(new AutoScrollEvent(this, this.caretBounds)); } } handleResolveDimensions(minWidth, maxWidth, minHeight, maxHeight) { // Only expand to the needed dimensions, but take minimum width from // theme into account const padding = 2 * this.inputTextInnerPadding; this.textHelper.maxWidth = this.wrapText ? Math.max(maxWidth - padding, 0) : Infinity; if (this.textHelper.dirty) { this.markWholeAsDirty(); } const effectiveMinWidth = Math.min(Math.max(this.inputTextMinWidth, minWidth), maxWidth); this.idealWidth = Math.min(Math.max(effectiveMinWidth, this.textHelper.width + padding), maxWidth); this.idealHeight = Math.min(Math.max(minHeight, this.textHelper.height + padding), maxHeight); } finalizeBounds() { const oldWidth = this.width; const oldHeight = this.height; super.finalizeBounds(); if (oldWidth !== this.width || oldHeight !== this.height) { this.cursorOffsetDirty = true; } } /** * The rectangle that the caret occupies, relative to the TextInput's * top-left corner. */ get caretRect() { const padding = this.inputTextInnerPadding; return [ padding + this.cursorOffset[0] - this.offset[0], padding + this.cursorOffset[1] - this.offset[1], this.cursorThickness, this.textHelper.fullLineHeight, ]; } /** Similar to {@link TextInput#caretRect}, but uses absolute positions. */ get caretAbsoluteRect() { const [x, y, w, h] = this.caretRect; return [x + this.idealX, y + this.idealY, w, h]; } /** Similar to {@link TextInput#caretRect}, but gets bounds instead. */ get caretBounds() { const [x, y, w, h] = this.caretRect; return [x, x + w, y, y + h]; } handlePainting(_dirtyRects) { // Paint background const ctx = this.viewport.context; ctx.fillStyle = this.inputBackgroundFill; ctx.fillRect(this.x, this.y, this.width, this.height); // Start clipping ctx.save(); ctx.beginPath(); ctx.rect(this.x, this.y, this.width, this.height); ctx.clip(); // Paint background for selection if there is a selection const padding = this.inputTextInnerPadding; if (this.cursorPos !== this.selectPos && this.blinkOn !== null) { ctx.fillStyle = this.inputSelectBackgroundFill; if (this.cursorOffset[1] === this.selectOffset[1]) { // Same line const left = Math.min(this.cursorOffset[0], this.selectOffset[0]); const right = Math.max(this.cursorOffset[0], this.selectOffset[0]); ctx.fillRect(this.idealX + padding + left - this.offset[0], this.idealY + padding + this.cursorOffset[1] - this.offset[1], right - left, this.textHelper.fullLineHeight); } else { // Spans multiple lines let topOffset, bottomOffset; if (this.cursorOffset[1] < this.selectOffset[1]) { topOffset = this.cursorOffset; bottomOffset = this.selectOffset; } else { bottomOffset = this.cursorOffset; topOffset = this.selectOffset; } // Top line: const fullLineHeight = this.textHelper.fullLineHeight; const topWidth = this.idealWidth + this.offset[0] - topOffset[0] - padding; if (topWidth > 0) { ctx.fillRect(this.idealX + padding + topOffset[0] - this.offset[0], this.idealY + padding + topOffset[1] - this.offset[1], topWidth, fullLineHeight); } // Bottom line: const bottomWidth = bottomOffset[0] + padding - this.offset[0]; if (bottomWidth > 0) { ctx.fillRect(this.idealX, this.idealY + padding + bottomOffset[1] - this.offset[1], bottomWidth, fullLineHeight); } // Middle lines: const middleYOffset = topOffset[1] + fullLineHeight; const middleHeight = bottomOffset[1] - middleYOffset; if (middleHeight > 0) { ctx.fillRect(this.idealX, this.idealY + padding + middleYOffset - this.offset[1], this.idealWidth, middleHeight); } } } // Paint current text value let fillStyle; if (this._editingEnabled) { const valid = this.variable.valid; if (valid || valid === undefined) { fillStyle = this.inputTextFill; } else { fillStyle = this.inputTextFillInvalid; } } else { fillStyle = this.inputTextFillDisabled; } this.textHelper.paint(ctx, fillStyle, this.idealX + padding - this.offset[0], this.idealY + padding - this.offset[1]); // Paint blink const blinkOn = this.blinkOn; this.blinkWasOn = blinkOn; if (blinkOn) { ctx.fillStyle = fillStyle; const [cx, cy, cw, ch] = this.caretAbsoluteRect; ctx.fillRect(cx, cy, cw, ch); if (this.effectiveTabMode) { const indicatorSize = Math.min(ch, this.cursorIndicatorSize); const indicatorThickness = Math.min(cw * 0.4, this.cursorThickness); // pre-calc arrow points: // 2----3 <- top // `\. `\. // 0--------1 4 <- mty (mid top Y) // | | // 9--------8 5 <- mby (mid bottom Y) // ,/' ,/' // 7----6 <- bottom // // ^ ^ ^^ ^ // | | || | // left | || right // | |mrx (mid right x) // | ix (inner X) // mlx (mid left x) const halfSize = indicatorSize / 2; const halfThickness = indicatorThickness / 2; const left = cx + cw * 2; const unroundedRight = left + indicatorSize; const right = Math.ceil(unroundedRight); const top = Math.floor(cy); const unroundedBottom = cy + indicatorSize; const bottom = Math.ceil(unroundedBottom); const midX = left + halfSize; const mlx = Math.floor(midX - halfThickness); const mrx = Math.ceil(midX + halfThickness); const ix = Math.floor(unroundedRight - halfThickness); const midY = cy + halfSize; const mty = Math.floor(midY - halfThickness); const mby = Math.ceil(midY + halfThickness); // paint tab indicator arrow ctx.beginPath(); ctx.moveTo(left, mty); ctx.lineTo(ix, mty); ctx.lineTo(mlx, top); ctx.lineTo(mrx, top); ctx.lineTo(right, mty); ctx.lineTo(right, mby); ctx.lineTo(mrx, bottom); ctx.lineTo(mlx, bottom); ctx.lineTo(ix, mby); ctx.lineTo(left, mby); ctx.closePath(); ctx.fill(); } } // Stop clipping ctx.restore(); } } TextInput.autoXML = { name: 'text-input', inputConfig: [ { mode: 'value', name: 'variable', validator: 'box', optional: true } ] }; __decorate([ watchField(TextInput.prototype.hideTextWatchCallback) ], TextInput.prototype, "hideText", void 0); __decorate([ damageArrayField() ], TextInput.prototype, "offset", void 0); __decorate([ layoutField ], TextInput.prototype, "wrapText", void 0); __decorate([ watchField(TextInput.prototype.markDirtyCaret) ], TextInput.prototype, "typeableTab", void 0); __decorate([ watchField(TextInput.prototype.markDirtyCaret) ], TextInput.prototype, "tabModeEnabled", void 0); //# sourceMappingURL=TextInput.js.map