189 lines
5.6 KiB
Vue
189 lines
5.6 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 悬浮操作按钮:蓝色渐变「+」,可在屏幕内自由拖动,松手后位置写入 Storage 下次启动恢复。
|
||
* 只做外观与点击上报;新建/上传等动作语义由页面处理。
|
||
* 拖动实现:touch 事件(真机/App)+ mouse 事件(H5 桌面调试)双轨;
|
||
* 位移小于阈值视为轻点、超过才算拖动。点击上报走原生 click(onTap)而非 touchend/mouseup
|
||
* 手动 emit:保留浏览器用户激活链,否则轻点里同步拉起文件选择器等 gesture API 会被静默拦截;
|
||
* 拖动收尾置 suppressClick 吞掉随后的原生 click,防拖完误触。
|
||
*/
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { useThemeTokens } from "@/composables/useThemeGradient";
|
||
|
||
const props = defineProps({
|
||
/** 初始底部偏移(rpx 数值,仅首次无存储记录时生效),供不同页面避开各自的底部元素 */
|
||
bottomOffset: {
|
||
type: Number,
|
||
default: 0,
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits<{
|
||
(e: "click"): void;
|
||
}>();
|
||
|
||
const STORAGE_KEY = "float-add-btn-pos";
|
||
|
||
/** 位移阈值(px):低于它按轻点处理 */
|
||
const TAP_THRESHOLD = 6;
|
||
|
||
const pos = ref({ x: 0, y: 0 });
|
||
|
||
/** 位置定妥前隐藏,避免默认位置 → 恢复位置的一帧闪跳 */
|
||
const ready = ref(false);
|
||
|
||
let dragging = false;
|
||
let moved = false;
|
||
let startPoint = { x: 0, y: 0 };
|
||
let originPos = { x: 0, y: 0 };
|
||
/** touch 已处理标记:移动端浏览器会在 touch 后补发 mouse 兼容事件,防双触发 */
|
||
let touchHandled = false;
|
||
|
||
function sizePx(): number {
|
||
return uni.upx2px(112);
|
||
}
|
||
|
||
/** 夹在屏幕可视区内,四边留 8px 呼吸位 */
|
||
function clampPos(x: number, y: number): { x: number; y: number } {
|
||
const { windowWidth, windowHeight } = uni.getSystemInfoSync();
|
||
const size = sizePx();
|
||
return {
|
||
x: Math.min(Math.max(8, x), windowWidth - size - 8),
|
||
y: Math.min(Math.max(8, y), windowHeight - size - 8),
|
||
};
|
||
}
|
||
|
||
onMounted(() => {
|
||
const { windowWidth, windowHeight } = uni.getSystemInfoSync();
|
||
const size = sizePx();
|
||
let x = windowWidth - size - 20;
|
||
let y = windowHeight - size - uni.upx2px(180 + props.bottomOffset);
|
||
const saved = uni.getStorageSync(STORAGE_KEY) as { x?: number; y?: number } | "";
|
||
if (saved && typeof saved.x === "number" && typeof saved.y === "number") {
|
||
x = saved.x;
|
||
y = saved.y;
|
||
}
|
||
pos.value = clampPos(x, y);
|
||
ready.value = true;
|
||
});
|
||
|
||
const posStyle = computed(() => ({
|
||
left: `${pos.value.x}px`,
|
||
top: `${pos.value.y}px`,
|
||
visibility: ready.value ? "visible" : "hidden",
|
||
}));
|
||
|
||
/** 按钮面色跟随主题(与文件夹图形同派生,主题统一):渐变 + 同色柔光投影 */
|
||
const { accentGradient, accentShadow } = useThemeTokens();
|
||
const skinStyle = computed(() => ({
|
||
background: accentGradient.value,
|
||
boxShadow: `0 12rpx 32rpx ${accentShadow.value}`,
|
||
}));
|
||
|
||
function onDragStart(x: number, y: number): void {
|
||
dragging = true;
|
||
moved = false;
|
||
startPoint = { x, y };
|
||
originPos = { ...pos.value };
|
||
}
|
||
|
||
function onDragMove(x: number, y: number): void {
|
||
if (!dragging) {
|
||
return;
|
||
}
|
||
const dx = x - startPoint.x;
|
||
const dy = y - startPoint.y;
|
||
if (!moved && Math.abs(dx) < TAP_THRESHOLD && Math.abs(dy) < TAP_THRESHOLD) {
|
||
return;
|
||
}
|
||
moved = true;
|
||
pos.value = clampPos(originPos.x + dx, originPos.y + dy);
|
||
}
|
||
|
||
/** 拖动收尾后浏览器还会补发一次原生 click,置位吞掉它(轻点不置位,由 onTap 正常上报) */
|
||
let suppressClick = false;
|
||
|
||
function onDragEnd(): void {
|
||
if (!dragging) {
|
||
return;
|
||
}
|
||
dragging = false;
|
||
if (moved) {
|
||
uni.setStorageSync(STORAGE_KEY, pos.value);
|
||
suppressClick = true;
|
||
}
|
||
}
|
||
|
||
/** 唯一点击出口:原生 click(鼠标轻点/触屏补发)保留用户手势,页面才能在里面拉起文件选择器 */
|
||
function onTap(): void {
|
||
if (suppressClick) {
|
||
suppressClick = false;
|
||
return;
|
||
}
|
||
emit("click");
|
||
}
|
||
|
||
// ===== touch(真机 / App)=====
|
||
function onTouchStart(e: TouchEvent): void {
|
||
touchHandled = true;
|
||
const touch = e.touches[0];
|
||
onDragStart(touch.clientX, touch.clientY);
|
||
}
|
||
|
||
function onTouchMove(e: TouchEvent): void {
|
||
const touch = e.touches[0];
|
||
onDragMove(touch.clientX, touch.clientY);
|
||
}
|
||
|
||
function onTouchEnd(): void {
|
||
onDragEnd();
|
||
// 下一个宏任务复位,让补发的 mouse 兼容事件先被拦截
|
||
setTimeout(() => {
|
||
touchHandled = false;
|
||
}, 0);
|
||
}
|
||
|
||
// ===== mouse(H5 桌面)=====
|
||
function onMouseDown(e: MouseEvent): void {
|
||
if (touchHandled) {
|
||
return;
|
||
}
|
||
// 注意不能 preventDefault:它会取消后续原生 click 及其用户激活,onTap 就再也收不到点击
|
||
onDragStart(e.clientX, e.clientY);
|
||
const move = (ev: MouseEvent) => onDragMove(ev.clientX, ev.clientY);
|
||
const up = () => {
|
||
onDragEnd();
|
||
window.removeEventListener("mousemove", move);
|
||
window.removeEventListener("mouseup", up);
|
||
};
|
||
window.addEventListener("mousemove", move);
|
||
window.addEventListener("mouseup", up);
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<view class="float-add-btn" :style="[posStyle, skinStyle]" @touchstart="onTouchStart"
|
||
@touchmove.stop.prevent="onTouchMove" @touchend="onTouchEnd" @mousedown="onMouseDown" @click="onTap">
|
||
<u-icon name="plus" size="52" color="#ffffff" />
|
||
</view>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
.float-add-btn {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 8;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 112rpx;
|
||
height: 112rpx;
|
||
border-radius: 50%;
|
||
/* 面色与投影由 skinStyle 内联下发(主题派生),这里只留形态 */
|
||
touch-action: none;
|
||
/* 屏蔽浏览器默认滚动手势,touchmove 才不被吞 */
|
||
user-select: none;
|
||
}
|
||
</style>
|