Files
ezbookkeeping/src/components/mobile/NumberPadSheet.vue
T
zhengchen.tao 11da502f75 fix(numpad): 修小键盘点击卡顿,touch-action: none → manipulation
诊断:用户反馈仅小键盘点击有延迟感,其他按钮正常。范围缩小后定位到
.numpad-button 上的 touch-action: none(上游 e178a079 引入)与 F7
内部 tap 事件处理叠加,让 click 事件合成慢一拍。backspace(自定义
.numpad-backspace-button 类)不受影响,刚好印证范围。

修复:改为 touch-action: manipulation(W3C 标准"快速点击"值),禁双
击缩放消除老 300ms 延迟,但保留 click 事件正常合成。

FORK.md #11 状态:🔍 调查中 → 🟢 已完成,附真因记录避免后续误诊。
2026-05-02 18:09:40 +08:00

599 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">&times;</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">&minus;</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">&plus;</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>