Extract quick entry category selector
This commit is contained in:
parent
322ee737fd
commit
e7250c4cf7
|
|
@ -0,0 +1,282 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { EntryType } from "@cents/domain";
|
||||||
|
import { ChevronRight } from "@lucide/vue";
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
import {
|
||||||
|
categories,
|
||||||
|
entryTypes,
|
||||||
|
type Category,
|
||||||
|
type EntryTypeOption,
|
||||||
|
} from "../data/categories";
|
||||||
|
|
||||||
|
type SelectorLevel = "type" | "category" | "subcategory";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
entryType: EntryType;
|
||||||
|
categoryPath: Category[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:entryType": [value: EntryType];
|
||||||
|
"update:categoryPath": [value: Category[]];
|
||||||
|
confirm: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectorLevel = ref<SelectorLevel>("type");
|
||||||
|
const axisOffset = ref(0);
|
||||||
|
let axisPointerId: number | null = null;
|
||||||
|
let axisDragStartY = 0;
|
||||||
|
let axisDragStartOffset = 0;
|
||||||
|
let axisDragged = false;
|
||||||
|
let axisLastY = 0;
|
||||||
|
let axisLastTime = 0;
|
||||||
|
let axisVelocity = 0;
|
||||||
|
let axisInertiaFrame: number | null = null;
|
||||||
|
|
||||||
|
const typeOption = computed(() => entryTypes.find((item) => item.id === props.entryType)!);
|
||||||
|
const selectedCategory = computed(() => props.categoryPath.at(-1));
|
||||||
|
const selectedParent = computed(() => props.categoryPath[0]);
|
||||||
|
const selectorOptions = computed<(EntryTypeOption | Category)[]>(() => {
|
||||||
|
if (selectorLevel.value === "type") {
|
||||||
|
const current = entryTypes.find((option) => option.id === props.entryType);
|
||||||
|
return [...entryTypes.filter((option) => option.id !== props.entryType), ...(current ? [current] : [])];
|
||||||
|
}
|
||||||
|
if (selectorLevel.value === "category") return categories[props.entryType];
|
||||||
|
return selectedParent.value?.children ?? [];
|
||||||
|
});
|
||||||
|
const upperSelectorOptions = computed(() => {
|
||||||
|
const count = Math.min(2, Math.floor(selectorOptions.value.length / 2));
|
||||||
|
return selectorOptions.value.slice(0, count);
|
||||||
|
});
|
||||||
|
const upperAxisHeight = computed(() => {
|
||||||
|
const count = upperSelectorOptions.value.length;
|
||||||
|
return count * 72 + Math.max(0, count - 1) * 10 + 34;
|
||||||
|
});
|
||||||
|
const axisBaseOffset = computed(() => upperSelectorOptions.value.length * 82);
|
||||||
|
const axisMaxOffset = computed(() =>
|
||||||
|
Math.max(0, (selectorOptions.value.length - upperSelectorOptions.value.length - 2) * 82),
|
||||||
|
);
|
||||||
|
const selectorPath = computed<(EntryTypeOption | Category)[]>(() => {
|
||||||
|
if (selectorLevel.value === "type") return [];
|
||||||
|
if (selectorLevel.value === "category") return [typeOption.value];
|
||||||
|
return selectedParent.value ? [typeOption.value, selectedParent.value] : [typeOption.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.open, (open) => {
|
||||||
|
if (open) resetAxisScroll();
|
||||||
|
else stopAxisInertia();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(stopAxisInertia);
|
||||||
|
|
||||||
|
function chooseSelectorOption(option: EntryTypeOption | Category) {
|
||||||
|
if (selectorLevel.value === "type") {
|
||||||
|
const nextType = option.id as EntryType;
|
||||||
|
if (nextType !== props.entryType) emit("update:categoryPath", []);
|
||||||
|
emit("update:entryType", nextType);
|
||||||
|
selectorLevel.value = "category";
|
||||||
|
resetAxisScroll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = option as Category;
|
||||||
|
if (selectorLevel.value === "category") {
|
||||||
|
const currentChild = selectedParent.value?.id === category.id ? props.categoryPath[1] : undefined;
|
||||||
|
emit("update:categoryPath", currentChild ? [category, currentChild] : [category]);
|
||||||
|
if (category.children?.length) {
|
||||||
|
selectorLevel.value = "subcategory";
|
||||||
|
resetAxisScroll();
|
||||||
|
} else {
|
||||||
|
emit("confirm");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedParent.value) return;
|
||||||
|
emit("update:categoryPath", [selectedParent.value, category]);
|
||||||
|
emit("confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePathLevel(index: number) {
|
||||||
|
selectorLevel.value = index === 0 ? "type" : "category";
|
||||||
|
resetAxisScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectorOptionSelected(option: EntryTypeOption | Category) {
|
||||||
|
if (selectorLevel.value === "type") return option.id === props.entryType;
|
||||||
|
if (selectorLevel.value === "category") return option.id === selectedParent.value?.id;
|
||||||
|
return props.categoryPath.length > 1 && option.id === selectedCategory.value?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAxisScroll() {
|
||||||
|
stopAxisInertia();
|
||||||
|
axisOffset.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampAxisOffset(value: number) {
|
||||||
|
return Math.max(0, Math.min(axisMaxOffset.value, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAxisDrag(event: PointerEvent) {
|
||||||
|
stopAxisInertia();
|
||||||
|
axisPointerId = event.pointerId;
|
||||||
|
axisDragStartY = event.clientY;
|
||||||
|
axisDragStartOffset = axisOffset.value;
|
||||||
|
axisDragged = false;
|
||||||
|
axisLastY = event.clientY;
|
||||||
|
axisLastTime = performance.now();
|
||||||
|
axisVelocity = 0;
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveAxisDrag(event: PointerEvent) {
|
||||||
|
if (axisPointerId !== event.pointerId) return;
|
||||||
|
const distance = axisDragStartY - event.clientY;
|
||||||
|
if (Math.abs(distance) > 5) axisDragged = true;
|
||||||
|
axisOffset.value = clampAxisOffset(axisDragStartOffset + distance);
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
const elapsed = Math.max(1, now - axisLastTime);
|
||||||
|
const instantaneousVelocity = (axisLastY - event.clientY) / elapsed;
|
||||||
|
axisVelocity = axisVelocity * 0.65 + instantaneousVelocity * 0.35;
|
||||||
|
axisLastY = event.clientY;
|
||||||
|
axisLastTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endAxisDrag(event: PointerEvent) {
|
||||||
|
if (axisPointerId !== event.pointerId) return;
|
||||||
|
axisPointerId = null;
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||||
|
if (axisDragged) startAxisInertia();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAxisClick(event: MouseEvent) {
|
||||||
|
if (!axisDragged) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
axisDragged = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollAxisBy(delta: number) {
|
||||||
|
stopAxisInertia();
|
||||||
|
axisOffset.value = clampAxisOffset(axisOffset.value + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAxisInertia() {
|
||||||
|
if (Math.abs(axisVelocity) < 0.04) return;
|
||||||
|
let previousTime = performance.now();
|
||||||
|
|
||||||
|
const step = (now: number) => {
|
||||||
|
const elapsed = Math.min(32, now - previousTime);
|
||||||
|
previousTime = now;
|
||||||
|
const current = axisOffset.value;
|
||||||
|
const next = clampAxisOffset(current + axisVelocity * elapsed);
|
||||||
|
axisOffset.value = next;
|
||||||
|
|
||||||
|
if (next === current && (next === 0 || next === axisMaxOffset.value)) {
|
||||||
|
stopAxisInertia();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
axisVelocity *= Math.pow(0.91, elapsed / 16);
|
||||||
|
if (Math.abs(axisVelocity) < 0.025) {
|
||||||
|
stopAxisInertia();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
axisInertiaFrame = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
axisInertiaFrame = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAxisInertia() {
|
||||||
|
if (axisInertiaFrame !== null) cancelAnimationFrame(axisInertiaFrame);
|
||||||
|
axisInertiaFrame = null;
|
||||||
|
axisVelocity = 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="coordinate-selector">
|
||||||
|
<div v-if="open" class="category-coordinate" aria-label="二维分类选择器" @click.stop>
|
||||||
|
<div class="selector-path" aria-label="已选分类路径">
|
||||||
|
<template v-for="(option, index) in selectorPath" :key="option.id">
|
||||||
|
<ChevronRight v-if="index > 0" :size="18" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:style="{ '--path-color': option.color }"
|
||||||
|
:aria-label="`返回${option.label}层重新选择`"
|
||||||
|
@click="choosePathLevel(index)"
|
||||||
|
>
|
||||||
|
<component :is="option.icon" :size="22" />
|
||||||
|
<small>{{ option.label }}</small>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<ChevronRight v-if="selectorPath.length" :size="18" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="upperSelectorOptions.length"
|
||||||
|
class="candidate-above-axis"
|
||||||
|
:style="{ height: `${upperAxisHeight}px` }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="candidate-axis-viewport"
|
||||||
|
@pointerdown="startAxisDrag"
|
||||||
|
@pointermove="moveAxisDrag"
|
||||||
|
@pointerup="endAxisDrag"
|
||||||
|
@pointercancel="endAxisDrag"
|
||||||
|
@click.capture="handleAxisClick"
|
||||||
|
@wheel.prevent="scrollAxisBy($event.deltaY)"
|
||||||
|
>
|
||||||
|
<div class="candidate-axis-list" :style="{ transform: `translate3d(0, -${axisOffset}px, 0)` }">
|
||||||
|
<button
|
||||||
|
v-for="option in selectorOptions"
|
||||||
|
:key="option.id"
|
||||||
|
type="button"
|
||||||
|
class="coordinate-option"
|
||||||
|
:class="{ selected: isSelectorOptionSelected(option) }"
|
||||||
|
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
|
||||||
|
@click="chooseSelectorOption(option)"
|
||||||
|
>
|
||||||
|
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="candidate-below-axis">
|
||||||
|
<div
|
||||||
|
class="candidate-axis-viewport"
|
||||||
|
@pointerdown="startAxisDrag"
|
||||||
|
@pointermove="moveAxisDrag"
|
||||||
|
@pointerup="endAxisDrag"
|
||||||
|
@pointercancel="endAxisDrag"
|
||||||
|
@click.capture="handleAxisClick"
|
||||||
|
@wheel.prevent="scrollAxisBy($event.deltaY)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="candidate-axis-list"
|
||||||
|
:style="{ transform: `translate3d(0, -${axisBaseOffset + axisOffset}px, 0)` }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="option in selectorOptions"
|
||||||
|
:key="option.id"
|
||||||
|
type="button"
|
||||||
|
class="coordinate-option"
|
||||||
|
:class="{ selected: isSelectorOptionSelected(option) }"
|
||||||
|
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
|
||||||
|
@click="chooseSelectorOption(option)"
|
||||||
|
>
|
||||||
|
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EntryType } from "@cents/domain";
|
import type { EntryType } from "@cents/domain";
|
||||||
import { BookOpen, CalendarDays, Check, ChevronDown, ChevronRight, Delete as BackspaceIcon, X } from "@lucide/vue";
|
import { BookOpen, CalendarDays, Check, ChevronDown, Delete as BackspaceIcon, X } from "@lucide/vue";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import {
|
import { entryTypes, type Category } from "../data/categories";
|
||||||
categories,
|
import CategoryCoordinateSelector from "./CategoryCoordinateSelector.vue";
|
||||||
entryTypes,
|
|
||||||
type Category,
|
|
||||||
type EntryTypeOption,
|
|
||||||
} from "../data/categories";
|
|
||||||
|
|
||||||
type SavedEntry = {
|
type SavedEntry = {
|
||||||
type: EntryType;
|
type: EntryType;
|
||||||
|
|
@ -18,8 +14,6 @@ type SavedEntry = {
|
||||||
ledgerIds: string[];
|
ledgerIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type SelectorLevel = "type" | "category" | "subcategory";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
ledgerId: string;
|
ledgerId: string;
|
||||||
ledgerName: string;
|
ledgerName: string;
|
||||||
|
|
@ -39,23 +33,12 @@ const note = ref("");
|
||||||
const occurredAt = ref(toLocalDateTime(new Date()));
|
const occurredAt = ref(toLocalDateTime(new Date()));
|
||||||
const dateInput = ref<HTMLInputElement | null>(null);
|
const dateInput = ref<HTMLInputElement | null>(null);
|
||||||
const selectorOpen = ref(false);
|
const selectorOpen = ref(false);
|
||||||
const selectorLevel = ref<SelectorLevel>("type");
|
|
||||||
const noteEditing = ref(false);
|
const noteEditing = ref(false);
|
||||||
const ledgerPickerOpen = ref(false);
|
const ledgerPickerOpen = ref(false);
|
||||||
const selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]);
|
const selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]);
|
||||||
const axisOffset = ref(0);
|
|
||||||
let axisPointerId: number | null = null;
|
|
||||||
let axisDragStartY = 0;
|
|
||||||
let axisDragStartOffset = 0;
|
|
||||||
let axisDragged = false;
|
|
||||||
let axisLastY = 0;
|
|
||||||
let axisLastTime = 0;
|
|
||||||
let axisVelocity = 0;
|
|
||||||
let axisInertiaFrame: number | null = null;
|
|
||||||
|
|
||||||
const typeOption = computed(() => entryTypes.find((item) => item.id === entryType.value)!);
|
const typeOption = computed(() => entryTypes.find((item) => item.id === entryType.value)!);
|
||||||
const selectedCategory = computed(() => categoryPath.value.at(-1));
|
const selectedCategory = computed(() => categoryPath.value.at(-1));
|
||||||
const selectedParent = computed(() => categoryPath.value[0]);
|
|
||||||
const categoryPathLabel = computed(() =>
|
const categoryPathLabel = computed(() =>
|
||||||
[typeOption.value.label, ...categoryPath.value.map((category) => category.label)].join(">"),
|
[typeOption.value.label, ...categoryPath.value.map((category) => category.label)].join(">"),
|
||||||
);
|
);
|
||||||
|
|
@ -91,31 +74,6 @@ const canSave = computed(() => {
|
||||||
const amount = Math.round(calculatedAmount.value * 100);
|
const amount = Math.round(calculatedAmount.value * 100);
|
||||||
return Number.isFinite(amount) && amount > 0 && occurredAtValid.value;
|
return Number.isFinite(amount) && amount > 0 && occurredAtValid.value;
|
||||||
});
|
});
|
||||||
const selectorOptions = computed<(EntryTypeOption | Category)[]>(() => {
|
|
||||||
if (selectorLevel.value === "type") {
|
|
||||||
const current = entryTypes.find((option) => option.id === entryType.value);
|
|
||||||
return [...entryTypes.filter((option) => option.id !== entryType.value), ...(current ? [current] : [])];
|
|
||||||
}
|
|
||||||
if (selectorLevel.value === "category") return categories[entryType.value];
|
|
||||||
return selectedParent.value?.children ?? [];
|
|
||||||
});
|
|
||||||
const upperSelectorOptions = computed(() => {
|
|
||||||
const count = Math.min(2, Math.floor(selectorOptions.value.length / 2));
|
|
||||||
return selectorOptions.value.slice(0, count);
|
|
||||||
});
|
|
||||||
const upperAxisHeight = computed(() => {
|
|
||||||
const count = upperSelectorOptions.value.length;
|
|
||||||
return count * 72 + Math.max(0, count - 1) * 10 + 34;
|
|
||||||
});
|
|
||||||
const axisBaseOffset = computed(() => upperSelectorOptions.value.length * 82);
|
|
||||||
const axisMaxOffset = computed(() =>
|
|
||||||
Math.max(0, (selectorOptions.value.length - upperSelectorOptions.value.length - 2) * 82),
|
|
||||||
);
|
|
||||||
const selectorPath = computed<(EntryTypeOption | Category)[]>(() => {
|
|
||||||
if (selectorLevel.value === "type") return [];
|
|
||||||
if (selectorLevel.value === "category") return [typeOption.value];
|
|
||||||
return selectedParent.value ? [typeOption.value, selectedParent.value] : [typeOption.value];
|
|
||||||
});
|
|
||||||
|
|
||||||
function toLocalDateTime(date: Date) {
|
function toLocalDateTime(date: Date) {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
|
|
@ -187,7 +145,6 @@ function selectOperator(operator: "+" | "-") {
|
||||||
function openSelector() {
|
function openSelector() {
|
||||||
if (selectorOpen.value) return;
|
if (selectorOpen.value) return;
|
||||||
selectorOpen.value = true;
|
selectorOpen.value = true;
|
||||||
resetAxisScroll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDatePicker() {
|
function openDatePicker() {
|
||||||
|
|
@ -198,132 +155,6 @@ function openDatePicker() {
|
||||||
else input.click();
|
else input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseSelectorOption(option: EntryTypeOption | Category) {
|
|
||||||
if (selectorLevel.value === "type") {
|
|
||||||
const typeChanged = option.id !== entryType.value;
|
|
||||||
entryType.value = option.id as EntryType;
|
|
||||||
if (typeChanged) categoryPath.value = [];
|
|
||||||
selectorLevel.value = "category";
|
|
||||||
resetAxisScroll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = option as Category;
|
|
||||||
if (selectorLevel.value === "category") {
|
|
||||||
const currentChild = selectedParent.value?.id === category.id ? categoryPath.value[1] : undefined;
|
|
||||||
categoryPath.value = currentChild ? [category, currentChild] : [category];
|
|
||||||
if (category.children?.length) {
|
|
||||||
selectorLevel.value = "subcategory";
|
|
||||||
resetAxisScroll();
|
|
||||||
}
|
|
||||||
else confirmCategory();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedParent.value) return;
|
|
||||||
categoryPath.value = [selectedParent.value, category];
|
|
||||||
confirmCategory();
|
|
||||||
}
|
|
||||||
|
|
||||||
function choosePathLevel(index: number) {
|
|
||||||
selectorLevel.value = index === 0 ? "type" : "category";
|
|
||||||
resetAxisScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSelectorOptionSelected(option: EntryTypeOption | Category) {
|
|
||||||
if (selectorLevel.value === "type") return option.id === entryType.value;
|
|
||||||
if (selectorLevel.value === "category") return option.id === selectedParent.value?.id;
|
|
||||||
return categoryPath.value.length > 1 && option.id === selectedCategory.value?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAxisScroll() {
|
|
||||||
stopAxisInertia();
|
|
||||||
axisOffset.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampAxisOffset(value: number) {
|
|
||||||
return Math.max(0, Math.min(axisMaxOffset.value, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAxisDrag(event: PointerEvent) {
|
|
||||||
stopAxisInertia();
|
|
||||||
axisPointerId = event.pointerId;
|
|
||||||
axisDragStartY = event.clientY;
|
|
||||||
axisDragStartOffset = axisOffset.value;
|
|
||||||
axisDragged = false;
|
|
||||||
axisLastY = event.clientY;
|
|
||||||
axisLastTime = performance.now();
|
|
||||||
axisVelocity = 0;
|
|
||||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveAxisDrag(event: PointerEvent) {
|
|
||||||
if (axisPointerId !== event.pointerId) return;
|
|
||||||
const distance = axisDragStartY - event.clientY;
|
|
||||||
if (Math.abs(distance) > 5) axisDragged = true;
|
|
||||||
axisOffset.value = clampAxisOffset(axisDragStartOffset + distance);
|
|
||||||
|
|
||||||
const now = performance.now();
|
|
||||||
const elapsed = Math.max(1, now - axisLastTime);
|
|
||||||
const instantaneousVelocity = (axisLastY - event.clientY) / elapsed;
|
|
||||||
axisVelocity = axisVelocity * 0.65 + instantaneousVelocity * 0.35;
|
|
||||||
axisLastY = event.clientY;
|
|
||||||
axisLastTime = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
function endAxisDrag(event: PointerEvent) {
|
|
||||||
if (axisPointerId !== event.pointerId) return;
|
|
||||||
axisPointerId = null;
|
|
||||||
const target = event.currentTarget as HTMLElement;
|
|
||||||
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
|
||||||
if (axisDragged) startAxisInertia();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAxisClick(event: MouseEvent) {
|
|
||||||
if (!axisDragged) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
axisDragged = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAxisBy(delta: number) {
|
|
||||||
stopAxisInertia();
|
|
||||||
axisOffset.value = clampAxisOffset(axisOffset.value + delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAxisInertia() {
|
|
||||||
if (Math.abs(axisVelocity) < 0.04) return;
|
|
||||||
let previousTime = performance.now();
|
|
||||||
|
|
||||||
const step = (now: number) => {
|
|
||||||
const elapsed = Math.min(32, now - previousTime);
|
|
||||||
previousTime = now;
|
|
||||||
const current = axisOffset.value;
|
|
||||||
const next = clampAxisOffset(current + axisVelocity * elapsed);
|
|
||||||
axisOffset.value = next;
|
|
||||||
|
|
||||||
if (next === current && (next === 0 || next === axisMaxOffset.value)) {
|
|
||||||
stopAxisInertia();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
axisVelocity *= Math.pow(0.91, elapsed / 16);
|
|
||||||
if (Math.abs(axisVelocity) < 0.025) {
|
|
||||||
stopAxisInertia();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
axisInertiaFrame = requestAnimationFrame(step);
|
|
||||||
};
|
|
||||||
|
|
||||||
axisInertiaFrame = requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAxisInertia() {
|
|
||||||
if (axisInertiaFrame !== null) cancelAnimationFrame(axisInertiaFrame);
|
|
||||||
axisInertiaFrame = null;
|
|
||||||
axisVelocity = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmCategory() {
|
function confirmCategory() {
|
||||||
selectorOpen.value = false;
|
selectorOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -425,86 +256,12 @@ function saveEntry() {
|
||||||
<span>{{ selectorOpen ? "确定" : categoryDisplayLabel }}</span>
|
<span>{{ selectorOpen ? "确定" : categoryDisplayLabel }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Transition name="coordinate-selector">
|
<CategoryCoordinateSelector
|
||||||
<div v-if="selectorOpen" class="category-coordinate" aria-label="二维分类选择器" @click.stop>
|
v-model:entry-type="entryType"
|
||||||
<div class="selector-path" aria-label="已选分类路径">
|
v-model:category-path="categoryPath"
|
||||||
<template v-for="(option, index) in selectorPath" :key="option.id">
|
:open="selectorOpen"
|
||||||
<ChevronRight v-if="index > 0" :size="18" aria-hidden="true" />
|
@confirm="confirmCategory"
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
:style="{ '--path-color': option.color }"
|
|
||||||
:aria-label="`返回${option.label}层重新选择`"
|
|
||||||
@click="choosePathLevel(index)"
|
|
||||||
>
|
|
||||||
<component :is="option.icon" :size="22" />
|
|
||||||
<small>{{ option.label }}</small>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<ChevronRight v-if="selectorPath.length" :size="18" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="upperSelectorOptions.length"
|
|
||||||
class="candidate-above-axis"
|
|
||||||
:style="{ height: `${upperAxisHeight}px` }"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="candidate-axis-viewport"
|
|
||||||
@pointerdown="startAxisDrag"
|
|
||||||
@pointermove="moveAxisDrag"
|
|
||||||
@pointerup="endAxisDrag"
|
|
||||||
@pointercancel="endAxisDrag"
|
|
||||||
@click.capture="handleAxisClick"
|
|
||||||
@wheel.prevent="scrollAxisBy($event.deltaY)"
|
|
||||||
>
|
|
||||||
<div class="candidate-axis-list" :style="{ transform: `translate3d(0, -${axisOffset}px, 0)` }">
|
|
||||||
<button
|
|
||||||
v-for="option in selectorOptions"
|
|
||||||
:key="option.id"
|
|
||||||
type="button"
|
|
||||||
class="coordinate-option"
|
|
||||||
:class="{ selected: isSelectorOptionSelected(option) }"
|
|
||||||
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
|
|
||||||
@click="chooseSelectorOption(option)"
|
|
||||||
>
|
|
||||||
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
|
|
||||||
<span>{{ option.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="candidate-below-axis">
|
|
||||||
<div
|
|
||||||
class="candidate-axis-viewport"
|
|
||||||
@pointerdown="startAxisDrag"
|
|
||||||
@pointermove="moveAxisDrag"
|
|
||||||
@pointerup="endAxisDrag"
|
|
||||||
@pointercancel="endAxisDrag"
|
|
||||||
@click.capture="handleAxisClick"
|
|
||||||
@wheel.prevent="scrollAxisBy($event.deltaY)"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="candidate-axis-list"
|
|
||||||
:style="{ transform: `translate3d(0, -${axisBaseOffset + axisOffset}px, 0)` }"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="option in selectorOptions"
|
|
||||||
:key="option.id"
|
|
||||||
type="button"
|
|
||||||
class="coordinate-option"
|
|
||||||
:class="{ selected: isSelectorOptionSelected(option) }"
|
|
||||||
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
|
|
||||||
@click="chooseSelectorOption(option)"
|
|
||||||
>
|
|
||||||
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
|
|
||||||
<span>{{ option.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue