初始提交:Codex Pets 相容的 Claude Code 桌面寵物
摘要: Electron 桌面寵物,透過 Claude Code hooks 即時反映工作狀態(思考、等待授權、失敗、完成),並支援 16 方向追視游標。 根本原因: 專案尚未納入版控,需要建立初始 repo 以便後續追蹤與協作。 影響: 建立 main 分支的第一個版本,包含完整可執行的程式碼與內附寵物「小念」。 修法: 納入以下內容: - main.js / preload.js:Electron 主程序、透明置頂視窗、系統匣選單、HTTP 事件伺服器、游標輪詢與拖曳 - renderer/:Codex V2 spritesheet 幀動畫播放器、session 狀態機、氣泡、alpha 點穿判定 - hook/claude-pet-hook.js:Claude Code hook 端,stdin → POST /event,永遠 exit 0 - lib/、scripts/:hooks 安裝/移除、Windows 開機自動啟動 - pets/xiao-nian:內附寵物包(pet.json + spritesheet.webp) - README.md、docs/states.png:使用說明與狀態總覽圖 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'" />
|
||||
<title>Claude Pet</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="bubble" class="hidden"><span id="bubble-text"></span></div>
|
||||
<canvas id="sprite" width="192" height="208"></canvas>
|
||||
</div>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,479 @@
|
||||
"use strict";
|
||||
// Claude Pet renderer:Codex V2 pet contract 的動畫引擎 + 狀態機
|
||||
// 規格來源:8 欄 x 11 列、192x208 cell,列 0-8 標準動作、列 9-10 為 16 個順時針看向方向(000 = 正上方)
|
||||
|
||||
const CELL_W = 192;
|
||||
const CELL_H = 208;
|
||||
|
||||
const ROW = {
|
||||
idle: 0,
|
||||
"running-right": 1,
|
||||
"running-left": 2,
|
||||
waving: 3,
|
||||
jumping: 4,
|
||||
failed: 5,
|
||||
waiting: 6,
|
||||
running: 7,
|
||||
review: 8,
|
||||
};
|
||||
|
||||
// 每一幀的毫秒數(Codex V2 animation-rows 合約)
|
||||
const TIMINGS = {
|
||||
idle: [280, 110, 110, 140, 140, 320],
|
||||
"running-right": [120, 120, 120, 120, 120, 120, 120, 220],
|
||||
"running-left": [120, 120, 120, 120, 120, 120, 120, 220],
|
||||
waving: [140, 140, 140, 280],
|
||||
jumping: [140, 140, 140, 140, 280],
|
||||
failed: [140, 140, 140, 140, 140, 140, 140, 240],
|
||||
waiting: [150, 150, 150, 150, 150, 260],
|
||||
running: [120, 120, 120, 120, 120, 220],
|
||||
review: [150, 150, 150, 150, 150, 280],
|
||||
};
|
||||
|
||||
const LOOK_DEADZONE = 56; // 游標離寵物中心太近 → 回到 idle
|
||||
const LOOK_IDLE_AFTER = 6000; // 游標靜止多久後不再盯著看
|
||||
const REVIEW_DURATION = 6000; // Stop 後 review 多久回 idle
|
||||
const SESSION_STALE = 30 * 60 * 1000;
|
||||
const PRIORITY = { waiting: 3, running: 2, review: 1, idle: 0 };
|
||||
|
||||
const canvas = document.getElementById("sprite");
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
const bubble = document.getElementById("bubble");
|
||||
const bubbleText = document.getElementById("bubble-text");
|
||||
|
||||
let sheet = null;
|
||||
let petInfo = null;
|
||||
let scale = 1;
|
||||
let followCursor = true;
|
||||
|
||||
const sessions = new Map(); // sessionId → { state, updatedAt, cwd, reviewUntil }
|
||||
let overlay = null; // 一次性動作:{ anim, loopsLeft, onDone }
|
||||
let drag = null; // 拖曳中:{ dir: "left" | "right" }
|
||||
const look = { idx: null, lastMoveAt: 0 };
|
||||
let player = { anim: "idle", frame: 0, frameStart: performance.now() };
|
||||
let prevBase = "idle";
|
||||
|
||||
let bubbleTimer = null;
|
||||
let bubbleSticky = false;
|
||||
let ignoringMouse = true;
|
||||
let pressed = null;
|
||||
let lastReported = "";
|
||||
|
||||
function log(msg) {
|
||||
try { window.pet.send("pet:log", `[renderer] ${msg}`); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---------- 載入 ----------
|
||||
|
||||
function loadPet(pet) {
|
||||
petInfo = pet;
|
||||
sheet = null;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
sheet = img;
|
||||
makeTrayIcon();
|
||||
log(`loaded ${pet.id} (v${pet.version}) ${img.naturalWidth}x${img.naturalHeight}`);
|
||||
};
|
||||
img.onerror = () => log(`spritesheet load failed for ${pet.id}`);
|
||||
img.src = pet.dataUrl;
|
||||
}
|
||||
|
||||
function applyScale(s) {
|
||||
scale = s;
|
||||
canvas.width = Math.round(CELL_W * s);
|
||||
canvas.height = Math.round(CELL_H * s);
|
||||
canvas.style.width = `${canvas.width}px`;
|
||||
canvas.style.height = `${canvas.height}px`;
|
||||
}
|
||||
|
||||
function cellHasPixels(row, col) {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 8; c.height = 8;
|
||||
const g = c.getContext("2d");
|
||||
g.drawImage(sheet, col * CELL_W + CELL_W / 2 - 24, row * CELL_H + CELL_H / 2 - 24, 48, 48, 0, 0, 8, 8);
|
||||
const d = g.getImageData(0, 0, 8, 8).data;
|
||||
for (let i = 3; i < d.length; i += 4) if (d[i] > 30) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function makeTrayIcon() {
|
||||
if (!sheet) return;
|
||||
try {
|
||||
const col = petInfo.version === 2 && cellHasPixels(0, 6) ? 6 : 0;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 32; c.height = 32;
|
||||
const g = c.getContext("2d");
|
||||
g.imageSmoothingEnabled = true;
|
||||
g.imageSmoothingQuality = "high";
|
||||
// 取上半身(頭部)做成方形 icon
|
||||
const sw = 128, sh = 128;
|
||||
g.drawImage(sheet, col * CELL_W + (CELL_W - sw) / 2, 4, sw, sh, 0, 0, 32, 32);
|
||||
window.pet.send("pet:tray-icon", c.toDataURL("image/png"));
|
||||
} catch (err) {
|
||||
log(`tray icon failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 狀態 ----------
|
||||
|
||||
function session(id, cwd) {
|
||||
const key = id || "default";
|
||||
let s = sessions.get(key);
|
||||
if (!s) {
|
||||
s = { state: "idle", updatedAt: Date.now(), cwd: cwd || null, reviewUntil: 0 };
|
||||
sessions.set(key, s);
|
||||
}
|
||||
if (cwd) s.cwd = cwd;
|
||||
s.updatedAt = Date.now();
|
||||
return s;
|
||||
}
|
||||
|
||||
function setState(s, state) {
|
||||
s.state = state;
|
||||
s.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
function baseState() {
|
||||
const now = Date.now();
|
||||
let best = "idle";
|
||||
for (const [id, s] of sessions) {
|
||||
if (now - s.updatedAt > SESSION_STALE) { sessions.delete(id); continue; }
|
||||
if (s.state === "review" && now > s.reviewUntil) s.state = "idle";
|
||||
if (PRIORITY[s.state] > PRIORITY[best]) best = s.state;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function play(anim, loops = 1, onDone = null) {
|
||||
overlay = { anim, loopsLeft: loops, onDone };
|
||||
player = { anim, frame: 0, frameStart: performance.now() };
|
||||
}
|
||||
|
||||
function resolveAnim() {
|
||||
if (drag) return drag.dir === "left" ? "running-left" : "running-right";
|
||||
if (overlay) return overlay.anim;
|
||||
const base = baseState();
|
||||
if (base === "idle" && followCursor && petInfo?.version === 2 && look.idx !== null) return "look";
|
||||
return base;
|
||||
}
|
||||
|
||||
function projectName(cwd) {
|
||||
if (!cwd) return "";
|
||||
return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
|
||||
}
|
||||
|
||||
// ---------- 氣泡 ----------
|
||||
|
||||
function say(text, ms, sticky = false) {
|
||||
clearTimeout(bubbleTimer);
|
||||
bubbleText.textContent = text;
|
||||
bubble.title = text;
|
||||
bubble.classList.remove("hidden");
|
||||
bubbleSticky = sticky;
|
||||
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
|
||||
}
|
||||
|
||||
function hideBubble() {
|
||||
clearTimeout(bubbleTimer);
|
||||
bubble.classList.add("hidden");
|
||||
bubbleSticky = false;
|
||||
}
|
||||
|
||||
// ---------- 事件 ----------
|
||||
|
||||
function handleEvent(p) {
|
||||
if (!p || typeof p !== "object") return;
|
||||
if (p.event === "__test") return handleTest(p);
|
||||
|
||||
const s = session(p.sessionId, p.cwd);
|
||||
const proj = projectName(s.cwd);
|
||||
const suffix = proj ? `(${proj})` : "";
|
||||
|
||||
switch (p.event) {
|
||||
case "SessionStart":
|
||||
setState(s, "idle");
|
||||
if (!p.source || p.source === "startup") {
|
||||
play("waving", 1);
|
||||
say(`👋 嗨!${suffix}`, 3000);
|
||||
}
|
||||
break;
|
||||
case "UserPromptSubmit":
|
||||
setState(s, "running");
|
||||
hideBubble();
|
||||
break;
|
||||
case "PreToolUse":
|
||||
if (p.toolName === "AskUserQuestion") {
|
||||
setState(s, "waiting");
|
||||
say(`❓ 有問題想問你${suffix}`, 0, true);
|
||||
} else if (p.toolName === "ExitPlanMode") {
|
||||
setState(s, "waiting");
|
||||
say(`📋 計畫等你確認${suffix}`, 0, true);
|
||||
} else {
|
||||
setState(s, "running");
|
||||
}
|
||||
break;
|
||||
case "PostToolUse":
|
||||
setState(s, "running");
|
||||
break;
|
||||
case "PostToolUseFailure":
|
||||
setState(s, "running");
|
||||
play("failed", 1);
|
||||
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000);
|
||||
break;
|
||||
case "PermissionRequest":
|
||||
setState(s, "waiting");
|
||||
say(`🔐 需要你授權${p.toolName ? `:${p.toolName}` : ""}`, 0, true);
|
||||
break;
|
||||
case "Notification": {
|
||||
const t = p.notificationType;
|
||||
if (t === "permission_prompt") {
|
||||
setState(s, "waiting");
|
||||
say(`🔐 需要你授權${suffix}`, 0, true);
|
||||
} else if (t === "idle_prompt") {
|
||||
setState(s, "waiting");
|
||||
say(`💤 在等你回覆${suffix}`, 0, true);
|
||||
} else if (t === "elicitation_dialog") {
|
||||
setState(s, "waiting");
|
||||
say(`❓ 有問題想問你${suffix}`, 0, true);
|
||||
} else if (p.message) {
|
||||
say(`💬 ${p.message}`, 4000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Stop":
|
||||
if (p.stopHookActive) break;
|
||||
setState(s, "review");
|
||||
s.reviewUntil = Date.now() + REVIEW_DURATION;
|
||||
play("jumping", 1, () => say(`✅ 完成!${suffix}`, 8000));
|
||||
break;
|
||||
case "StopFailure":
|
||||
setState(s, "idle");
|
||||
play("failed", 2);
|
||||
say(`❌ ${p.error || "出錯了"}`, 8000);
|
||||
break;
|
||||
case "SubagentStart":
|
||||
case "SubagentStop":
|
||||
setState(s, "running");
|
||||
break;
|
||||
case "PreCompact":
|
||||
setState(s, "running");
|
||||
say("🗜️ 整理記憶中…", 3000);
|
||||
break;
|
||||
case "SessionEnd":
|
||||
sessions.delete(p.sessionId || "default");
|
||||
if (sessions.size === 0) {
|
||||
hideBubble();
|
||||
say("👋 掰掰", 2000);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
report();
|
||||
}
|
||||
|
||||
function handleTest(p) {
|
||||
const anim = p.anim;
|
||||
if (anim === "clear") {
|
||||
sessions.delete("test");
|
||||
overlay = null;
|
||||
hideBubble();
|
||||
} else if (anim === "waving" || anim === "jumping" || anim === "failed") {
|
||||
play(anim, anim === "failed" ? 2 : 1);
|
||||
} else if (anim in PRIORITY) {
|
||||
const s = session("test");
|
||||
setState(s, anim);
|
||||
if (anim === "review") s.reviewUntil = Date.now() + 60000;
|
||||
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true);
|
||||
else if (anim === "idle") hideBubble();
|
||||
}
|
||||
report();
|
||||
}
|
||||
|
||||
function report() {
|
||||
const base = baseState();
|
||||
const snapshot = JSON.stringify({ anim: player.anim, base, sessions: sessions.size, pet: petInfo?.id || null });
|
||||
if (snapshot !== lastReported) {
|
||||
lastReported = snapshot;
|
||||
window.pet.send("pet:state", JSON.parse(snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 動畫主迴圈 ----------
|
||||
|
||||
function tick(now) {
|
||||
const anim = resolveAnim();
|
||||
if (anim !== player.anim) {
|
||||
player = { anim, frame: 0, frameStart: now };
|
||||
report();
|
||||
}
|
||||
|
||||
if (anim !== "look") {
|
||||
const t = TIMINGS[anim];
|
||||
if (now - player.frameStart > 5000) player.frameStart = now; // 從休眠醒來,避免狂追幀
|
||||
let guard = 0;
|
||||
while (now - player.frameStart >= t[player.frame] && guard++ < 64) {
|
||||
player.frameStart += t[player.frame];
|
||||
player.frame += 1;
|
||||
if (player.frame >= t.length) {
|
||||
player.frame = 0;
|
||||
if (overlay && overlay.anim === anim) {
|
||||
overlay.loopsLeft -= 1;
|
||||
if (overlay.loopsLeft <= 0) {
|
||||
const done = overlay.onDone;
|
||||
overlay = null;
|
||||
if (done) done();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const base = baseState();
|
||||
if (prevBase === "waiting" && base !== "waiting" && bubbleSticky) hideBubble();
|
||||
prevBase = base;
|
||||
|
||||
draw(anim);
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function draw(anim) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!sheet) return;
|
||||
let row;
|
||||
let col;
|
||||
if (anim === "look") {
|
||||
row = 9 + (look.idx >> 3);
|
||||
col = look.idx & 7;
|
||||
} else {
|
||||
row = ROW[anim];
|
||||
col = player.frame;
|
||||
}
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// ---------- 看向游標 ----------
|
||||
|
||||
window.pet.on("pet:cursor", ({ dx, dy }) => {
|
||||
const now = Date.now();
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < LOOK_DEADZONE * Math.max(scale, 0.75)) {
|
||||
look.idx = null;
|
||||
look.lastMoveAt = now;
|
||||
return;
|
||||
}
|
||||
// 000 = 正上方,順時針
|
||||
const angle = (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360;
|
||||
const candidate = Math.round(angle / 22.5) % 16;
|
||||
if (look.idx === null) {
|
||||
look.idx = candidate;
|
||||
} else if (candidate !== look.idx) {
|
||||
// 加一點遲滯,避免在邊界抖動
|
||||
let diff = Math.abs(angle - look.idx * 22.5);
|
||||
diff = Math.min(diff, 360 - diff);
|
||||
if (diff > 11.25 + 3) look.idx = candidate;
|
||||
}
|
||||
look.lastMoveAt = now;
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (look.idx !== null && Date.now() - look.lastMoveAt > LOOK_IDLE_AFTER) look.idx = null;
|
||||
}, 500);
|
||||
|
||||
// ---------- 滑鼠:點穿 / 拖曳 / 點擊 / 右鍵 ----------
|
||||
|
||||
function overOpaque(x, y) {
|
||||
if (!bubble.classList.contains("hidden")) {
|
||||
const r = bubble.getBoundingClientRect();
|
||||
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true;
|
||||
}
|
||||
const r = canvas.getBoundingClientRect();
|
||||
if (x < r.left || x >= r.right || y < r.top || y >= r.bottom) return false;
|
||||
const px = Math.floor((x - r.left) * (canvas.width / r.width));
|
||||
const py = Math.floor((y - r.top) * (canvas.height / r.height));
|
||||
try {
|
||||
return ctx.getImageData(px, py, 1, 1).data[3] > 20;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function setIgnore(ignore) {
|
||||
if (ignore === ignoringMouse) return;
|
||||
ignoringMouse = ignore;
|
||||
window.pet.send("pet:ignore-mouse", ignore);
|
||||
canvas.classList.toggle("grab", !ignore);
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (pressed) {
|
||||
if (!pressed.moved && Math.hypot(e.screenX - pressed.sx, e.screenY - pressed.sy) > 4) {
|
||||
pressed.moved = true;
|
||||
drag = { dir: "right" };
|
||||
canvas.classList.add("grabbing");
|
||||
window.pet.send("pet:drag-start", { offsetX: pressed.x, offsetY: pressed.y });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setIgnore(!overOpaque(e.clientX, e.clientY));
|
||||
});
|
||||
|
||||
document.addEventListener("mouseout", (e) => {
|
||||
if (!e.relatedTarget && !pressed) setIgnore(true);
|
||||
});
|
||||
|
||||
window.addEventListener("mousedown", (e) => {
|
||||
if (e.button === 0) {
|
||||
pressed = { x: e.clientX, y: e.clientY, sx: e.screenX, sy: e.screenY, moved: false };
|
||||
} else if (e.button === 2) {
|
||||
window.pet.send("pet:context-menu");
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("mouseup", (e) => {
|
||||
if (e.button !== 0 || !pressed) return;
|
||||
const wasDrag = pressed.moved;
|
||||
pressed = null;
|
||||
if (wasDrag) {
|
||||
drag = null;
|
||||
canvas.classList.remove("grabbing");
|
||||
window.pet.send("pet:drag-end");
|
||||
} else {
|
||||
onClick();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
|
||||
window.pet.on("pet:drag-move", ({ dx }) => {
|
||||
if (drag && dx) drag.dir = dx < 0 ? "left" : "right";
|
||||
});
|
||||
|
||||
function onClick() {
|
||||
if (!bubble.classList.contains("hidden") && !bubbleSticky) {
|
||||
hideBubble();
|
||||
return;
|
||||
}
|
||||
if (!overlay) play("waving", 1);
|
||||
}
|
||||
|
||||
// ---------- 初始化 ----------
|
||||
|
||||
window.pet.on("pet:init", ({ pet, scale: s, followCursor: f }) => {
|
||||
followCursor = f !== false;
|
||||
applyScale(s || 1);
|
||||
if (pet) loadPet(pet);
|
||||
else log("no pet available");
|
||||
});
|
||||
window.pet.on("pet:load", ({ pet }) => { if (pet) loadPet(pet); });
|
||||
window.pet.on("pet:scale", ({ scale: s }) => applyScale(s));
|
||||
window.pet.on("pet:settings", (st) => {
|
||||
if (st && "followCursor" in st) followCursor = !!st.followCursor;
|
||||
});
|
||||
window.pet.on("pet:event", handleEvent);
|
||||
|
||||
window.pet.send("pet:ready");
|
||||
requestAnimationFrame(tick);
|
||||
@@ -0,0 +1,67 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
font-family: "Segoe UI", "Microsoft JhengHei", "PingFang TC", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
#bubble {
|
||||
position: relative;
|
||||
max-width: calc(100% - 12px);
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 11px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
color: #222;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#bubble::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -6px;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(255, 255, 255, 0.97);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
#bubble.hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#sprite {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#sprite.grab {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#sprite.grabbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
Reference in New Issue
Block a user