11da502f75
诊断:用户反馈仅小键盘点击有延迟感,其他按钮正常。范围缩小后定位到
.numpad-button 上的 touch-action: none(上游 e178a079 引入)与 F7
内部 tap 事件处理叠加,让 click 事件合成慢一拍。backspace(自定义
.numpad-backspace-button 类)不受影响,刚好印证范围。
修复:改为 touch-action: manipulation(W3C 标准"快速点击"值),禁双
击缩放消除老 300ms 延迟,但保留 click 事件正常合成。
FORK.md #11 状态:🔍 调查中 → 🟢 已完成,附真因记录避免后续误诊。
599 lines
19 KiB
Vue
599 lines
19 KiB
Vue
<template>
|
||
<f7-sheet swipe-to-close swipe-handler=".swipe-handler" class="numpad-sheet" style="height: auto"
|
||
:opened="show" @sheet:open="onSheetOpen" @sheet:closed="onSheetClosed">
|
||
<div class="swipe-handler"></div>
|
||
<f7-page-content class="margin-top no-padding-top">
|
||
<div class="margin-top padding-horizontal" v-if="hint">
|
||
<span>{{ hint }}</span>
|
||
</div>
|
||
<div class="numpad-values">
|
||
<span id="numpad-value" class="numpad-value" :class="currentDisplayNumClass" @click="onDisplayValueClick">{{ currentDisplay }}</span>
|
||
<f7-button class="numpad-backspace-button" @click="backspace" @taphold="clear()">
|
||
<f7-icon class="icon-with-direction" f7="delete_left"></f7-icon>
|
||
</f7-button>
|
||
</div>
|
||
|
||
<f7-popover class="numpad-paste-popover" target-el="#numpad-value"
|
||
v-model:opened="showPastePopover">
|
||
<f7-list class="numpad-paste-popover-context-menu-list">
|
||
<f7-list-item link="#" no-chevron :title="tt('Paste')" @click="paste"></f7-list-item>
|
||
</f7-list>
|
||
</f7-popover>
|
||
|
||
<div class="numpad-buttons">
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(7)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[7] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(8)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[8] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(9)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[9] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('×')">
|
||
<span class="numpad-button-text numpad-button-text-normal">×</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(4)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[4] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(5)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[5] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(6)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[6] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('−')">
|
||
<span class="numpad-button-text numpad-button-text-normal">−</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(1)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[1] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(2)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[2] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(3)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[3] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('+')">
|
||
<span class="numpad-button-text numpad-button-text-normal">+</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="clear()">
|
||
<span class="numpad-button-text numpad-button-text-normal">C</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(0)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[0] }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" v-if="supportDecimalSeparator" @click="inputDecimalSeparator()">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ decimalSeparator }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-num" v-if="!supportDecimalSeparator" @click="inputDoubleNum(0)">
|
||
<span class="numpad-button-text numpad-button-text-normal">{{ `${digits[0]}${digits[0]}` }}</span>
|
||
</f7-button>
|
||
<f7-button class="numpad-button numpad-button-confirm no-right-border no-bottom-border" fill @click="confirm()">
|
||
<span :class="{ 'numpad-button-text': true, 'numpad-button-text-confirm': !currentSymbol }">{{ confirmText }}</span>
|
||
</f7-button>
|
||
</div>
|
||
</f7-page-content>
|
||
</f7-sheet>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch } from 'vue';
|
||
|
||
import { useI18n } from '@/locales/helpers.ts';
|
||
import { useI18nUIComponents, isiOS } from '@/lib/ui/mobile.ts';
|
||
|
||
import { NumeralSystem } from '@/core/numeral.ts';
|
||
import { ALL_CURRENCIES } from '@/consts/currency.ts';
|
||
import { isNumber } from '@/lib/common.ts';
|
||
import logger from '@/lib/logger.ts';
|
||
|
||
const props = defineProps<{
|
||
modelValue: number;
|
||
minValue?: number;
|
||
maxValue?: number;
|
||
currency?: string;
|
||
flipNegative?: boolean;
|
||
hint?: string;
|
||
show: boolean;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:modelValue', value: number): void;
|
||
(e: 'update:show', value: boolean): void;
|
||
}>();
|
||
|
||
const {
|
||
tt,
|
||
getAllLocalizedDigits,
|
||
getCurrentNumeralSystemType,
|
||
getCurrentDecimalSeparator,
|
||
parseAmountFromLocalizedNumerals,
|
||
parseAmountFromWesternArabicNumerals,
|
||
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
|
||
appendDigitGroupingSymbolAndDecimalSeparator
|
||
} = useI18n();
|
||
const { showToast } = useI18nUIComponents();
|
||
|
||
const isSupportClipboard = !!navigator.clipboard;
|
||
|
||
const previousValue = ref<string>('');
|
||
const currentSymbol = ref<string>('');
|
||
const currentValue = ref<string>(getInitedStringValue(props.modelValue, props.flipNegative));
|
||
const pastingAmount = ref<boolean>(false);
|
||
const showPastePopover = ref<boolean>(false);
|
||
|
||
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
|
||
|
||
const digits = computed<string[]>(() => getAllLocalizedDigits());
|
||
const decimalSeparator = computed<string>(() => getCurrentDecimalSeparator());
|
||
|
||
const supportDecimalSeparator = computed<boolean>(() => {
|
||
if (!props.currency || !ALL_CURRENCIES[props.currency] || !isNumber(ALL_CURRENCIES[props.currency]!.fraction)) {
|
||
return true;
|
||
}
|
||
|
||
return (ALL_CURRENCIES[props.currency]!.fraction as number) > 0;
|
||
});
|
||
|
||
const currentDisplay = computed<string>(() => {
|
||
const finalPreviousValue = appendDigitGroupingSymbolAndDecimalSeparator(numeralSystem.value.replaceWesternArabicDigitsToLocalizedDigits(previousValue.value));
|
||
let finalCurrentValue = currentValue.value;
|
||
let currentValueSuffix = '';
|
||
|
||
if (finalCurrentValue && finalCurrentValue[finalCurrentValue.length - 1] === decimalSeparator.value) {
|
||
finalCurrentValue = finalCurrentValue.substring(0, finalCurrentValue.length - 1);
|
||
currentValueSuffix = decimalSeparator.value;
|
||
}
|
||
|
||
finalCurrentValue = appendDigitGroupingSymbolAndDecimalSeparator(numeralSystem.value.replaceWesternArabicDigitsToLocalizedDigits(finalCurrentValue));
|
||
|
||
if (currentValueSuffix) {
|
||
finalCurrentValue += currentValueSuffix;
|
||
}
|
||
|
||
if (currentSymbol.value) {
|
||
return `${finalPreviousValue} ${currentSymbol.value} ${finalCurrentValue}`;
|
||
} else {
|
||
return finalCurrentValue;
|
||
}
|
||
});
|
||
|
||
const currentDisplayNumClass = computed<string>(() => {
|
||
if (currentDisplay.value && currentDisplay.value.length >= 24) {
|
||
return 'numpad-value-small';
|
||
} else if (currentDisplay.value && currentDisplay.value.length >= 16) {
|
||
return 'numpad-value-normal';
|
||
} else {
|
||
return 'numpad-value-large';
|
||
}
|
||
});
|
||
|
||
const confirmText = computed<string>(() => {
|
||
if (currentSymbol.value) {
|
||
return '=';
|
||
} else {
|
||
return tt('OK');
|
||
}
|
||
});
|
||
|
||
function getInitedStringValue(value: number, flipNegative?: boolean): string {
|
||
if (flipNegative) {
|
||
value = -value;
|
||
}
|
||
|
||
return getStringValue(value, true);
|
||
}
|
||
|
||
function getStringValue(value: number, hideZero: boolean): string {
|
||
if (!isNumber(value)) {
|
||
return '';
|
||
}
|
||
|
||
const textualNumber = formatAmountToWesternArabicNumeralsWithoutDigitGrouping(value, props.currency);
|
||
|
||
const decimalSeparator = getCurrentDecimalSeparator();
|
||
const decimalSeparatorPos = textualNumber.indexOf(decimalSeparator);
|
||
|
||
if (decimalSeparatorPos < 0) {
|
||
if (hideZero && textualNumber === NumeralSystem.WesternArabicNumerals.digitZero) {
|
||
return '';
|
||
}
|
||
|
||
return textualNumber;
|
||
}
|
||
|
||
const integer = textualNumber.substring(0, decimalSeparatorPos);
|
||
const decimals = textualNumber.substring(decimalSeparatorPos + 1, textualNumber.length);
|
||
let newDecimals = '';
|
||
|
||
for (let i = decimals.length - 1; i >= 0; i--) {
|
||
if (decimals[i] !== NumeralSystem.WesternArabicNumerals.digitZero || newDecimals.length > 0) {
|
||
newDecimals = decimals[i] + newDecimals;
|
||
}
|
||
}
|
||
|
||
if (newDecimals.length < 1) {
|
||
if (hideZero && integer === NumeralSystem.WesternArabicNumerals.digitZero) {
|
||
return '';
|
||
}
|
||
|
||
return integer;
|
||
}
|
||
|
||
return `${integer}${decimalSeparator}${newDecimals}`;
|
||
}
|
||
|
||
function inputNum(num: number): void {
|
||
if (!previousValue.value && currentSymbol.value === '−') {
|
||
currentValue.value = '-' + currentValue.value;
|
||
currentSymbol.value = '';
|
||
}
|
||
|
||
if (currentValue.value === NumeralSystem.WesternArabicNumerals.digitZero) {
|
||
currentValue.value = num.toString();
|
||
return;
|
||
} else if (currentValue.value === `-${NumeralSystem.WesternArabicNumerals.digitZero}`) {
|
||
currentValue.value = '-' + num.toString();
|
||
return;
|
||
}
|
||
|
||
const decimalSeparatorPos = currentValue.value.indexOf(decimalSeparator.value);
|
||
|
||
if (decimalSeparatorPos >= 0 && currentValue.value.substring(decimalSeparatorPos + 1, currentValue.value.length).length >= 2) {
|
||
return;
|
||
}
|
||
|
||
const newValue = currentValue.value + num.toString();
|
||
|
||
if (isNumber(props.minValue)) {
|
||
const current = parseAmountFromWesternArabicNumerals(newValue);
|
||
|
||
if (current < (props.minValue)) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (isNumber(props.maxValue)) {
|
||
const current = parseAmountFromWesternArabicNumerals(newValue);
|
||
|
||
if (current > (props.maxValue)) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
currentValue.value = newValue;
|
||
}
|
||
|
||
function inputDoubleNum(num: number): void {
|
||
inputNum(num);
|
||
inputNum(num);
|
||
}
|
||
|
||
function inputDecimalSeparator(): void {
|
||
if (currentValue.value.indexOf(decimalSeparator.value) >= 0) {
|
||
return;
|
||
}
|
||
|
||
if (!previousValue.value && currentSymbol.value === '−') {
|
||
currentValue.value = '-' + currentValue.value;
|
||
currentSymbol.value = '';
|
||
}
|
||
|
||
if (currentValue.value.length < 1) {
|
||
currentValue.value = NumeralSystem.WesternArabicNumerals.digitZero;
|
||
} else if (currentValue.value === '-') {
|
||
currentValue.value = '-' + NumeralSystem.WesternArabicNumerals.digitZero;
|
||
}
|
||
|
||
currentValue.value = currentValue.value + decimalSeparator.value;
|
||
}
|
||
|
||
function setSymbol(symbol: string): void {
|
||
if (currentValue.value) {
|
||
if (currentSymbol.value) {
|
||
const lastFormulaCalcResult = confirm();
|
||
|
||
if (!lastFormulaCalcResult) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
previousValue.value = currentValue.value;
|
||
currentValue.value = '';
|
||
}
|
||
|
||
currentSymbol.value = symbol;
|
||
}
|
||
|
||
function backspace(): void {
|
||
if (!currentValue.value || currentValue.value.length < 1) {
|
||
if (currentSymbol.value) {
|
||
currentValue.value = previousValue.value;
|
||
previousValue.value = '';
|
||
currentSymbol.value = '';
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
currentValue.value = currentValue.value.substring(0, currentValue.value.length - 1);
|
||
}
|
||
|
||
function clear(): void {
|
||
currentValue.value = '';
|
||
previousValue.value = '';
|
||
currentSymbol.value = '';
|
||
}
|
||
|
||
function paste(): void {
|
||
showPastePopover.value = false;
|
||
|
||
if (pastingAmount.value) {
|
||
pastingAmount.value = false;
|
||
return;
|
||
}
|
||
|
||
pastingAmount.value = true;
|
||
|
||
navigator.clipboard.readText().then(text => {
|
||
pastingAmount.value = false;
|
||
|
||
if (!text) {
|
||
return;
|
||
}
|
||
|
||
const parsedAmount = parseAmountFromLocalizedNumerals(text);
|
||
|
||
if (Number.isNaN(parsedAmount) || !Number.isFinite(parsedAmount)) {
|
||
showToast('Cannot parse amount from clipboard');
|
||
return;
|
||
}
|
||
|
||
if (isNumber(props.minValue)) {
|
||
if (parsedAmount < (props.minValue)) {
|
||
showToast('Numeric Overflow');
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (isNumber(props.maxValue)) {
|
||
if (parsedAmount > (props.maxValue)) {
|
||
showToast('Numeric Overflow');
|
||
return;
|
||
}
|
||
}
|
||
|
||
currentValue.value = getStringValue(parsedAmount, false);
|
||
}).catch(error => {
|
||
// Do not set pastingAmount to false here
|
||
// In iOS, system will show the paste context menu, if user click outside, the paste action should not be triggered again
|
||
logger.error('failed to read clipboard text', error);
|
||
});
|
||
}
|
||
|
||
function confirm(): boolean {
|
||
if (currentSymbol.value && currentValue.value.length >= 1) {
|
||
const previous = parseAmountFromWesternArabicNumerals(previousValue.value);
|
||
const current = parseAmountFromWesternArabicNumerals(currentValue.value);
|
||
let finalValue = 0;
|
||
|
||
switch (currentSymbol.value) {
|
||
case '+':
|
||
finalValue = previous + current;
|
||
break;
|
||
case '−':
|
||
finalValue = previous - current;
|
||
break;
|
||
case '×':
|
||
finalValue = Math.trunc(previous * current / 100);
|
||
break;
|
||
default:
|
||
finalValue = previous;
|
||
}
|
||
|
||
if (isNumber(props.minValue)) {
|
||
if (finalValue < (props.minValue)) {
|
||
showToast('Numeric Overflow');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (isNumber(props.maxValue)) {
|
||
if (finalValue > (props.maxValue)) {
|
||
showToast('Numeric Overflow');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
currentValue.value = getStringValue(finalValue, false);
|
||
previousValue.value = '';
|
||
currentSymbol.value = '';
|
||
|
||
return true;
|
||
} else if (currentSymbol.value && currentValue.value.length < 1) {
|
||
currentValue.value = previousValue.value;
|
||
previousValue.value = '';
|
||
currentSymbol.value = '';
|
||
|
||
return true;
|
||
} else {
|
||
let value: number = parseAmountFromWesternArabicNumerals(currentValue.value);
|
||
|
||
if (props.flipNegative) {
|
||
value = -value;
|
||
}
|
||
|
||
emit('update:modelValue', value);
|
||
close();
|
||
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function close(): void {
|
||
emit('update:show', false);
|
||
}
|
||
|
||
function onSheetOpen(): void {
|
||
currentValue.value = getInitedStringValue(props.modelValue, props.flipNegative);
|
||
previousValue.value = '';
|
||
currentSymbol.value = '';
|
||
}
|
||
|
||
function onSheetClosed(): void {
|
||
close();
|
||
}
|
||
|
||
function onDisplayValueClick(): void {
|
||
if (!isSupportClipboard) {
|
||
return;
|
||
}
|
||
|
||
if (isiOS()) {
|
||
paste();
|
||
} else {
|
||
showPastePopover.value = true;
|
||
}
|
||
}
|
||
|
||
watch(() => props.flipNegative, (newValue) => {
|
||
currentValue.value = getInitedStringValue(props.modelValue, newValue);
|
||
});
|
||
</script>
|
||
|
||
<style>
|
||
.numpad-sheet {
|
||
height: auto;
|
||
}
|
||
|
||
.numpad-values {
|
||
display: flex;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--f7-page-bg-color);
|
||
}
|
||
|
||
.numpad-value {
|
||
display: flex;
|
||
position: relative;
|
||
flex: 1;
|
||
padding-inline-start: 16px;
|
||
line-height: 1;
|
||
height: var(--ebk-numpad-value-height);
|
||
align-items: center;
|
||
box-sizing: border-box;
|
||
user-select: none;
|
||
}
|
||
|
||
.numpad-backspace-button {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 20%;
|
||
height: var(--ebk-numpad-value-height);
|
||
font-size: 22px;
|
||
color: var(--f7-color-black);
|
||
flex-shrink: 0;
|
||
border-left: 1px solid var(--f7-page-bg-color);
|
||
}
|
||
|
||
.dark .numpad-backspace-button {
|
||
color: var(--f7-color-white);
|
||
}
|
||
|
||
.numpad-value-small {
|
||
font-size: var(--ebk-numpad-value-small-font-size);
|
||
}
|
||
|
||
.numpad-value-normal {
|
||
font-size: var(--ebk-numpad-value-normal-font-size);
|
||
}
|
||
|
||
.numpad-value-large {
|
||
font-size: var(--ebk-numpad-value-large-font-size);
|
||
}
|
||
|
||
.numpad-buttons {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.numpad-button {
|
||
display: flex;
|
||
position: relative;
|
||
text-align: center;
|
||
border-radius: 0;
|
||
border-right: 1px solid var(--f7-page-bg-color);
|
||
border-bottom: 1px solid var(--f7-page-bg-color);
|
||
height: 60px;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
box-sizing: border-box;
|
||
user-select: none;
|
||
/* 上游设的 touch-action: none 在 F7 tap 处理下让 click 慢一拍(小键盘卡顿
|
||
的实际根因,不是网络/渲染)。改 manipulation:禁双击缩放 + 消除 300ms
|
||
老延迟,但保留 click 正常合成。详见 FORK.md #11 */
|
||
touch-action: manipulation;
|
||
}
|
||
|
||
.numpad-button-num {
|
||
width: calc(80% / 3);
|
||
}
|
||
|
||
.numpad-button-function, .numpad-button-confirm {
|
||
width: 20%;
|
||
}
|
||
|
||
.numpad-button-num.active-state, .numpad-button-function.active-state {
|
||
background-color: rgba(var(--f7-color-black-rgb), .15);
|
||
}
|
||
|
||
.numpad-button-text {
|
||
display: block;
|
||
font-size: var(--ebk-numpad-normal-button-font-size);
|
||
font-weight: normal;
|
||
line-height: 1;
|
||
}
|
||
|
||
.numpad-button-text-normal {
|
||
color: var(--f7-color-black);
|
||
}
|
||
|
||
.dark .numpad-button-text-normal {
|
||
color: var(--f7-color-white);
|
||
}
|
||
|
||
.numpad-button-text-confirm {
|
||
font-size: var(--ebk-numpad-confirm-button-font-size);
|
||
}
|
||
|
||
.numpad-paste-popover.popover {
|
||
width: auto;
|
||
|
||
.numpad-paste-popover-context-menu-list.list {
|
||
:first-child li:first-child a {
|
||
&.active-state {
|
||
border-radius: unset;
|
||
}
|
||
|
||
> .item-content {
|
||
min-height: var(--ebk-popover-context-menu-min-height);
|
||
|
||
> .item-inner {
|
||
min-height: var(--ebk-popover-context-menu-min-height);
|
||
padding-top: var(--ebk-popover-context-menu-vertical-padding);
|
||
padding-bottom: var(--ebk-popover-context-menu-vertical-padding);
|
||
padding-left: var(--ebk-popover-context-menu-left-padding);
|
||
padding-right: var(--ebk-popover-context-menu-right-padding);
|
||
|
||
> .item-title {
|
||
font-size: var(--ebk-popover-context-menu-button-font-size);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|