@pmndrs/uikit
Version:
Build performant 3D user interfaces with Three.js and yoga.
240 lines (239 loc) • 10.7 kB
JavaScript
import { computed, signal } from '@preact/signals-core';
import { abortableEffect } from '../utils.js';
import { Text, textDefaults } from './text.js';
import { setupCaret } from '../caret.js';
import { createSelection } from '../selection.js';
const cancelSet = new Set();
function cancelBlur(event) {
cancelSet.add(event);
}
export const canvasInputProps = {
onPointerDown: (e) => {
if (!(document.activeElement instanceof HTMLElement)) {
return;
}
if (!cancelSet.has(e.nativeEvent)) {
return;
}
cancelSet.delete(e.nativeEvent);
e.preventDefault();
},
};
export const inputDefaults = {
...textDefaults,
type: 'text',
disabled: false,
tabIndex: 0,
autocomplete: '',
whiteSpace: 'pre',
};
export class Input extends Text {
element;
selectionRange;
hasFocus;
uncontrolledSignal = signal(undefined);
currentSignal = computed(() => this.properties.value.value ?? this.uncontrolledSignal.value ?? this.properties.value.defaultValue ?? '');
constructor(inputProperties, initialClasses, config) {
const caretColor = signal(undefined);
const selectionHandlers = signal(undefined);
const selectionTransformations = signal([]);
const caretTransformation = signal(undefined);
const instancedTextRef = {};
const selectionRange = signal(undefined);
const hasFocus = signal(false);
super(inputProperties, initialClasses, {
defaults: inputDefaults,
dynamicHandlers: selectionHandlers,
selectionRange,
selectionTransformations,
caretTransformation,
instancedTextRef,
hasFocus,
isPlaceholder: computed(() => this.currentSignal.value.length === 0),
...config,
defaultOverrides: {
cursor: 'text',
...{
text: computed(() => this.currentSignal.value.length === 0
? this.properties.value.placeholder
: this.properties.value.type === 'password'
? '*'.repeat(this.currentSignal.value.length ?? 0)
: this.currentSignal.value),
},
caretColor,
...config?.defaultOverrides,
},
});
this.selectionRange = selectionRange;
this.hasFocus = hasFocus;
abortableEffect(() => void (caretColor.value = this.properties.value.color), this.abortSignal);
setupSelectionHandlers(selectionHandlers, this.properties, this.currentSignal, this, instancedTextRef, this.focus.bind(this), this.abortSignal);
const parentClippingRect = computed(() => this.parentContainer.value?.clippingRect.value);
this.element = createHtmlInputElement(selectionRange, (newValue) => {
if (this.properties.peek().value == null) {
this.uncontrolledSignal.value = newValue;
}
this.properties.peek().onValueChange?.(newValue);
}, config?.multiline ?? false);
setupCaret(this.properties, this.globalTextMatrix, caretTransformation, this.isVisible, this.backgroundOrderInfo, this.backgroundGroupDeps, parentClippingRect, this.root, this.abortSignal);
createSelection(this.properties, this.root, this.globalTextMatrix, selectionTransformations, this.isVisible, this.backgroundOrderInfo, this.backgroundGroupDeps, parentClippingRect, this.abortSignal);
setupHtmlInputElement(this.properties, this.element, this.currentSignal, this.abortSignal);
setupUpdateHasFocus(this.element, this.hasFocus, (hasFocus) => {
this.properties.peek().onFocusChange?.(hasFocus);
}, this.abortSignal);
}
focus(start, end, direction) {
if (!this.hasFocus.peek()) {
this.element.focus();
}
if (start != null && end != null) {
this.element.setSelectionRange(start, end, direction);
}
this.selectionRange.value = [this.element.selectionStart ?? 0, this.element.selectionEnd ?? 0];
}
}
const segmenter = typeof Intl === 'undefined' ? undefined : new Intl.Segmenter(undefined, { granularity: 'word' });
export function setupSelectionHandlers(target, properties, text, component, instancedTextRef, focus, abortSignal) {
abortableEffect(() => {
if (properties.value.disabled) {
target.value = undefined;
return;
}
let dragState;
const onPointerFinish = (e) => {
if (dragState == null || dragState.pointerId != e.pointerId) {
return;
}
e.stopImmediatePropagation?.();
dragState = undefined;
};
target.value = {
onPointerDown: (e) => {
if (dragState != null || e.uv == null || instancedTextRef.current == null) {
return;
}
cancelBlur(e.nativeEvent);
e.stopImmediatePropagation?.();
if ('setPointerCapture' in e.object && typeof e.object.setPointerCapture === 'function') {
e.object.setPointerCapture(e.pointerId);
}
const startCharIndex = uvToCharIndex(component, e.uv, instancedTextRef.current, 'between');
dragState = {
pointerId: e.pointerId,
startCharIndex,
};
setTimeout(() => focus(startCharIndex, startCharIndex));
},
onDblClick: (e) => {
if (segmenter == null || e.uv == null || instancedTextRef.current == null) {
return;
}
e.stopImmediatePropagation?.();
if (properties.peek().type === 'password') {
setTimeout(() => focus(0, text.peek().length, 'none'));
return;
}
const charIndex = uvToCharIndex(component, e.uv, instancedTextRef.current, 'on');
const segments = segmenter.segment(text.peek());
let segmentLengthSum = 0;
for (const { segment } of segments) {
const segmentLength = segment.length;
if (charIndex < segmentLengthSum + segmentLength) {
setTimeout(() => focus(segmentLengthSum, segmentLengthSum + segmentLength, 'none'));
break;
}
segmentLengthSum += segmentLength;
}
},
onPointerUp: onPointerFinish,
onPointerLeave: onPointerFinish,
onPointerCancel: onPointerFinish,
onPointerMove: (e) => {
if (dragState == null ||
dragState?.pointerId != e.pointerId ||
e.uv == null ||
instancedTextRef.current == null) {
return;
}
e.stopImmediatePropagation?.();
const charIndex = uvToCharIndex(component, e.uv, instancedTextRef.current, 'between');
const start = Math.min(dragState.startCharIndex, charIndex);
const end = Math.max(dragState.startCharIndex, charIndex);
const direction = dragState.startCharIndex < charIndex ? 'forward' : 'backward';
setTimeout(() => focus(start, end, direction));
},
};
}, abortSignal);
}
export function createHtmlInputElement(selectionRange, onChange, multiline) {
const element = document.createElement(multiline ? 'textarea' : 'input');
const style = element.style;
style.setProperty('position', 'absolute');
style.setProperty('left', '-1000vw');
style.setProperty('top', '0');
style.setProperty('pointerEvents', 'none');
style.setProperty('opacity', '0');
element.addEventListener('input', () => {
onChange?.(element.value);
updateSelection();
});
const updateSelection = () => {
const { selectionStart, selectionEnd } = element;
if (selectionStart == null || selectionEnd == null) {
selectionRange.value = undefined;
return;
}
const current = selectionRange.peek();
if (current != null && current[0] === selectionStart && current[1] === selectionEnd) {
return;
}
selectionRange.value = [selectionStart, selectionEnd];
};
element.addEventListener('keydown', updateSelection);
element.addEventListener('keyup', updateSelection);
element.addEventListener('blur', () => (selectionRange.value = undefined));
return element;
}
function setupHtmlInputElement(properties, element, value, abortSignal) {
document.body.appendChild(element);
abortSignal.addEventListener('abort', () => element.remove());
abortableEffect(() => void (element.value = value.value), abortSignal);
abortableEffect(() => void (element.disabled = properties.value.disabled), abortSignal);
abortableEffect(() => void (element.tabIndex = properties.value.tabIndex), abortSignal);
abortableEffect(() => void (element.autocomplete = properties.value.autocomplete), abortSignal);
abortableEffect(() => element.setAttribute('type', properties.value.type), abortSignal);
}
function setupUpdateHasFocus(element, hasFocusSignal, onFocusChange, abortSignal) {
if (abortSignal.aborted) {
return;
}
hasFocusSignal.value = document.activeElement === element;
const listener = () => {
const hasFocus = document.activeElement === element;
if (hasFocus == hasFocusSignal.value) {
return;
}
hasFocusSignal.value = hasFocus;
onFocusChange(hasFocus);
};
element.addEventListener('focus', listener);
element.addEventListener('blur', listener);
abortSignal.addEventListener('abort', () => {
element.removeEventListener('focus', listener);
element.removeEventListener('blur', listener);
});
}
function uvToCharIndex({ size: s, borderInset: b, paddingInset: p }, uv, instancedText, position) {
const size = s.peek();
const borderInset = b.peek();
const paddingInset = p.peek();
if (size == null || borderInset == null || paddingInset == null) {
return 0;
}
const [width, height] = size;
const [bTop, , , bLeft] = borderInset;
const [pTop, , , pLeft] = paddingInset;
const x = uv.x * width - bLeft - pLeft;
const y = (uv.y - 1) * height + bTop + pTop;
return instancedText.getCharIndex(x, y, position);
}