mirror of
https://github.com/unanmed/HumanBreak.git
synced 2025-06-15 04:57:59 +08:00
521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
import { DefaultProps } from '@motajs/render-vue';
|
||
import { computed, defineComponent, onUnmounted, ref, watch } from 'vue';
|
||
import { TextContent, TextContentProps } from './textbox';
|
||
import { RectRCircleParams } from '@motajs/render-elements';
|
||
import {
|
||
Container,
|
||
ElementLocator,
|
||
MotaRenderer,
|
||
RenderItem,
|
||
Transform
|
||
} from '@motajs/render-core';
|
||
import { Font } from '@motajs/render-style';
|
||
import { transitionedColor } from '../use';
|
||
import { linear } from 'mutate-animate';
|
||
import { Background, Selection } from './misc';
|
||
import { GameUI, IUIMountable, SetupComponentOptions } from '@motajs/system-ui';
|
||
|
||
export interface InputProps extends DefaultProps, Partial<TextContentProps> {
|
||
/** 输入框的提示内容 */
|
||
placeholder?: string;
|
||
/** 输入框的值 */
|
||
value?: string;
|
||
/** 是否是多行输入,多行输入时,允许换行 */
|
||
multiline?: boolean;
|
||
/** 边框颜色 */
|
||
border?: string;
|
||
/** 边框圆角 */
|
||
circle?: RectRCircleParams;
|
||
/** 边框宽度 */
|
||
borderWidth?: number;
|
||
/** 内边距 */
|
||
pad?: number;
|
||
}
|
||
|
||
export type InputEmits = {
|
||
/**
|
||
* 当输入框的值被确认时触发,例如失焦时
|
||
* @param value 输入框的值
|
||
*/
|
||
change: (value: string) => void;
|
||
|
||
/**
|
||
* 当输入框的值发生改变时触发,例如输入了一个字符,或者删除了一个字母
|
||
* @param value 输入框的值
|
||
*/
|
||
input: (value: string) => void;
|
||
|
||
'update:value': (value: string) => void;
|
||
};
|
||
|
||
const inputProps = {
|
||
props: ['placeholder', 'value', 'multiline'],
|
||
emits: ['change', 'input', 'update:value']
|
||
} satisfies SetupComponentOptions<InputProps, InputEmits, keyof InputEmits>;
|
||
|
||
/**
|
||
* 输入组件,点击后允许编辑。参数参考 {@link InputProps},事件参考 {@link InputEmits}。
|
||
* 完全继承 TextContent 组件的参数,用于控制输入内容的显示方式。用法示例:
|
||
* ```tsx
|
||
* const inputValue = ref('');
|
||
* <Input
|
||
* placeholder="提示词"
|
||
* // 双向数据绑定,当输入内容改变时,inputValue 会同时改变
|
||
* v-model={inputValue.value}
|
||
* // 设置为多行模式
|
||
* multiline
|
||
* // 边框颜色
|
||
* border="#eee"
|
||
* // 圆角参数,与 g-rectr 参数一致
|
||
* circle={[4]}
|
||
* // 边框宽度
|
||
* borderWidth={3}
|
||
* // 内边距
|
||
* pad={8}
|
||
* />
|
||
* ```
|
||
*/
|
||
export const Input = defineComponent<InputProps, InputEmits, keyof InputEmits>(
|
||
(props, { attrs, emit }) => {
|
||
let ele: HTMLInputElement | HTMLTextAreaElement | null = null;
|
||
|
||
const value = ref(props.value ?? '');
|
||
const root = ref<Container>();
|
||
|
||
const width = computed(() => props.loc?.[2] ?? 200);
|
||
const height = computed(() => props.loc?.[3] ?? 200);
|
||
const showText = computed(() => value.value || props.placeholder || '');
|
||
const padding = computed(() => props.pad ?? 4);
|
||
const textLoc = computed<ElementLocator>(() => [
|
||
padding.value,
|
||
padding.value,
|
||
width.value - padding.value * 2,
|
||
height.value - padding.value * 2
|
||
]);
|
||
|
||
const borderColor = transitionedColor(
|
||
props.border ?? '#ddd',
|
||
200,
|
||
linear()
|
||
)!;
|
||
|
||
const listenInput = () => {
|
||
if (!ele) return;
|
||
ele.addEventListener('input', () => {
|
||
if (ele) {
|
||
updateInput(ele.value);
|
||
}
|
||
});
|
||
ele.addEventListener('change', () => {
|
||
if (ele) {
|
||
updateValue(ele.value);
|
||
}
|
||
});
|
||
ele.addEventListener('blur', () => {
|
||
ele?.remove();
|
||
});
|
||
};
|
||
|
||
const createInput = (mul: boolean) => {
|
||
if (mul) {
|
||
ele = document.createElement('textarea');
|
||
} else {
|
||
ele = document.createElement('input');
|
||
}
|
||
// See file://./../../../../../src/styles.less
|
||
ele.classList.add('motajs-input-element');
|
||
listenInput();
|
||
};
|
||
|
||
const updateValue = (newValue: string) => {
|
||
value.value = newValue;
|
||
emit('update:value', newValue);
|
||
emit('change', newValue);
|
||
};
|
||
|
||
const updateInput = (newValue: string) => {
|
||
value.value = newValue;
|
||
emit('update:value', newValue);
|
||
emit('input', newValue);
|
||
};
|
||
|
||
const click = () => {
|
||
if (!ele) createInput(props.multiline ?? false);
|
||
if (!ele) return;
|
||
// 计算当前绝对位置
|
||
const renderer = MotaRenderer.get('render-main');
|
||
const canvas = renderer?.getCanvas();
|
||
if (!canvas) return;
|
||
const chain: RenderItem[] = [];
|
||
let now: RenderItem | undefined = root.value;
|
||
if (!now) return;
|
||
while (now) {
|
||
chain.unshift(now);
|
||
now = now.parent;
|
||
}
|
||
const { clientLeft, clientTop } = canvas;
|
||
const trans = new Transform();
|
||
trans.translate(clientLeft, clientTop);
|
||
trans.scale(core.domStyle.scale);
|
||
for (const item of chain) {
|
||
const { anchorX, anchorY, width, height } = item;
|
||
trans.translate(-anchorX * width, -anchorY * height);
|
||
trans.multiply(item.transform);
|
||
}
|
||
trans.translate(padding.value, padding.value);
|
||
const [a, b, , c, d, , e, f] = trans.mat;
|
||
const str = `matrix(${a},${b},${c},${d},${e},${f})`;
|
||
const w = width.value * core.domStyle.scale;
|
||
const h = height.value * core.domStyle.scale;
|
||
const font = props.font ?? Font.defaults();
|
||
ele.style.transform = str;
|
||
ele.style.width = `${w - padding.value * 2}px`;
|
||
ele.style.height = `${h - padding.value * 2}px`;
|
||
ele.style.font = font.string();
|
||
ele.style.color = String(props.fillStyle ?? 'white');
|
||
document.body.appendChild(ele);
|
||
ele.focus();
|
||
};
|
||
|
||
const enter = () => {
|
||
borderColor.set('#0ff');
|
||
};
|
||
|
||
const leave = () => {
|
||
borderColor.set(props.border ?? '#ddd');
|
||
};
|
||
|
||
watch(
|
||
() => props.value,
|
||
newValue => {
|
||
value.value = newValue ?? '';
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => props.multiline,
|
||
value => {
|
||
createInput(value ?? false);
|
||
},
|
||
{
|
||
immediate: true
|
||
}
|
||
);
|
||
|
||
onUnmounted(() => {
|
||
ele?.remove();
|
||
});
|
||
|
||
return () => (
|
||
<container
|
||
ref={root}
|
||
cursor="text"
|
||
onClick={click}
|
||
onEnter={enter}
|
||
onLeave={leave}
|
||
>
|
||
<g-rectr
|
||
loc={[0, 0, width.value, height.value]}
|
||
circle={props.circle}
|
||
lineWidth={props.borderWidth}
|
||
strokeStyle={borderColor.ref.value}
|
||
zIndex={10}
|
||
/>
|
||
<TextContent
|
||
{...attrs}
|
||
noevent
|
||
loc={textLoc.value}
|
||
width={width.value - padding.value * 2}
|
||
text={showText.value}
|
||
alpha={value.value.length === 0 ? 0.6 : 1}
|
||
zIndex={0}
|
||
/>
|
||
</container>
|
||
);
|
||
},
|
||
inputProps
|
||
);
|
||
|
||
export interface InputBoxProps extends TextContentProps {
|
||
loc: ElementLocator;
|
||
input?: InputProps;
|
||
winskin?: ImageIds;
|
||
color?: CanvasStyle;
|
||
border?: CanvasStyle;
|
||
pad?: number;
|
||
inputHeight?: number;
|
||
text?: string;
|
||
yesText?: string;
|
||
noText?: string;
|
||
selFont?: Font;
|
||
selFill?: CanvasStyle;
|
||
}
|
||
|
||
export type InputBoxEmits = {
|
||
/**
|
||
* 当确认输入框的内容时触发
|
||
* @param value 输入框的内容
|
||
*/
|
||
confirm: (value: string) => void;
|
||
|
||
/**
|
||
* 当取消时触发
|
||
* @param value 输入框的内容
|
||
*/
|
||
cancel: (value: string) => void;
|
||
} & InputEmits;
|
||
|
||
const inputBoxProps = {
|
||
props: [
|
||
'loc',
|
||
'input',
|
||
'winskin',
|
||
'color',
|
||
'border',
|
||
'pad',
|
||
'inputHeight',
|
||
'text',
|
||
'yesText',
|
||
'noText',
|
||
'selFont',
|
||
'selFill',
|
||
'width'
|
||
],
|
||
emits: ['confirm', 'cancel', 'change', 'input', 'update:value']
|
||
} satisfies SetupComponentOptions<
|
||
InputBoxProps,
|
||
InputBoxEmits,
|
||
keyof InputBoxEmits
|
||
>;
|
||
|
||
/**
|
||
* 输入框组件,与 2.x 的 myconfirm 类似,单次调用参考 {@link getInput}。用法与 `ConfirmBox` 类似。
|
||
* 参数参考 {@link InputBoxProps},事件参考 {@link InputBoxEmits},用例如下:
|
||
* ```tsx
|
||
* const onConfirm = (value: string) => console.log(value);
|
||
*
|
||
* <ConfirmBox
|
||
* text="请输入文本:"
|
||
* width={240}
|
||
* // 输入框会自动计算宽度和高度,因此不需要手动指定,即使手动指定也无效
|
||
* loc={[240, 240, void 0, void 0, 0.5, 0.5]}
|
||
* // 使用 winskin 图片作为背景
|
||
* winskin="winskin.png"
|
||
* // 使用颜色作为背景和边框,如果设置了 winskin,那么此参数无效
|
||
* color="#333"
|
||
* border="gold"
|
||
* // 设置选项的字体
|
||
* selFont={new Font('Verdana', 16)}
|
||
* // 设置选项的文本颜色
|
||
* selFill="#d48"
|
||
* // 完全继承 TextContent 的参数,因此可以填写 font 参数指定文本字体
|
||
* font={new Font('arial')}
|
||
* onConfirm={onYes}
|
||
* // 可以使用 input 参数调整输入组件
|
||
* input={{
|
||
* // 例如在输入组件中添加占位符(未输入任何东西时显示此内容)
|
||
* placeholder: '在这里输入你的文本'
|
||
* }}
|
||
* />
|
||
* ```
|
||
*/
|
||
export const InputBox = defineComponent<
|
||
InputBoxProps,
|
||
InputBoxEmits,
|
||
keyof InputBoxEmits
|
||
>((props, { attrs, emit }) => {
|
||
const contentHeight = ref(0);
|
||
const value = ref('');
|
||
const selected = ref(false);
|
||
const yesSize = ref<[number, number]>([0, 0]);
|
||
const noSize = ref<[number, number]>([0, 0]);
|
||
const height = ref(200);
|
||
|
||
const yesText = computed(() => props.yesText ?? '确认');
|
||
const noText = computed(() => props.noText ?? '取消');
|
||
const text = computed(() => props.text ?? '请输入内容:');
|
||
const padding = computed(() => props.pad ?? 8);
|
||
const inputHeight = computed(() => props.inputHeight ?? 16);
|
||
const inputLoc = computed<ElementLocator>(() => [
|
||
padding.value,
|
||
padding.value * 2 + contentHeight.value,
|
||
props.width - padding.value * 2,
|
||
inputHeight.value - padding.value * 2
|
||
]);
|
||
const yesLoc = computed<ElementLocator>(() => {
|
||
const y = height.value - padding.value;
|
||
return [props.width / 3, y, void 0, void 0, 0.5, 1];
|
||
});
|
||
const noLoc = computed<ElementLocator>(() => {
|
||
const y = height.value - padding.value;
|
||
return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1];
|
||
});
|
||
const selectionLoc = computed<ElementLocator>(() => {
|
||
if (selected.value) {
|
||
const [x = 0, y = 0] = yesLoc.value;
|
||
const [width, height] = yesSize.value;
|
||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||
} else {
|
||
const [x = 0, y = 0] = noLoc.value;
|
||
const [width, height] = noSize.value;
|
||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||
}
|
||
});
|
||
|
||
const updateHeight = (h: number) => {
|
||
contentHeight.value = h;
|
||
height.value = h + inputHeight.value + padding.value * 4;
|
||
};
|
||
|
||
const change = (value: string) => {
|
||
emit('change', value);
|
||
};
|
||
|
||
const input = (value: string) => {
|
||
emit('update:value', value);
|
||
emit('input', value);
|
||
};
|
||
|
||
const setYes = (_: string, width: number, height: number) => {
|
||
yesSize.value = [width, height];
|
||
};
|
||
|
||
const setNo = (_: string, width: number, height: number) => {
|
||
noSize.value = [width, height];
|
||
};
|
||
|
||
const confirm = () => {
|
||
emit('confirm', value.value);
|
||
};
|
||
|
||
const cancel = () => {
|
||
emit('cancel', value.value);
|
||
};
|
||
|
||
return () => (
|
||
<container>
|
||
<Background
|
||
loc={[0, 0, props.width, height.value]}
|
||
winskin={props.winskin}
|
||
color={props.color}
|
||
border={props.border}
|
||
zIndex={0}
|
||
/>
|
||
<TextContent
|
||
{...attrs}
|
||
loc={[padding.value, padding.value]}
|
||
text={text.value}
|
||
width={props.width - padding.value * 2}
|
||
zIndex={5}
|
||
onUpdateHeight={updateHeight}
|
||
/>
|
||
<Input
|
||
{...(props.input ?? {})}
|
||
loc={inputLoc.value}
|
||
v-model={value.value}
|
||
zIndex={10}
|
||
onChange={change}
|
||
onInput={input}
|
||
/>
|
||
<Selection
|
||
loc={selectionLoc.value}
|
||
winskin={props.winskin}
|
||
border={props.border}
|
||
noevent
|
||
zIndex={10}
|
||
/>
|
||
<text
|
||
loc={yesLoc.value}
|
||
text={yesText.value}
|
||
fillStyle={props.selFill}
|
||
font={props.selFont}
|
||
cursor="pointer"
|
||
zIndex={15}
|
||
onClick={confirm}
|
||
onEnter={() => (selected.value = true)}
|
||
onSetText={setYes}
|
||
/>
|
||
<text
|
||
loc={noLoc.value}
|
||
text={noText.value}
|
||
fillStyle={props.selFill}
|
||
font={props.selFont}
|
||
cursor="pointer"
|
||
zIndex={15}
|
||
onClick={cancel}
|
||
onEnter={() => (selected.value = false)}
|
||
onSetText={setNo}
|
||
/>
|
||
</container>
|
||
);
|
||
}, inputBoxProps);
|
||
|
||
/**
|
||
* 弹出一个输入框,然后将结果返回:
|
||
* ```ts
|
||
* const value = await getInput(
|
||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||
* props.controller,
|
||
* // 提示内容
|
||
* '请输入文本:',
|
||
* // 输入框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||
* // 宽度设为 240
|
||
* 240,
|
||
* // 可以给选择框传入其他的 props,例如指定字体,此项可选
|
||
* { font: new Font('Verdana', 20) }
|
||
* );
|
||
* // 之后,就可以根据 value 来执行不同的操作了
|
||
* console.log(value); // 输出用户输入的内容
|
||
* ```
|
||
* @param controller UI 控制器
|
||
* @param text 确认文本内容
|
||
* @param loc 确认框的位置
|
||
* @param width 确认框的宽度
|
||
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
|
||
*/
|
||
export function getInput(
|
||
controller: IUIMountable,
|
||
text: string,
|
||
loc: ElementLocator,
|
||
width: number,
|
||
props?: InputBoxProps
|
||
) {
|
||
return new Promise<string>(res => {
|
||
const instance = controller.open(
|
||
InputBoxUI,
|
||
{
|
||
...(props ?? {}),
|
||
text,
|
||
loc,
|
||
width,
|
||
onConfirm: value => {
|
||
controller.close(instance);
|
||
res(value);
|
||
},
|
||
onCancel: () => {
|
||
controller.close(instance);
|
||
res('');
|
||
}
|
||
},
|
||
true
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 与 `getInput` 类似,不过会将结果转为数字。用法参考 {@link getInput}
|
||
*/
|
||
export async function getInputNumber(
|
||
controller: IUIMountable,
|
||
text: string,
|
||
loc: ElementLocator,
|
||
width: number,
|
||
props?: InputBoxProps
|
||
) {
|
||
const value = await getInput(controller, text, loc, width, props);
|
||
return parseFloat(value);
|
||
}
|
||
|
||
export const InputBoxUI = new GameUI('input-box', InputBox);
|