Merge branch 'dev'
This commit is contained in:
commit
e3486f7161
|
|
@ -1,186 +1,38 @@
|
|||
/**
|
||||
* Notification REST API — mounted at /api/notifications
|
||||
*
|
||||
* All endpoints require Bearer auth (same pattern as spaces.ts).
|
||||
* Proxies all requests to the encryptid service which has direct DB access.
|
||||
* The rspace container cannot reach the encryptid database directly.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { verifyToken, extractToken } from "./auth";
|
||||
import {
|
||||
getUserNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
dismissNotification,
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
savePushSubscription,
|
||||
deletePushSubscriptionByEndpoint,
|
||||
} from "../src/encryptid/db";
|
||||
|
||||
const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
||||
|
||||
export const notificationRouter = new Hono();
|
||||
|
||||
// ── Auth helper ──
|
||||
async function requireAuth(req: Request) {
|
||||
const token = extractToken(req.headers);
|
||||
if (!token) return null;
|
||||
// Proxy all notification requests to encryptid
|
||||
notificationRouter.all("/*", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const targetUrl = `${ENCRYPTID_URL}${url.pathname}${url.search}`;
|
||||
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
headers.delete("host");
|
||||
|
||||
try {
|
||||
return await verifyToken(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET / — Paginated notification list ──
|
||||
notificationRouter.get("/", async (c) => {
|
||||
try {
|
||||
const claims = await requireAuth(c.req.raw);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
const url = new URL(c.req.url);
|
||||
const unreadOnly = url.searchParams.get("unread") === "true";
|
||||
const category = url.searchParams.get("category") || undefined;
|
||||
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 100);
|
||||
const offset = Number(url.searchParams.get("offset")) || 0;
|
||||
|
||||
const notifications = await getUserNotifications(claims.sub, {
|
||||
unreadOnly,
|
||||
category,
|
||||
limit,
|
||||
offset,
|
||||
const res = await fetch(targetUrl, {
|
||||
method: c.req.method,
|
||||
headers,
|
||||
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
|
||||
// @ts-ignore duplex needed for streaming request bodies
|
||||
duplex: "half",
|
||||
});
|
||||
|
||||
return c.json({ notifications });
|
||||
} catch (err) {
|
||||
console.error("[notifications] GET / failed:", err instanceof Error ? err.message : err);
|
||||
return c.json({ notifications: [] });
|
||||
return new Response(res.body, { status: res.status, headers: res.headers });
|
||||
} catch (e: any) {
|
||||
console.error("[notifications proxy] Failed to reach encryptid:", e?.message);
|
||||
// Return safe fallbacks instead of hanging
|
||||
if (url.pathname.endsWith("/count")) return c.json({ unreadCount: 0 });
|
||||
if (url.pathname === "/api/notifications") return c.json({ notifications: [] });
|
||||
return c.json({ error: "Notification service unavailable" }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /count — Lightweight unread count (polling fallback) ──
|
||||
notificationRouter.get("/count", async (c) => {
|
||||
try {
|
||||
const claims = await requireAuth(c.req.raw);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
const count = await getUnreadCount(claims.sub);
|
||||
return c.json({ unreadCount: count });
|
||||
} catch (err) {
|
||||
console.error("[notifications] GET /count failed:", err instanceof Error ? err.message : err);
|
||||
return c.json({ unreadCount: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
// ── PATCH /:id/read — Mark one notification as read ──
|
||||
notificationRouter.patch("/:id/read", async (c) => {
|
||||
const claims = await requireAuth(c.req.raw);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
const id = c.req.param("id");
|
||||
const ok = await markNotificationRead(id, claims.sub);
|
||||
if (!ok) return c.json({ error: "Notification not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── POST /read-all — Mark all read (optional scope) ──
|
||||
notificationRouter.post("/read-all", async (c) => {
|
||||
const claims = await requireAuth(c.req.raw);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
let spaceSlug: string | undefined;
|
||||
let category: string | undefined;
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
spaceSlug = body.spaceSlug;
|
||||
category = body.category;
|
||||
} catch {
|
||||
// No body is fine — mark everything read
|
||||
}
|
||||
|
||||
const count = await markAllNotificationsRead(claims.sub, { spaceSlug, category });
|
||||
return c.json({ ok: true, markedRead: count });
|
||||
});
|
||||
|
||||
// ── DELETE /:id — Dismiss/archive a notification ──
|
||||
notificationRouter.delete("/:id", async (c) => {
|
||||
const claims = await requireAuth(c.req.raw);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
const id = c.req.param("id");
|
||||
const ok = await dismissNotification(id, claims.sub);
|
||||
if (!ok) return c.json({ error: "Notification not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── GET /preferences — Get notification preferences ──
|
||||
notificationRouter.get("/preferences", async (c) => {
|
||||
const claims = await requireAuth(c.req.raw);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
const prefs = await getNotificationPreferences(claims.sub);
|
||||
return c.json({ preferences: prefs || {
|
||||
emailEnabled: true,
|
||||
pushEnabled: true,
|
||||
quietHoursStart: null,
|
||||
quietHoursEnd: null,
|
||||
mutedSpaces: [],
|
||||
mutedCategories: [],
|
||||
digestFrequency: 'none',
|
||||
}});
|
||||
});
|
||||
|
||||
// ── PATCH /preferences — Update notification preferences ──
|
||||
notificationRouter.patch("/preferences", async (c) => {
|
||||
const claims = await requireAuth(c.req.raw);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
const prefs = await upsertNotificationPreferences(claims.sub, body);
|
||||
return c.json({ preferences: prefs });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// WEB PUSH ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
// ── GET /push/vapid-public-key — Return public VAPID key (no auth) ──
|
||||
notificationRouter.get("/push/vapid-public-key", (c) => {
|
||||
const key = process.env.VAPID_PUBLIC_KEY;
|
||||
if (!key) return c.json({ error: "Push not configured" }, 503);
|
||||
return c.json({ publicKey: key });
|
||||
});
|
||||
|
||||
// ── POST /push/subscribe — Save push subscription (auth required) ──
|
||||
notificationRouter.post("/push/subscribe", async (c) => {
|
||||
const claims = await requireAuth(c.req.raw);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { endpoint, keys } = body;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return c.json({ error: "Invalid subscription object" }, 400);
|
||||
}
|
||||
|
||||
await savePushSubscription({
|
||||
id: crypto.randomUUID(),
|
||||
userDid: claims.sub,
|
||||
endpoint,
|
||||
keyP256dh: keys.p256dh,
|
||||
keyAuth: keys.auth,
|
||||
userAgent: c.req.header("user-agent"),
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── POST /push/unsubscribe — Remove push subscription (auth required) ──
|
||||
notificationRouter.post("/push/unsubscribe", async (c) => {
|
||||
const claims = await requireAuth(c.req.raw);
|
||||
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
if (!body.endpoint) return c.json({ error: "endpoint required" }, 400);
|
||||
|
||||
await deletePushSubscriptionByEndpoint(body.endpoint);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -797,11 +797,13 @@ export function renderShell(opts: ShellOptions): string {
|
|||
if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); }
|
||||
// Broadcast to other same-browser tabs
|
||||
if (_tabChannel) {
|
||||
_tabChannel.postMessage({
|
||||
type: 'tabs-sync',
|
||||
layers: layers,
|
||||
closed: [..._closedModuleIds],
|
||||
});
|
||||
try {
|
||||
_tabChannel.postMessage({
|
||||
type: 'tabs-sync',
|
||||
layers: layers,
|
||||
closed: [..._closedModuleIds],
|
||||
});
|
||||
} catch(e) { /* channel may be closed during navigation */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,15 @@ import {
|
|||
getTrustScoresByAuthority,
|
||||
listAllUsersWithTrust,
|
||||
sql,
|
||||
getUserNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
dismissNotification,
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
savePushSubscription,
|
||||
deletePushSubscriptionByEndpoint,
|
||||
getUserUPAddress,
|
||||
setUserUPAddress,
|
||||
getUserByUPAddress,
|
||||
|
|
@ -1245,6 +1254,146 @@ app.put('/api/user/tabs/:spaceSlug', async (c) => {
|
|||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// NOTIFICATION API (read/manage — used by rspace proxy)
|
||||
// ============================================================================
|
||||
|
||||
/** GET /api/notifications — paginated notification list */
|
||||
app.get('/api/notifications', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
|
||||
try {
|
||||
const url = new URL(c.req.url);
|
||||
const notifications = await getUserNotifications(claims.sub, {
|
||||
unreadOnly: url.searchParams.get('unread') === 'true',
|
||||
category: url.searchParams.get('category') || undefined,
|
||||
limit: Math.min(Number(url.searchParams.get('limit')) || 50, 100),
|
||||
offset: Number(url.searchParams.get('offset')) || 0,
|
||||
});
|
||||
return c.json({ notifications });
|
||||
} catch (err) {
|
||||
console.error('[notifications] GET / failed:', err instanceof Error ? err.message : err);
|
||||
return c.json({ notifications: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/notifications/count — lightweight unread count */
|
||||
app.get('/api/notifications/count', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
|
||||
try {
|
||||
const count = await getUnreadCount(claims.sub);
|
||||
return c.json({ unreadCount: count });
|
||||
} catch (err) {
|
||||
console.error('[notifications] GET /count failed:', err instanceof Error ? err.message : err);
|
||||
return c.json({ unreadCount: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
/** PATCH /api/notifications/:id/read — mark one notification as read */
|
||||
app.patch('/api/notifications/:id/read', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
|
||||
const ok = await markNotificationRead(c.req.param('id'), claims.sub);
|
||||
if (!ok) return c.json({ error: 'Notification not found' }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** POST /api/notifications/read-all — mark all read */
|
||||
app.post('/api/notifications/read-all', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
|
||||
let spaceSlug: string | undefined;
|
||||
let category: string | undefined;
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
spaceSlug = body.spaceSlug;
|
||||
category = body.category;
|
||||
} catch { /* no body is fine */ }
|
||||
|
||||
const count = await markAllNotificationsRead(claims.sub, { spaceSlug, category });
|
||||
return c.json({ ok: true, markedRead: count });
|
||||
});
|
||||
|
||||
/** DELETE /api/notifications/:id — dismiss/archive */
|
||||
app.delete('/api/notifications/:id', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
|
||||
const ok = await dismissNotification(c.req.param('id'), claims.sub);
|
||||
if (!ok) return c.json({ error: 'Notification not found' }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** GET /api/notifications/preferences */
|
||||
app.get('/api/notifications/preferences', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
|
||||
const prefs = await getNotificationPreferences(claims.sub);
|
||||
return c.json({ preferences: prefs || {
|
||||
emailEnabled: true, pushEnabled: true,
|
||||
quietHoursStart: null, quietHoursEnd: null,
|
||||
mutedSpaces: [], mutedCategories: [],
|
||||
digestFrequency: 'none',
|
||||
}});
|
||||
});
|
||||
|
||||
/** PATCH /api/notifications/preferences */
|
||||
app.patch('/api/notifications/preferences', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
const prefs = await upsertNotificationPreferences(claims.sub, body);
|
||||
return c.json({ preferences: prefs });
|
||||
});
|
||||
|
||||
/** GET /api/notifications/push/vapid-public-key */
|
||||
app.get('/api/notifications/push/vapid-public-key', (c) => {
|
||||
const key = process.env.VAPID_PUBLIC_KEY;
|
||||
if (!key) return c.json({ error: 'Push not configured' }, 503);
|
||||
return c.json({ publicKey: key });
|
||||
});
|
||||
|
||||
/** POST /api/notifications/push/subscribe */
|
||||
app.post('/api/notifications/push/subscribe', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { endpoint, keys } = body;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return c.json({ error: 'Invalid subscription object' }, 400);
|
||||
}
|
||||
|
||||
await savePushSubscription({
|
||||
id: crypto.randomUUID(),
|
||||
userDid: claims.sub,
|
||||
endpoint,
|
||||
keyP256dh: keys.p256dh,
|
||||
keyAuth: keys.auth,
|
||||
userAgent: c.req.header('user-agent'),
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** POST /api/notifications/push/unsubscribe */
|
||||
app.post('/api/notifications/push/unsubscribe', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
if (!body.endpoint) return c.json({ error: 'endpoint required' }, 400);
|
||||
|
||||
await deletePushSubscriptionByEndpoint(body.endpoint);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ACCOUNT SETTINGS ENDPOINTS
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -982,29 +982,6 @@
|
|||
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
#people-conn-status {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid var(--rs-border, rgba(255,255,255,0.1));
|
||||
}
|
||||
|
||||
#people-conn-status.visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
#people-conn-status .conn-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#people-conn-status .conn-dot.pulse {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#people-dots {
|
||||
display: flex;
|
||||
|
|
@ -2000,7 +1977,6 @@
|
|||
<div id="people-online-badge">
|
||||
<span class="dots" id="people-dots"></span>
|
||||
<span id="people-badge-text">1 online</span>
|
||||
<span id="people-conn-status"></span>
|
||||
</div>
|
||||
<div id="people-panel">
|
||||
<div id="people-panel-header">
|
||||
|
|
@ -3392,7 +3368,6 @@
|
|||
const peopleBadgeText = document.getElementById("people-badge-text");
|
||||
const peopleCount = document.getElementById("people-count");
|
||||
const peopleList = document.getElementById("people-list");
|
||||
const peopleConnStatus = document.getElementById("people-conn-status");
|
||||
let connState = "connecting"; // "connected" | "offline" | "reconnecting" | "connecting"
|
||||
const pingToast = document.getElementById("ping-toast");
|
||||
const pingToastText = document.getElementById("ping-toast-text");
|
||||
|
|
@ -3439,15 +3414,26 @@
|
|||
function renderPeopleBadge() {
|
||||
const totalCount = onlinePeers.size + 1; // +1 for self
|
||||
peopleDots.innerHTML = "";
|
||||
if (!isMultiplayer) {
|
||||
// Offline / solo mode
|
||||
|
||||
if (connState === "offline") {
|
||||
// Actually disconnected from internet
|
||||
peopleBadgeText.textContent = "Offline";
|
||||
peopleBadge.title = "You\u2019re offline \u2014 your changes are saved locally and will resync when you reconnect to the internet.";
|
||||
const selfDot = document.createElement("span");
|
||||
selfDot.className = "dot";
|
||||
selfDot.style.background = "#64748b";
|
||||
selfDot.style.background = "#f59e0b";
|
||||
peopleDots.appendChild(selfDot);
|
||||
} else if (connState === "reconnecting" || connState === "connecting") {
|
||||
peopleBadgeText.textContent = "Reconnecting\u2026";
|
||||
peopleBadge.title = "Reconnecting to the server \u2014 your changes are saved locally and will resync automatically.";
|
||||
const selfDot = document.createElement("span");
|
||||
selfDot.className = "dot";
|
||||
selfDot.style.background = "#3b82f6";
|
||||
peopleDots.appendChild(selfDot);
|
||||
} else {
|
||||
// Connected
|
||||
peopleBadgeText.textContent = totalCount === 1 ? "1 online" : `${totalCount} online`;
|
||||
peopleBadge.title = "";
|
||||
// Self dot
|
||||
const selfDot = document.createElement("span");
|
||||
selfDot.className = "dot";
|
||||
|
|
@ -3464,30 +3450,28 @@
|
|||
dotCount++;
|
||||
}
|
||||
}
|
||||
peopleCount.textContent = isMultiplayer ? totalCount : "—";
|
||||
// Connection status indicator
|
||||
if (connState === "connected" || !isMultiplayer) {
|
||||
peopleConnStatus.classList.remove("visible");
|
||||
peopleConnStatus.innerHTML = "";
|
||||
} else {
|
||||
const color = connState === "offline" ? "#f59e0b" : "#3b82f6";
|
||||
const label = connState === "offline" ? "Offline" : "Reconnecting…";
|
||||
const pulse = connState !== "offline" ? " pulse" : "";
|
||||
peopleConnStatus.innerHTML = `<span class="conn-dot${pulse}" style="background:${color}"></span>${label}`;
|
||||
peopleConnStatus.classList.add("visible");
|
||||
}
|
||||
peopleCount.textContent = connState === "connected" ? totalCount : "\u2014";
|
||||
}
|
||||
|
||||
function renderPeoplePanel() {
|
||||
peopleList.innerHTML = "";
|
||||
// Self row with online/offline toggle
|
||||
// Show offline notice if disconnected
|
||||
if (connState === "offline" || connState === "reconnecting" || connState === "connecting") {
|
||||
const notice = document.createElement("div");
|
||||
notice.style.cssText = "padding:8px 16px;font-size:12px;color:var(--rs-text-muted);background:var(--rs-bg-surface-raised,rgba(255,255,255,0.04));border-bottom:1px solid var(--rs-border-subtle,rgba(255,255,255,0.06))";
|
||||
notice.textContent = connState === "offline"
|
||||
? "\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect."
|
||||
: "Reconnecting to server\u2026";
|
||||
peopleList.appendChild(notice);
|
||||
}
|
||||
// Self row with cursor visibility toggle
|
||||
const selfRow = document.createElement("div");
|
||||
selfRow.className = "people-row";
|
||||
selfRow.innerHTML = `<span class="dot" style="background:${isMultiplayer ? escapeHtml(localColor) : '#64748b'}"></span>
|
||||
selfRow.innerHTML = `<span class="dot" style="background:${escapeHtml(localColor)}"></span>
|
||||
<span class="name">${escapeHtml(storedUsername)} <span class="you-tag">(you)</span></span>
|
||||
<span class="mode-toggle">
|
||||
<button class="mode-solo ${isMultiplayer ? '' : 'active'}">Offline</button>
|
||||
<button class="mode-multi ${isMultiplayer ? 'active' : ''}">Online</button>
|
||||
<span class="mode-toggle" title="Toggle cursor sharing with other users">
|
||||
<button class="mode-solo ${isMultiplayer ? '' : 'active'}">Solo</button>
|
||||
<button class="mode-multi ${isMultiplayer ? 'active' : ''}">Share</button>
|
||||
</span>`;
|
||||
selfRow.querySelector(".mode-solo").addEventListener("click", () => setMultiplayerMode(false));
|
||||
selfRow.querySelector(".mode-multi").addEventListener("click", () => setMultiplayerMode(true));
|
||||
|
|
@ -6628,10 +6612,18 @@
|
|||
updateCanvasTransform();
|
||||
}, { passive: false });
|
||||
|
||||
// ── Shadow-DOM-aware text input check ──
|
||||
// e.target is retargeted to the shadow host, so we must walk composedPath()
|
||||
function isInTextInput(e) {
|
||||
return e.composedPath().some(el =>
|
||||
el instanceof HTMLElement && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Space key tracking for space+drag pan ──
|
||||
let spaceHeld = false;
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space" && !e.target.closest("input, textarea, [contenteditable]")) {
|
||||
if (e.code === "Space" && !isInTextInput(e)) {
|
||||
e.preventDefault();
|
||||
spaceHeld = true;
|
||||
canvas.style.cursor = "grab";
|
||||
|
|
@ -6663,7 +6655,7 @@
|
|||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.key === "Delete" || e.key === "Backspace") &&
|
||||
!e.target.closest("input, textarea, [contenteditable]") &&
|
||||
!isInTextInput(e) &&
|
||||
!bulkDeleteOverlay &&
|
||||
selectedShapeIds.size > 0) {
|
||||
if (selectedShapeIds.size > 5) {
|
||||
|
|
@ -6677,7 +6669,7 @@
|
|||
// ── Undo / Redo (Ctrl+Z / Ctrl+Shift+Z) ──
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.key === "z" || e.key === "Z") && (e.ctrlKey || e.metaKey) &&
|
||||
!e.target.closest("input, textarea, [contenteditable]")) {
|
||||
!isInTextInput(e)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
sync.redo();
|
||||
|
|
|
|||
Loading…
Reference in New Issue