@pmndrs/uikit
Version:
Build performant 3D user interfaces with Three.js and yoga.
290 lines (289 loc) • 15.7 kB
JavaScript
import { createFlexNodeState } from '../flex/index.js';
import { createHoverPropertyTransformers, setupCursorCleanup } from '../hover.js';
import { computedIsClipped } from '../clipping.js';
import { setupInstancedPanel } from '../panel/instanced-panel.js';
import { setupObjectTransform, computedTransformMatrix } from '../transform.js';
import { computedInheritableProperty, computedNonInheritableProperty, traverseProperties, } from '../properties/index.js';
import { createResponsivePropertyTransformers } from '../responsive.js';
import { computedOrderInfo, ElementType } from '../order.js';
import { createActivePropertyTransfomers } from '../active.js';
import { computed, signal } from '@preact/signals-core';
import { computedGlobalMatrix, computedHandlers, computedIsVisible, computedMergedProperties, setupNode, setupMatrixWorldUpdate, setupPointerEvents, computedAncestorsHaveListeners, } from './utils.js';
import { abortableEffect, readReactive } from '../utils.js';
import { setupLayoutListeners, setupClippedListeners } from '../listeners.js';
import { computedPanelGroupDependencies } from '../panel/instanced-panel-group.js';
import { createInteractionPanel, setupInteractionPanel } from '../panel/instanced-panel-mesh.js';
import { createCaret } from '../caret.js';
import { createSelection } from '../selection.js';
import { createFocusPropertyTransformers } from '../focus.js';
import { computedFont, computedGylphGroupDependencies, createInstancedText, } from '../text/index.js';
import { darkPropertyTransformers } from '../dark.js';
import { getDefaultPanelMaterialConfig } from '../panel/index.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 function createInputState(parentCtx, fontFamilies, style, properties, defaultProperties) {
const flexState = createFlexNodeState();
const hoveredSignal = signal([]);
const activeSignal = signal([]);
const hasFocusSignal = signal(false);
const mergedProperties = computedMergedProperties(style, properties, defaultProperties, {
...darkPropertyTransformers,
...createResponsivePropertyTransformers(parentCtx.root.size),
...createHoverPropertyTransformers(hoveredSignal),
...createActivePropertyTransfomers(activeSignal),
...createFocusPropertyTransformers(hasFocusSignal),
}, undefined, (m) => {
traverseProperties(style.value, properties.value, defaultProperties.value, (p) => {
m.add('caretOpacity', p.opacity);
m.add('caretColor', p.color);
});
});
const transformMatrix = computedTransformMatrix(mergedProperties, flexState, parentCtx.root.pixelSize);
const globalMatrix = computedGlobalMatrix(parentCtx.childrenMatrix, transformMatrix);
const isClipped = computedIsClipped(parentCtx.clippingRect, globalMatrix, flexState.size, parentCtx.root.pixelSize);
const isVisible = computedIsVisible(flexState, isClipped, mergedProperties);
const backgroundGroupDeps = computedPanelGroupDependencies(mergedProperties);
const backgroundOrderInfo = computedOrderInfo(mergedProperties, 'zIndexOffset', ElementType.Panel, backgroundGroupDeps, parentCtx.orderInfo);
const selectionTransformations = signal([]);
const caretTransformation = signal(undefined);
const selectionRange = signal(undefined);
const fontSignal = computedFont(mergedProperties, fontFamilies, parentCtx.root.renderer);
const orderInfo = computedOrderInfo(undefined, 'zIndexOffset', ElementType.Text, computedGylphGroupDependencies(fontSignal), backgroundOrderInfo);
const defaultValue = style.peek()?.defaultValue ?? properties.peek()?.defaultValue;
const writeValue = style.peek()?.value == null && properties.peek()?.value == null ? signal(defaultValue ?? '') : undefined;
const valueSignal = computed(() => writeValue?.value ?? readReactive(style.value?.value) ?? readReactive(properties.value?.value) ?? '');
const type = computedNonInheritableProperty(style, properties, 'type', 'text');
const displayValueSignal = computed(() => type.value === 'text' ? valueSignal.value : '*'.repeat(valueSignal.value.length ?? 0));
const disabled = computedNonInheritableProperty(style, properties, 'disabled', false);
const updateMatrixWorld = computedInheritableProperty(mergedProperties, 'updateMatrixWorld', false);
const instancedTextRef = {};
const focus = (start, end, direction) => {
if (!hasFocusSignal.peek()) {
element.focus();
}
if (start != null && end != null) {
element.setSelectionRange(start, end, direction);
}
selectionRange.value = [element.selectionStart ?? 0, element.selectionEnd ?? 0];
};
const selectionHandlers = computedSelectionHandlers(type, valueSignal, flexState, instancedTextRef, focus, disabled);
const multiline = style.peek()?.multiline ?? properties.peek()?.multiline ?? false;
const element = createHtmlInputElement(selectionRange, (newValue) => {
if (writeValue != null) {
writeValue.value = newValue;
}
style.peek()?.onValueChange?.(newValue);
properties.peek()?.onValueChange?.(newValue);
}, multiline);
return Object.assign(flexState, {
multiline,
element,
instancedTextRef,
interactionPanel: createInteractionPanel(backgroundOrderInfo, parentCtx.root, parentCtx.clippingRect, globalMatrix, flexState),
hoveredSignal,
activeSignal,
hasFocusSignal,
mergedProperties,
transformMatrix,
globalMatrix,
isClipped,
isVisible,
backgroundGroupDeps,
backgroundOrderInfo,
orderInfo,
selectionTransformations,
caretTransformation,
selectionRange,
fontSignal,
valueSignal,
writeValue,
type,
displayValueSignal,
disabled,
updateMatrixWorld,
root: parentCtx.root,
handlers: computedHandlers(style, properties, defaultProperties, hoveredSignal, activeSignal, selectionHandlers, 'text'),
focus,
blur() {
element.blur();
selectionRange.value = undefined;
},
});
}
export function setupInput(state, parentCtx, style, properties, defaultProperties, object, abortSignal) {
setupCursorCleanup(state.hoveredSignal, abortSignal);
setupNode(state, parentCtx, object, false, abortSignal);
setupObjectTransform(parentCtx.root, object, state.transformMatrix, abortSignal);
setupInstancedPanel(state.mergedProperties, state.backgroundOrderInfo, state.backgroundGroupDeps, parentCtx.root.panelGroupManager, state.globalMatrix, state.size, undefined, state.borderInset, parentCtx.clippingRect, state.isVisible, getDefaultPanelMaterialConfig(), abortSignal);
createCaret(state.mergedProperties, state.globalMatrix, state.caretTransformation, state.isVisible, state.backgroundOrderInfo, state.backgroundGroupDeps, parentCtx.clippingRect, parentCtx.root.panelGroupManager, abortSignal);
createSelection(state.mergedProperties, state.globalMatrix, state.selectionTransformations, state.isVisible, state.backgroundOrderInfo, state.backgroundGroupDeps, parentCtx.clippingRect, parentCtx.root.panelGroupManager, abortSignal);
const customLayouting = createInstancedText(state.mergedProperties, state.displayValueSignal, state.globalMatrix, state.node, state, state.isVisible, parentCtx.clippingRect, state.orderInfo, state.fontSignal, parentCtx.root.gylphGroupManager, state.selectionRange, state.selectionTransformations, state.caretTransformation, state.instancedTextRef, state.multiline ? 'break-word' : 'keep-all', abortSignal);
abortableEffect(() => state.node.value?.setCustomLayouting(customLayouting.value), abortSignal);
setupInteractionPanel(state.interactionPanel, state.root, state.globalMatrix, state.size, abortSignal);
setupMatrixWorldUpdate(state.updateMatrixWorld, false, object, state.root, state.globalMatrix, false, abortSignal);
setupMatrixWorldUpdate(state.updateMatrixWorld, false, state.interactionPanel, state.root, state.globalMatrix, true, abortSignal);
setupLayoutListeners(style, properties, state.size, abortSignal);
setupClippedListeners(style, properties, state.isClipped, abortSignal);
setupHtmlInputElement(state.element, state.valueSignal, state.type, state.disabled, computedNonInheritableProperty(style, properties, 'tabIndex', 0), computedNonInheritableProperty(style, properties, 'autocomplete', ''), abortSignal);
setupUpdateHasFocus(state.element, state.hasFocusSignal, (hasFocus) => {
properties.peek()?.onFocusChange?.(hasFocus);
style.peek()?.onFocusChange?.(hasFocus);
}, abortSignal);
const ancestorsHaveListeners = computedAncestorsHaveListeners(parentCtx, state.handlers);
setupPointerEvents(state.mergedProperties, ancestorsHaveListeners, parentCtx.root, state.interactionPanel, false, abortSignal);
}
const segmenter = typeof Intl === 'undefined' ? undefined : new Intl.Segmenter(undefined, { granularity: 'word' });
export function computedSelectionHandlers(type, text, flexState, instancedTextRef, focus, disabled) {
return computed(() => {
if (disabled.value) {
return undefined;
}
let dragState;
const onPointerFinish = (e) => {
if (dragState == null || dragState.pointerId != e.pointerId) {
return;
}
e.stopImmediatePropagation?.();
dragState = undefined;
};
return {
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(flexState, e.uv, instancedTextRef.current, 'between');
dragState = {
pointerId: e.pointerId,
startCharIndex,
};
setTimeout(() => focus(startCharIndex, startCharIndex));
},
onDoubleClick: (e) => {
if (segmenter == null || e.uv == null || instancedTextRef.current == null) {
return;
}
e.stopImmediatePropagation?.();
if (type.peek() === 'password') {
setTimeout(() => focus(0, text.peek().length, 'none'));
return;
}
const charIndex = uvToCharIndex(flexState, 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?.pointerId != e.pointerId || e.uv == null || instancedTextRef.current == null) {
return;
}
e.stopImmediatePropagation?.();
const charIndex = uvToCharIndex(flexState, 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));
},
};
});
}
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(element, value, type, disabled, tabIndex, autocomplete, abortSignal) {
document.body.appendChild(element);
abortSignal.addEventListener('abort', () => element.remove());
abortableEffect(() => void (element.value = value.value), abortSignal);
abortableEffect(() => void (element.disabled = disabled.value), abortSignal);
abortableEffect(() => void (element.tabIndex = tabIndex.value), abortSignal);
abortableEffect(() => void (element.autocomplete = autocomplete.value), abortSignal);
abortableEffect(() => element.setAttribute('type', type.value), 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);
}