vlens
Version:
Data Centric Routing & Rendering Mini-Framework
323 lines (284 loc) • 8.06 kB
text/typescript
import * as core from "./core";
import * as refs from "./refs";
import * as cache from "./cache";
/** @deprecated */
export function readNumberAttr(
el: HTMLElement | EventTarget | null,
attr: string,
): number {
if (el instanceof HTMLElement) {
return parseInt(el.getAttribute(attr) ?? "");
} else {
return Number.NaN;
}
}
/** @deprecated */
export function readObjectRefAttr<T>(
el: HTMLElement | EventTarget | null,
attr: string,
): T | null {
return refs.objectById<T>(readNumberAttr(el, attr));
}
function onInput(ref: refs.Ref, transform: Transform<any, string> | undefined, event: Event) {
let target = event.target as HTMLInputElement;
let refValue = refs.get(ref);
let value: any = target.value;
if (target.type === "checkbox") {
value = target.checked;
}
if (target.type === "radio") {
value = target.value;
}
if (target.type === "number" || typeof refValue === "number") {
value = parseInt(value);
}
if (ref.obj instanceof Storage) {
value = JSON.stringify(value);
}
if (transform) {
transform(ref, value)
} else {
refs.set(ref, value);
}
core.scheduleRedraw();
}
export type RedrawMode = "regular" | "container" | "off";
export type Transform<T, R> = (r: refs.Ref<T>, v?: R) => R
export interface InputOptions {
redraw?: RedrawMode;
transform?: Transform<any, string>;
radio?: boolean
initial?: any;
}
export function inputAttrs(ref?: refs.Ref, options: InputOptions = {}): any {
if (!ref) {
return {};
}
let value = refs.get(ref);
if (ref.obj instanceof Storage) {
value = safeJsonParse(value, options.initial);
}
if (options?.transform) {
value = options.transform(ref)
}
let isBoolean = typeof value === "boolean" || options.radio
let valueField = isBoolean ? "checked" : "value";
return {
[valueField]: value,
onInput: cache.partial(onInput, ref, options.transform),
};
}
export function contentEditableAttrs(ref: refs.Ref<string>): any {
let value = refs.get(ref);
return {
onInput: cache.partial(onInputContentEditable, ref),
contentEditable: true,
dangerouslySetInnerHTML: { __html: value },
};
}
// content editable caret
type CECaret = {
activeElement: Element | null;
// -1 for no selection
start: number;
end: number;
};
// given a childNode and childNodeOffset, return an offset relative to the focusNode
function offsetWithinFocusNode(
focusNode: Node,
childNode: Node,
childNodeOffset: number,
): number {
let currentOffset = 0;
for (let i = 0; i < focusNode.childNodes.length; i++) {
let n = focusNode.childNodes[i];
if (childNode === n) {
return currentOffset + childNodeOffset;
} else if (childNode.contains(n)) {
return (
currentOffset +
offsetWithinFocusNode(n, childNode, childNodeOffset)
);
} else {
let t = n.textContent;
if (t) {
currentOffset += t.length;
}
}
}
return -1;
}
function offsetContainerOfCaret(
focusNode: Node,
offset: number,
): [Node, number] | null {
if (focusNode.childNodes.length === 0) {
return [focusNode, offset];
}
let currentOffset = 0;
for (let i = 0; i < focusNode.childNodes.length; i++) {
let n = focusNode.childNodes[i];
let t = n.textContent;
let nextOffset = currentOffset;
if (t) {
nextOffset += t.length;
}
if (n.nodeName === "BR") {
nextOffset++;
}
if (nextOffset >= offset) {
// if node has no child nodes, just return the node itself.
// if node has child nodes, recurse
let relativeOffset = offset - currentOffset;
if (n.childNodes.length > 0) {
return offsetContainerOfCaret(n, relativeOffset);
} else {
return [n, relativeOffset];
}
}
currentOffset = nextOffset;
}
return null;
}
function getCaret(): CECaret {
let sel = getSelection();
let activeElement = document.activeElement;
let start = -1;
let end = -1;
if (activeElement && sel && sel.rangeCount > 0) {
let range = sel.getRangeAt(0);
start = offsetWithinFocusNode(
activeElement,
range.startContainer,
range.startOffset,
);
if (range.collapsed) {
end = start;
} else {
end = offsetWithinFocusNode(
activeElement,
range.endContainer,
range.endOffset,
);
}
}
return {
activeElement,
start,
end,
};
}
function setCaret(caret: CECaret) {
if (caret.start == -1 || caret.end == -1) {
return;
}
if (!caret.activeElement) {
return;
}
let sel = window.getSelection();
if (!sel) {
console.log("can't set caret; no selection");
return;
}
if (caret.activeElement !== document.activeElement) {
// (caret.activeElement as HTMLElement).focus();
return;
}
let range = document.createRange();
let rangeStart = offsetContainerOfCaret(caret.activeElement, caret.start);
let rangeEnd = offsetContainerOfCaret(caret.activeElement, caret.end);
if (rangeStart && rangeEnd) {
range.setStart(...rangeStart);
range.setEnd(...rangeEnd);
} else {
return;
}
sel.removeAllRanges();
sel.addRange(range);
}
function onInputContentEditable(ref: refs.Ref, event: InputEvent) {
const caret = getCaret();
// console.log("Caret:", caret)
let currentTarget = event.currentTarget as HTMLElement;
let content = currentTarget.innerHTML;
refs.set(ref, content);
core.scheduleRedraw();
requestAnimationFrame(() => {
setCaret(caret);
});
}
// TODO: support this usecase within inputAttrs
export function radioAttrs<T extends number | string>(
ref: refs.Ref<T>,
option: T,
) {
return {
value: option,
checked: refs.get(ref) === option,
onInput: cache.partial(onInput, ref, undefined),
};
}
export function toggleButtonAttrs(ref?: refs.Ref<boolean>) {
if (!ref) {
return {}
}
return {
onClick: cache.partial(onToggleClick, ref),
};
}
function onToggleClick(ref: refs.Ref<boolean>, event: Event) {
refs.set(ref, !refs.get(ref));
core.scheduleRedraw();
}
// for boolean refs whose purpose is to know about click events
export function consumeBooleanRef(b: refs.Ref<boolean>): boolean {
let result = refs.get(b);
if (result) {
refs.set(b, false);
}
return result;
}
function safeJsonParse(
raw: string | undefined | null,
fallback?: unknown,
): unknown {
if (!raw) {
return fallback;
}
try {
return JSON.parse(raw);
} catch (e) {
return fallback;
}
}
export function elementRefAttrs(ref?: refs.Ref<HTMLElement | null>): any {
if (!ref) {
return {}
}
return {
"listen-create": true,
"listen-mutate": true,
oncreate: cache.partial(eventSetRef, ref),
onmutate: cache.partial(eventSetRef, ref),
};
}
export function eventSetRef(ref: refs.Ref<HTMLElement | null>, event: CustomEvent) {
refs.set(ref, event.currentTarget);
}
function onHover(ref: refs.Ref<boolean>, event: Event) {
if (event.type === "mouseenter") {
refs.set(ref, true);
} else if (event.type === "mouseleave") {
refs.set(ref, false);
}
core.scheduleRedraw();
}
export function trackHoverAttrs(ref?: refs.Ref<boolean>) {
if (!ref) {
return {}
}
return {
onMouseEnter: cache.partial(onHover, ref),
onMouseLeave: cache.partial(onHover, ref),
};
}