fix(mqtt): make payload resizing accessible
This commit is contained in:
1 parent
9a30b95a1a
commit
668c5ddadb
10 files changed
+264
-4
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { mqttPublish } from "@/lib/backend/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,6 +24,18 @@ const encoding = ref<PayloadEncoding>("plaintext");
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const success = ref(false);
|
||||
const publishPanelRef = ref<HTMLElement | null>(null);
|
||||
const payloadTextareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const payloadHeight = ref(80);
|
||||
const resizing = ref(false);
|
||||
|
||||
const MQTT_PAYLOAD_MIN_HEIGHT_PX = 60;
|
||||
const MQTT_PAYLOAD_MAX_PANEL_RATIO = 0.5;
|
||||
const MQTT_PAYLOAD_KEYBOARD_STEP_PX = 10;
|
||||
const MQTT_PAYLOAD_HEIGHT_STORAGE_KEY = "dbx-mqtt-payload-height";
|
||||
let resizeStartY = 0;
|
||||
let resizeStartHeight = 0;
|
||||
let panelResizeObserver: ResizeObserver | undefined;
|
||||
|
||||
const qosLabels = ["QoS 0", "QoS 1", "QoS 2"];
|
||||
const qosHints = computed(() => [t("connection.mqttQosAtMostOnce"), t("connection.mqttQosAtLeastOnce"), t("connection.mqttQosExactlyOnce")]);
|
||||
@@ -55,6 +67,89 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
/** 计算消息输入框允许使用的最大高度,避免挤占全部消息列表区域 */
|
||||
function maxPayloadHeight() {
|
||||
const containerHeight = publishPanelRef.value?.parentElement?.clientHeight || window.innerHeight || 0;
|
||||
const panelHeight = publishPanelRef.value?.offsetHeight || 0;
|
||||
const currentTextareaHeight = payloadTextareaRef.value?.offsetHeight || payloadHeight.value;
|
||||
const panelChromeHeight = Math.max(0, panelHeight - currentTextareaHeight);
|
||||
return Math.max(MQTT_PAYLOAD_MIN_HEIGHT_PX, Math.floor(containerHeight * MQTT_PAYLOAD_MAX_PANEL_RATIO - panelChromeHeight));
|
||||
}
|
||||
|
||||
/** 将消息输入框高度限制在可用区域内 */
|
||||
function clampPayloadHeight(height: number) {
|
||||
return Math.max(MQTT_PAYLOAD_MIN_HEIGHT_PX, Math.min(maxPayloadHeight(), Math.round(height)));
|
||||
}
|
||||
|
||||
function persistPayloadHeight(height: number) {
|
||||
payloadHeight.value = clampPayloadHeight(height);
|
||||
localStorage.setItem(MQTT_PAYLOAD_HEIGHT_STORAGE_KEY, payloadHeight.value.toString());
|
||||
}
|
||||
|
||||
/** 容器尺寸变化时同步收缩输入框,保证消息列表仍可见 */
|
||||
function handlePanelResize() {
|
||||
payloadHeight.value = clampPayloadHeight(payloadHeight.value);
|
||||
}
|
||||
|
||||
/** 开始拖动消息输入框顶部的高度调节条 */
|
||||
function startResize(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
resizing.value = true;
|
||||
resizeStartY = event.clientY;
|
||||
resizeStartHeight = payloadHeight.value;
|
||||
|
||||
document.addEventListener("mousemove", handleResize);
|
||||
document.addEventListener("mouseup", stopResize);
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "ns-resize";
|
||||
}
|
||||
|
||||
/** 根据向上拖动的距离实时调整消息输入框高度 */
|
||||
function handleResize(event: MouseEvent) {
|
||||
if (!resizing.value) return;
|
||||
payloadHeight.value = clampPayloadHeight(resizeStartHeight + resizeStartY - event.clientY);
|
||||
}
|
||||
|
||||
function handleResizeKeydown(event: KeyboardEvent) {
|
||||
const direction = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||
if (direction === 0) return;
|
||||
event.preventDefault();
|
||||
persistPayloadHeight(payloadHeight.value + direction * MQTT_PAYLOAD_KEYBOARD_STEP_PX);
|
||||
}
|
||||
|
||||
/** 结束拖动并记住用户设置的高度 */
|
||||
function stopResize() {
|
||||
if (!resizing.value) return;
|
||||
resizing.value = false;
|
||||
document.removeEventListener("mousemove", handleResize);
|
||||
document.removeEventListener("mouseup", stopResize);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
persistPayloadHeight(payloadHeight.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const savedHeight = Number.parseInt(localStorage.getItem(MQTT_PAYLOAD_HEIGHT_STORAGE_KEY) ?? "", 10);
|
||||
if (Number.isFinite(savedHeight)) payloadHeight.value = clampPayloadHeight(savedHeight);
|
||||
|
||||
window.addEventListener("resize", handlePanelResize);
|
||||
if (typeof ResizeObserver !== "undefined" && publishPanelRef.value?.parentElement) {
|
||||
panelResizeObserver = new ResizeObserver(handlePanelResize);
|
||||
panelResizeObserver.observe(publishPanelRef.value.parentElement);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("mousemove", handleResize);
|
||||
document.removeEventListener("mouseup", stopResize);
|
||||
window.removeEventListener("resize", handlePanelResize);
|
||||
panelResizeObserver?.disconnect();
|
||||
if (resizing.value) {
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
}
|
||||
});
|
||||
|
||||
async function publish() {
|
||||
const publishTopic = topic.value.trim();
|
||||
if (!publishTopic) {
|
||||
@@ -102,7 +197,20 @@ function clearForm() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="publish-panel">
|
||||
<div ref="publishPanelRef" class="publish-panel">
|
||||
<div
|
||||
class="payload-resize-handle"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
aria-orientation="horizontal"
|
||||
:aria-label="t('connection.mqttResizePayload')"
|
||||
:aria-valuemin="MQTT_PAYLOAD_MIN_HEIGHT_PX"
|
||||
:aria-valuemax="maxPayloadHeight()"
|
||||
:aria-valuenow="payloadHeight"
|
||||
:title="t('connection.mqttResizePayload')"
|
||||
@mousedown="startResize"
|
||||
@keydown="handleResizeKeydown"
|
||||
/>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{ t("connection.mqttPublish") }}</span>
|
||||
<Button size="sm" variant="ghost" class="h-6 text-xs" @click="clearForm">{{ t("common.clear") }}</Button>
|
||||
@@ -150,7 +258,7 @@ function clearForm() {
|
||||
<!-- Payload -->
|
||||
<div class="form-group flex-1 flex flex-col min-h-0">
|
||||
<label class="form-label">{{ t("connection.mqttPayload") }}</label>
|
||||
<textarea v-model="payloadText" class="form-textarea flex-1 font-mono text-xs" :placeholder="payloadPlaceholder" rows="4" @keydown.ctrl.enter="publish()" />
|
||||
<textarea ref="payloadTextareaRef" v-model="payloadText" class="form-textarea font-mono text-xs" :style="{ height: `${payloadHeight}px`, maxHeight: `${maxPayloadHeight()}px` }" :placeholder="payloadPlaceholder" @keydown.ctrl.enter="publish()" />
|
||||
<span class="form-hint">{{ t("connection.mqttPublishShortcut") }}</span>
|
||||
</div>
|
||||
|
||||
@@ -169,6 +277,7 @@ function clearForm() {
|
||||
|
||||
<style scoped>
|
||||
.publish-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -177,6 +286,37 @@ function clearForm() {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.payload-resize-handle {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
height: 9px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.payload-resize-handle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.payload-resize-handle:hover::before,
|
||||
.payload-resize-handle:focus-visible::before {
|
||||
background-color: color-mix(in srgb, var(--color-text) 20%, transparent);
|
||||
}
|
||||
|
||||
.payload-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -240,7 +380,7 @@ function clearForm() {
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
resize: none;
|
||||
min-height: 60px;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.4;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n, { loadLocaleMessages } from "@/i18n";
|
||||
import MqttPublishDialog from "@/components/mqtt/MqttPublishDialog.vue";
|
||||
|
||||
const { mqttPublishMock } = vi.hoisted(() => ({
|
||||
mqttPublishMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
mqttPublish: mqttPublishMock,
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
async function mountPublishDialog() {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(MqttPublishDialog, {
|
||||
connectionId: "mqtt-connection-1",
|
||||
initialTopic: "device/status",
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
mqttPublishMock.mockReset().mockResolvedValue(undefined);
|
||||
localStorage.clear();
|
||||
await loadLocaleMessages("zh-CN");
|
||||
i18n.global.locale.value = "zh-CN";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("MQTT 消息发布输入框", () => {
|
||||
it("支持从顶部拖动调节高度并记住设置", async () => {
|
||||
const container = await mountPublishDialog();
|
||||
const handle = container.querySelector<HTMLElement>(".payload-resize-handle");
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
expect(handle?.getAttribute("aria-label")).toBe("拖动调节消息输入框高度");
|
||||
expect(handle?.getAttribute("role")).toBe("separator");
|
||||
expect(handle?.getAttribute("aria-orientation")).toBe("horizontal");
|
||||
|
||||
handle?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 300 }));
|
||||
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientY: 180 }));
|
||||
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, clientY: 180 }));
|
||||
await nextTick();
|
||||
|
||||
expect(textarea?.style.height).toBe("200px");
|
||||
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe("200");
|
||||
});
|
||||
|
||||
it("支持键盘调节高度并记住设置", async () => {
|
||||
const container = await mountPublishDialog();
|
||||
const handle = container.querySelector<HTMLElement>(".payload-resize-handle");
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
|
||||
expect(handle?.tabIndex).toBe(0);
|
||||
handle?.focus();
|
||||
expect(document.activeElement).toBe(handle);
|
||||
|
||||
handle?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowUp" }));
|
||||
await nextTick();
|
||||
|
||||
expect(textarea?.style.height).toBe("90px");
|
||||
expect(handle?.getAttribute("aria-valuenow")).toBe("90");
|
||||
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe("90");
|
||||
|
||||
handle?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" }));
|
||||
await nextTick();
|
||||
|
||||
expect(textarea?.style.height).toBe("80px");
|
||||
expect(handle?.getAttribute("aria-valuenow")).toBe("80");
|
||||
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe("80");
|
||||
});
|
||||
|
||||
it("将键盘调节限制在分隔条声明的高度范围内", async () => {
|
||||
const container = await mountPublishDialog();
|
||||
const handle = container.querySelector<HTMLElement>(".payload-resize-handle");
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
const minHeight = Number(handle?.getAttribute("aria-valuemin"));
|
||||
const maxHeight = Number(handle?.getAttribute("aria-valuemax"));
|
||||
|
||||
for (let i = 0; i < 100; i++) handle?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
|
||||
await nextTick();
|
||||
|
||||
expect(textarea?.style.height).toBe(`${minHeight}px`);
|
||||
expect(handle?.getAttribute("aria-valuenow")).toBe(minHeight.toString());
|
||||
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe(minHeight.toString());
|
||||
|
||||
for (let i = 0; i < 100; i++) handle?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
|
||||
await nextTick();
|
||||
|
||||
expect(textarea?.style.height).toBe(`${maxHeight}px`);
|
||||
expect(handle?.getAttribute("aria-valuenow")).toBe(maxHeight.toString());
|
||||
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe(maxHeight.toString());
|
||||
});
|
||||
});
|
||||
@@ -804,6 +804,7 @@ export default {
|
||||
mqttRetainMessage: "Retain message",
|
||||
mqttPayloadEncoding: "Payload encoding",
|
||||
mqttPayload: "Payload",
|
||||
mqttResizePayload: "Drag to resize the message input",
|
||||
mqttPayloadPlaceholderPlaintext: "Enter message content...",
|
||||
mqttPayloadPlaceholderJson: "Enter JSON, for example {example}",
|
||||
mqttPayloadPlaceholderBase64: "Enter Base64-encoded data",
|
||||
|
||||
@@ -1036,6 +1036,7 @@ export default withEnglishFallback({
|
||||
mqttRetainMessage: "Retener mensaje",
|
||||
mqttPayloadEncoding: "Codificación del Payload",
|
||||
mqttPayload: "Payload",
|
||||
mqttResizePayload: "Arrastra para ajustar la altura del campo de mensaje",
|
||||
mqttPayloadPlaceholderPlaintext: "Introduce el contenido del mensaje...",
|
||||
mqttPayloadPlaceholderJson: "Introduce JSON, por ejemplo {example}",
|
||||
mqttPayloadPlaceholderBase64: "Introduce datos codificados en Base64",
|
||||
|
||||
@@ -1034,6 +1034,7 @@ export default withEnglishFallback({
|
||||
mqttRetainMessage: "Mantieni messaggio",
|
||||
mqttPayloadEncoding: "Codifica del Payload",
|
||||
mqttPayload: "Payload",
|
||||
mqttResizePayload: "Trascina per regolare l'altezza del campo messaggio",
|
||||
mqttPayloadPlaceholderPlaintext: "Inserisci il contenuto del messaggio...",
|
||||
mqttPayloadPlaceholderJson: "Inserisci JSON, ad esempio {example}",
|
||||
mqttPayloadPlaceholderBase64: "Inserisci dati codificati in Base64",
|
||||
|
||||
@@ -1034,6 +1034,7 @@ export default withEnglishFallback({
|
||||
mqttRetainMessage: "メッセージを保持",
|
||||
mqttPayloadEncoding: "ペイロードのエンコード",
|
||||
mqttPayload: "ペイロード",
|
||||
mqttResizePayload: "ドラッグしてメッセージ入力欄の高さを調整",
|
||||
mqttPayloadPlaceholderPlaintext: "メッセージ内容を入力...",
|
||||
mqttPayloadPlaceholderJson: "JSON を入力(例: {example})",
|
||||
mqttPayloadPlaceholderBase64: "Base64 エンコード済みデータを入力",
|
||||
|
||||
@@ -1035,6 +1035,7 @@ export default withEnglishFallback({
|
||||
mqttRetainMessage: "Reter mensagem",
|
||||
mqttPayloadEncoding: "Codificação do Payload",
|
||||
mqttPayload: "Payload",
|
||||
mqttResizePayload: "Arraste para ajustar a altura do campo de mensagem",
|
||||
mqttPayloadPlaceholderPlaintext: "Digite o conteúdo da mensagem...",
|
||||
mqttPayloadPlaceholderJson: "Digite JSON, por exemplo {example}",
|
||||
mqttPayloadPlaceholderBase64: "Digite dados codificados em Base64",
|
||||
|
||||
@@ -729,6 +729,7 @@ export default withEnglishFallback({
|
||||
mqttRetainMessage: "保留消息",
|
||||
mqttPayloadEncoding: "Payload 编码",
|
||||
mqttPayload: "消息内容 (Payload)",
|
||||
mqttResizePayload: "拖动调节消息输入框高度",
|
||||
mqttPayloadPlaceholderPlaintext: "输入消息内容...",
|
||||
mqttPayloadPlaceholderJson: "输入 JSON,例如 {example}",
|
||||
mqttPayloadPlaceholderBase64: "输入 Base64 编码的数据",
|
||||
|
||||
@@ -1034,6 +1034,7 @@ export default withEnglishFallback({
|
||||
mqttRetainMessage: "保留訊息",
|
||||
mqttPayloadEncoding: "Payload 編碼",
|
||||
mqttPayload: "訊息內容 (Payload)",
|
||||
mqttResizePayload: "拖曳調整訊息輸入框高度",
|
||||
mqttPayloadPlaceholderPlaintext: "輸入訊息內容...",
|
||||
mqttPayloadPlaceholderJson: "輸入 JSON,例如 {example}",
|
||||
mqttPayloadPlaceholderBase64: "輸入 Base64 編碼的資料",
|
||||
|
||||
Reference in new issue
Block a user