feat: add mobile web frontend with AudioWorklet recording
This commit is contained in:
313
web/app.js
Normal file
313
web/app.js
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* VoicePaste — Main application logic.
|
||||
*
|
||||
* Modules:
|
||||
* 1. WebSocket client (token auth, reconnect)
|
||||
* 2. Audio pipeline (getUserMedia → AudioWorklet → resample → WS binary)
|
||||
* 3. Recording controls (touch/mouse, state machine)
|
||||
* 4. History (localStorage, tap to re-send)
|
||||
* 5. UI state management
|
||||
*/
|
||||
;(function () {
|
||||
"use strict";
|
||||
|
||||
// ── Constants ──
|
||||
const TARGET_SAMPLE_RATE = 16000;
|
||||
const WS_RECONNECT_BASE = 1000;
|
||||
const WS_RECONNECT_MAX = 16000;
|
||||
const HISTORY_KEY = "voicepaste_history";
|
||||
const HISTORY_MAX = 50;
|
||||
|
||||
// ── DOM refs ──
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const statusEl = $("#status");
|
||||
const statusText = $("#status-text");
|
||||
const previewText = $("#preview-text");
|
||||
const previewBox = $("#preview");
|
||||
const micBtn = $("#mic-btn");
|
||||
const historyList = $("#history-list");
|
||||
const historyEmpty = $("#history-empty");
|
||||
const clearHistoryBtn = $("#clear-history");
|
||||
|
||||
// ── State ──
|
||||
const state = {
|
||||
ws: null,
|
||||
connected: false,
|
||||
recording: false,
|
||||
audioCtx: null,
|
||||
workletNode: null,
|
||||
stream: null,
|
||||
reconnectDelay: WS_RECONNECT_BASE,
|
||||
reconnectTimer: null,
|
||||
};
|
||||
|
||||
// ── Utility ──
|
||||
function getToken() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return params.get("token") || "";
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
// ── Resampler (linear interpolation, native rate → 16kHz 16-bit mono) ──
|
||||
function resampleTo16kInt16(float32, srcRate) {
|
||||
const ratio = srcRate / TARGET_SAMPLE_RATE;
|
||||
const outLen = Math.floor(float32.length / ratio);
|
||||
const out = new Int16Array(outLen);
|
||||
for (let i = 0; i < outLen; i++) {
|
||||
const srcIdx = i * ratio;
|
||||
const lo = Math.floor(srcIdx);
|
||||
const hi = Math.min(lo + 1, float32.length - 1);
|
||||
const frac = srcIdx - lo;
|
||||
const sample = float32[lo] + frac * (float32[hi] - float32[lo]);
|
||||
// Clamp to [-1, 1] then scale to Int16
|
||||
out[i] = Math.max(-32768, Math.min(32767, Math.round(sample * 32767)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// ── WebSocket ──
|
||||
function wsUrl() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const token = getToken();
|
||||
const q = token ? `?token=${encodeURIComponent(token)}` : "";
|
||||
return `${proto}//${location.host}/ws${q}`;
|
||||
}
|
||||
function setStatus(cls, text) {
|
||||
statusEl.className = `status ${cls}`;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
function connectWS() {
|
||||
if (state.ws) return;
|
||||
setStatus("connecting", "连接中…");
|
||||
const ws = new WebSocket(wsUrl());
|
||||
ws.binaryType = "arraybuffer";
|
||||
ws.onopen = () => {
|
||||
state.connected = true;
|
||||
state.reconnectDelay = WS_RECONNECT_BASE;
|
||||
setStatus("connected", "已连接");
|
||||
micBtn.disabled = false;
|
||||
};
|
||||
ws.onmessage = (e) => handleServerMsg(e.data);
|
||||
ws.onclose = () => {
|
||||
state.connected = false;
|
||||
state.ws = null;
|
||||
micBtn.disabled = true;
|
||||
if (state.recording) stopRecording();
|
||||
setStatus("disconnected", "已断开");
|
||||
scheduleReconnect();
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
state.ws = ws;
|
||||
}
|
||||
function scheduleReconnect() {
|
||||
clearTimeout(state.reconnectTimer);
|
||||
state.reconnectTimer = setTimeout(() => {
|
||||
connectWS();
|
||||
}, state.reconnectDelay);
|
||||
state.reconnectDelay = Math.min(state.reconnectDelay * 2, WS_RECONNECT_MAX);
|
||||
}
|
||||
function sendJSON(obj) {
|
||||
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
||||
state.ws.send(JSON.stringify(obj));
|
||||
}
|
||||
}
|
||||
function sendBinary(int16arr) {
|
||||
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
||||
state.ws.send(int16arr.buffer);
|
||||
}
|
||||
}
|
||||
// ── Server message handler ──
|
||||
function handleServerMsg(data) {
|
||||
if (typeof data !== "string") return;
|
||||
let msg;
|
||||
try { msg = JSON.parse(data); } catch { return; }
|
||||
switch (msg.type) {
|
||||
case "partial":
|
||||
setPreview(msg.text, false);
|
||||
break;
|
||||
case "final":
|
||||
setPreview(msg.text, true);
|
||||
if (msg.text) addHistory(msg.text);
|
||||
break;
|
||||
case "pasted":
|
||||
showToast("✅ 已粘贴");
|
||||
break;
|
||||
case "error":
|
||||
showToast(`❌ ${msg.message || "错误"}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
function setPreview(text, isFinal) {
|
||||
if (!text) {
|
||||
previewText.textContent = "按住说话…";
|
||||
previewText.classList.add("placeholder");
|
||||
previewBox.classList.remove("active");
|
||||
return;
|
||||
}
|
||||
previewText.textContent = text;
|
||||
previewText.classList.remove("placeholder");
|
||||
previewBox.classList.toggle("active", !isFinal);
|
||||
}
|
||||
function showToast(msg) {
|
||||
// Lightweight toast — reuse or create
|
||||
let toast = $("#toast");
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.id = "toast";
|
||||
toast.style.cssText =
|
||||
"position:fixed;bottom:calc(100px + var(--safe-bottom,0px));left:50%;" +
|
||||
"transform:translateX(-50%);background:#222;color:#eee;padding:8px 18px;" +
|
||||
"border-radius:20px;font-size:14px;z-index:999;opacity:0;transition:opacity .3s;";
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = msg;
|
||||
toast.style.opacity = "1";
|
||||
clearTimeout(toast._timer);
|
||||
toast._timer = setTimeout(() => { toast.style.opacity = "0"; }, 2000);
|
||||
}
|
||||
// ── Audio pipeline ──
|
||||
async function initAudio() {
|
||||
if (state.audioCtx) return;
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: TARGET_SAMPLE_RATE, // Request 16kHz directly (Chrome supports)
|
||||
});
|
||||
await audioCtx.audioWorklet.addModule("audio-processor.js");
|
||||
state.audioCtx = audioCtx;
|
||||
}
|
||||
async function startRecording() {
|
||||
if (state.recording) return;
|
||||
try {
|
||||
await initAudio();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 },
|
||||
});
|
||||
state.stream = stream;
|
||||
const source = state.audioCtx.createMediaStreamSource(stream);
|
||||
const worklet = new AudioWorkletNode(state.audioCtx, "audio-processor");
|
||||
worklet.port.onmessage = (e) => {
|
||||
if (e.data.type === "audio") {
|
||||
const int16 = resampleTo16kInt16(e.data.samples, e.data.sampleRate);
|
||||
sendBinary(int16);
|
||||
}
|
||||
};
|
||||
source.connect(worklet);
|
||||
// Don't connect worklet to destination (no playback)
|
||||
state.workletNode = worklet;
|
||||
state.recording = true;
|
||||
sendJSON({ type: "start" });
|
||||
micBtn.classList.add("recording");
|
||||
setPreview("", false);
|
||||
} catch (err) {
|
||||
showToast(`麦克风错误: ${err.message}`);
|
||||
}
|
||||
}
|
||||
function stopRecording() {
|
||||
if (!state.recording) return;
|
||||
state.recording = false;
|
||||
// Stop worklet
|
||||
if (state.workletNode) {
|
||||
state.workletNode.port.postMessage({ command: "stop" });
|
||||
state.workletNode.disconnect();
|
||||
state.workletNode = null;
|
||||
}
|
||||
// Stop mic stream
|
||||
if (state.stream) {
|
||||
state.stream.getTracks().forEach((t) => t.stop());
|
||||
state.stream = null;
|
||||
}
|
||||
sendJSON({ type: "stop" });
|
||||
micBtn.classList.remove("recording");
|
||||
}
|
||||
// ── History (localStorage) ──
|
||||
function loadHistory() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(HISTORY_KEY)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function saveHistory(items) {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(items));
|
||||
}
|
||||
function addHistory(text) {
|
||||
const items = loadHistory();
|
||||
items.unshift({ text, ts: Date.now() });
|
||||
if (items.length > HISTORY_MAX) items.length = HISTORY_MAX;
|
||||
saveHistory(items);
|
||||
renderHistory();
|
||||
}
|
||||
function clearHistory() {
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
renderHistory();
|
||||
}
|
||||
function renderHistory() {
|
||||
const items = loadHistory();
|
||||
historyList.innerHTML = "";
|
||||
if (!items.length) {
|
||||
historyEmpty.style.display = "";
|
||||
return;
|
||||
}
|
||||
historyEmpty.style.display = "none";
|
||||
for (const item of items) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
`<span class="hist-text">${escapeHtml(item.text)}</span>` +
|
||||
`<span class="hist-time">${formatTime(item.ts)}</span>`;
|
||||
li.addEventListener("click", () => {
|
||||
sendJSON({ type: "paste", text: item.text });
|
||||
showToast("发送粘贴…");
|
||||
});
|
||||
historyList.appendChild(li);
|
||||
}
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
// ── Event bindings ──
|
||||
function bindMicButton() {
|
||||
// Touch events (mobile primary)
|
||||
micBtn.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
startRecording();
|
||||
}, { passive: false });
|
||||
micBtn.addEventListener("touchend", (e) => {
|
||||
e.preventDefault();
|
||||
stopRecording();
|
||||
}, { passive: false });
|
||||
micBtn.addEventListener("touchcancel", (e) => {
|
||||
e.preventDefault();
|
||||
stopRecording();
|
||||
}, { passive: false });
|
||||
// Mouse fallback (desktop testing)
|
||||
micBtn.addEventListener("mousedown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
startRecording();
|
||||
});
|
||||
micBtn.addEventListener("mouseup", () => stopRecording());
|
||||
micBtn.addEventListener("mouseleave", () => {
|
||||
if (state.recording) stopRecording();
|
||||
});
|
||||
}
|
||||
// ── Init ──
|
||||
function init() {
|
||||
micBtn.disabled = true;
|
||||
bindMicButton();
|
||||
if (clearHistoryBtn) {
|
||||
clearHistoryBtn.addEventListener("click", clearHistory);
|
||||
}
|
||||
renderHistory();
|
||||
connectWS();
|
||||
}
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
73
web/audio-processor.js
Normal file
73
web/audio-processor.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AudioWorklet processor for VoicePaste.
|
||||
*
|
||||
* Captures raw Float32 PCM from the microphone, accumulates samples into
|
||||
* ~200ms frames, and posts them to the main thread for resampling + WS send.
|
||||
*
|
||||
* Communication:
|
||||
* Main → Processor: { command: "start" | "stop" }
|
||||
* Processor → Main: { type: "audio", samples: Float32Array, sampleRate: number }
|
||||
*/
|
||||
class AudioProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.recording = false;
|
||||
this.buffer = [];
|
||||
this.bufferLen = 0;
|
||||
// ~200ms worth of samples at current sample rate
|
||||
// sampleRate is a global in AudioWorkletGlobalScope
|
||||
this.frameSize = Math.floor(sampleRate * 0.2);
|
||||
|
||||
this.port.onmessage = (e) => {
|
||||
if (e.data.command === "start") {
|
||||
this.recording = true;
|
||||
this.buffer = [];
|
||||
this.bufferLen = 0;
|
||||
} else if (e.data.command === "stop") {
|
||||
// Flush remaining samples
|
||||
if (this.bufferLen > 0) {
|
||||
this._flush();
|
||||
}
|
||||
this.recording = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
if (!this.recording) return true;
|
||||
|
||||
const input = inputs[0];
|
||||
if (!input || !input[0]) return true;
|
||||
|
||||
// Mono channel 0
|
||||
const channelData = input[0];
|
||||
this.buffer.push(new Float32Array(channelData));
|
||||
this.bufferLen += channelData.length;
|
||||
|
||||
if (this.bufferLen >= this.frameSize) {
|
||||
this._flush();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_flush() {
|
||||
// Merge buffer chunks into a single Float32Array
|
||||
const merged = new Float32Array(this.bufferLen);
|
||||
let offset = 0;
|
||||
for (const chunk of this.buffer) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
this.port.postMessage(
|
||||
{ type: "audio", samples: merged, sampleRate: sampleRate },
|
||||
[merged.buffer] // Transfer ownership for zero-copy
|
||||
);
|
||||
|
||||
this.buffer = [];
|
||||
this.bufferLen = 0;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("audio-processor", AudioProcessor);
|
||||
48
web/index.html
Normal file
48
web/index.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>VoicePaste</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<h1>VoicePaste</h1>
|
||||
<div id="status" class="status disconnected">
|
||||
<span class="dot"></span>
|
||||
<span id="status-text">Connecting...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="preview-section">
|
||||
<div id="preview" class="preview-box">
|
||||
<p id="preview-text" class="placeholder">Press and hold to speak</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="mic-section">
|
||||
<button id="mic-btn" disabled>
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section id="history-section">
|
||||
<div class="history-header">
|
||||
<h2>History</h2>
|
||||
<button id="clear-history" class="text-btn">Clear</button>
|
||||
</div>
|
||||
<ul id="history-list"></ul>
|
||||
<p id="history-empty" class="placeholder">No history yet</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
254
web/style.css
Normal file
254
web/style.css
Normal file
@@ -0,0 +1,254 @@
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #161616;
|
||||
--surface-hover: #1e1e1e;
|
||||
--border: #2a2a2a;
|
||||
--text: #e8e8e8;
|
||||
--text-dim: #888;
|
||||
--accent: #3b82f6;
|
||||
--accent-glow: rgba(59, 130, 246, 0.3);
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--radius: 12px;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: calc(16px + var(--safe-top)) 16px calc(16px + var(--safe-bottom));
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status.connected .dot { background: var(--success); }
|
||||
.status.disconnected .dot { background: var(--danger); }
|
||||
.status.connecting .dot {
|
||||
background: var(--accent);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
|
||||
/* Preview */
|
||||
#preview-section {
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
min-height: 80px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.preview-box.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent-glow);
|
||||
}
|
||||
|
||||
#preview-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#preview-text.placeholder {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Mic Button */
|
||||
#mic-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#mic-btn {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#mic-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#mic-btn:not(:disabled):active,
|
||||
#mic-btn.recording {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 0 24px var(--accent-glow);
|
||||
}
|
||||
|
||||
#mic-btn.recording {
|
||||
animation: mic-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes mic-pulse {
|
||||
0%, 100% { box-shadow: 0 0 24px var(--accent-glow); }
|
||||
50% { box-shadow: 0 0 48px var(--accent-glow), 0 0 80px rgba(59, 130, 246, 0.15); }
|
||||
}
|
||||
/* History */
|
||||
#history-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.history-header h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.text-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.text-btn:active {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
#history-list {
|
||||
list-style: none;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
#history-list li {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
#history-list li:active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
#history-list li .hist-text {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
#history-list li .hist-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
#history-empty {
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
}
|
||||
.placeholder {
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
/* Scrollbar */
|
||||
#history-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
#history-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
#history-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
Reference in New Issue
Block a user