@dengsixue/dsh-file-menu 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,646 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@dengsixue/dsh-file-menu",
3
+ factory(require) {
4
+ const exports = {};
5
+ exports.name = "@dengsixue/dsh-file-menu";
6
+ exports.inject = [];
7
+
8
+ const BRIDGE_PREFIX = "/api/dsh-file-menu";
9
+ // Menu wording for the system file browser: "在 Finder 中" on macOS, "在资源管理器中" on Windows.
10
+ const REVEAL_IN = /Windows/i.test(navigator.userAgent || navigator.platform || "") ? "在资源管理器中" : "在 Finder 中";
11
+
12
+ // Inject styles for the floating menu and toast
13
+ function ensureStyles() {
14
+ if (document.getElementById("dsh-file-context-menu-styles")) return;
15
+ const style = document.createElement("style");
16
+ style.id = "dsh-file-context-menu-styles";
17
+ style.textContent = `
18
+ .dsh-fcm-menu {
19
+ position: fixed;
20
+ z-index: 999999;
21
+ min-width: 220px;
22
+ background: rgba(249, 249, 251, 0.94);
23
+ backdrop-filter: blur(40px) saturate(180%);
24
+ -webkit-backdrop-filter: blur(40px) saturate(180%);
25
+ border: 0.5px solid rgba(0, 0, 0, 0.14);
26
+ border-radius: 8px;
27
+ padding: 6px 0;
28
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.16), 0 1px 4px rgba(0, 0, 0, 0.08);
29
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Segoe UI", sans-serif;
30
+ font-size: 14px;
31
+ line-height: 1.3;
32
+ color: #1d1d1f;
33
+ user-select: none;
34
+ animation: dshFcmFadeIn 100ms cubic-bezier(0.16, 1, 0.3, 1);
35
+ }
36
+ @media (prefers-color-scheme: dark) {
37
+ .dsh-fcm-menu {
38
+ background: rgba(44, 44, 46, 0.94);
39
+ border: 0.5px solid rgba(255, 255, 255, 0.16);
40
+ color: #f5f5f7;
41
+ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.5), 0 1px 4px rgba(0, 0, 0, 0.3);
42
+ }
43
+ }
44
+ @keyframes dshFcmFadeIn {
45
+ from { opacity: 0; transform: scale(0.96); }
46
+ to { opacity: 1; transform: scale(1); }
47
+ }
48
+ .dsh-fcm-item {
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: space-between;
52
+ min-height: 22px;
53
+ padding: 3px 14px;
54
+ margin: 0 5px;
55
+ border-radius: 5px;
56
+ cursor: default;
57
+ position: relative;
58
+ white-space: nowrap;
59
+ }
60
+ .dsh-fcm-item:hover, .dsh-fcm-item.active {
61
+ background: #0a84ff;
62
+ color: #ffffff;
63
+ }
64
+ .dsh-fcm-item-disabled {
65
+ opacity: 0.45;
66
+ cursor: not-allowed;
67
+ }
68
+ .dsh-fcm-item-disabled:hover {
69
+ background: transparent;
70
+ color: inherit;
71
+ }
72
+ .dsh-fcm-divider {
73
+ height: 1px;
74
+ background: rgba(0, 0, 0, 0.1);
75
+ margin: 5px 14px;
76
+ }
77
+ @media (prefers-color-scheme: dark) {
78
+ .dsh-fcm-divider {
79
+ background: rgba(255, 255, 255, 0.12);
80
+ }
81
+ }
82
+ .dsh-fcm-icon {
83
+ width: 16px;
84
+ height: 16px;
85
+ margin-right: 8px;
86
+ flex-shrink: 0;
87
+ object-fit: contain;
88
+ }
89
+ .dsh-fcm-item .dsh-fcm-label {
90
+ flex: 1;
91
+ }
92
+ .dsh-fcm-arrow {
93
+ display: flex;
94
+ align-items: center;
95
+ margin-left: 16px;
96
+ opacity: 0.7;
97
+ }
98
+ .dsh-fcm-item:hover .dsh-fcm-arrow {
99
+ opacity: 1;
100
+ }
101
+ .dsh-fcm-submenu {
102
+ position: absolute;
103
+ top: -5px;
104
+ left: calc(100% - 2px);
105
+ display: none;
106
+ }
107
+ .dsh-fcm-submenu.left {
108
+ left: auto;
109
+ right: calc(100% - 2px);
110
+ }
111
+ .dsh-fcm-submenu::before {
112
+ content: "";
113
+ position: absolute;
114
+ top: -10px;
115
+ bottom: -10px;
116
+ left: -14px;
117
+ width: 18px;
118
+ background: transparent;
119
+ }
120
+ .dsh-fcm-submenu.left::before {
121
+ left: auto;
122
+ right: -14px;
123
+ width: 18px;
124
+ }
125
+ .dsh-fcm-item:hover > .dsh-fcm-submenu,
126
+ .dsh-fcm-submenu.dsh-fcm-visible {
127
+ display: block;
128
+ }
129
+ .dsh-fcm-toast {
130
+ position: fixed;
131
+ bottom: 24px;
132
+ left: 50%;
133
+ transform: translateX(-50%);
134
+ z-index: 1000000;
135
+ background: rgba(20, 20, 22, 0.86);
136
+ color: #ffffff;
137
+ padding: 8px 16px;
138
+ border-radius: 20px;
139
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", sans-serif;
140
+ font-size: 13px;
141
+ font-weight: 500;
142
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
143
+ backdrop-filter: blur(16px);
144
+ -webkit-backdrop-filter: blur(16px);
145
+ pointer-events: none;
146
+ animation: dshFcmToastIn 160ms cubic-bezier(0.16, 1, 0.3, 1);
147
+ }
148
+ @keyframes dshFcmToastIn {
149
+ from { opacity: 0; transform: translate(-50%, 8px); }
150
+ to { opacity: 1; transform: translate(-50%, 0); }
151
+ }
152
+ `;
153
+ document.head.appendChild(style);
154
+ }
155
+
156
+ let currentMenu = null;
157
+ let toastTimeout = null;
158
+ let cachedApps = null;
159
+
160
+ function showToast(message) {
161
+ if (toastTimeout) clearTimeout(toastTimeout);
162
+ const old = document.querySelector(".dsh-fcm-toast");
163
+ if (old) old.remove();
164
+
165
+ const toast = document.createElement("div");
166
+ toast.className = "dsh-fcm-toast";
167
+ toast.textContent = message;
168
+ document.body.appendChild(toast);
169
+
170
+ toastTimeout = setTimeout(() => {
171
+ toast.style.transition = "opacity 200ms";
172
+ toast.style.opacity = "0";
173
+ setTimeout(() => toast.remove(), 200);
174
+ }, 2000);
175
+ }
176
+
177
+ async function copyToClipboard(text) {
178
+ if (!text && text !== "") return;
179
+ try {
180
+ if (navigator.clipboard && navigator.clipboard.writeText) {
181
+ await navigator.clipboard.writeText(text);
182
+ return;
183
+ }
184
+ } catch {}
185
+ const ta = document.createElement("textarea");
186
+ ta.value = text;
187
+ ta.style.position = "fixed";
188
+ ta.style.opacity = "0";
189
+ ta.style.left = "-9999px";
190
+ document.body.appendChild(ta);
191
+ ta.focus();
192
+ ta.select();
193
+ try {
194
+ document.execCommand("copy");
195
+ } finally {
196
+ ta.remove();
197
+ }
198
+ }
199
+
200
+ function closeMenu() {
201
+ if (currentMenu) {
202
+ currentMenu.remove();
203
+ currentMenu = null;
204
+ }
205
+ }
206
+
207
+ // Returns { apps, lastApp }. Not cached, so the "last used" app is always current.
208
+ async function loadApps(filePath) {
209
+ try {
210
+ const res = await fetch(`${BRIDGE_PREFIX}/apps?path=${encodeURIComponent(filePath)}`);
211
+ const data = await res.json();
212
+ if (data.ok && Array.isArray(data.apps)) {
213
+ return { apps: data.apps, lastApp: data.lastApp || "" };
214
+ }
215
+ } catch {}
216
+ return { apps: [{ name: "系统默认", app: "", icon: "" }], lastApp: "" };
217
+ }
218
+
219
+ function makeIcon(src) {
220
+ const img = document.createElement("img");
221
+ img.src = src;
222
+ img.alt = "";
223
+ img.width = 16;
224
+ img.height = 16;
225
+ img.className = "dsh-fcm-icon";
226
+ return img;
227
+ }
228
+
229
+ // Precise path validation: must be a local file path, NEVER an API route or URL
230
+ function isGenuineFilePath(str) {
231
+ if (!str || typeof str !== "string") return false;
232
+ const s = str.trim();
233
+ if (s.length < 2 || s.length > 600) return false;
234
+ if (s.includes("\n") || s.includes("\r")) return false;
235
+
236
+ // 1. Defensively reject web protocols, API endpoints, and hosts
237
+ if (/^(https?|ftp|file|ws|wss|blob|data):/i.test(s)) return false;
238
+ if (/^\/?api(\/|$)/i.test(s)) return false;
239
+ if (/^\/?v\d+(\/|$)/i.test(s)) return false;
240
+ if (/^127\.0\.0\.1|^localhost/i.test(s)) return false;
241
+
242
+ // 2. Reject CLI options and pure syntax symbols
243
+ if (/^--?[a-zA-Z]/.test(s)) return false;
244
+ if (/^[\/\\]{2,}/.test(s)) return false;
245
+ if (/^[\*\+\~\#\!\$\%\^\&\(\)\=\{\}\[\]\|:;\"\'<>,]+$/.test(s)) return false;
246
+
247
+ // 3. Absolute filesystem paths on macOS / Linux
248
+ if (/^\/(Users|System|Applications|Library|Volumes|tmp|private|opt|usr|var)\b/.test(s)) {
249
+ return true;
250
+ }
251
+
252
+ // 4. Home and workspace alias prefix
253
+ if (s.startsWith("~/") || s.startsWith("@/")) {
254
+ return true;
255
+ }
256
+
257
+ // 5. Windows drive letter paths
258
+ if (/^[a-zA-Z]:[\\/]/.test(s)) {
259
+ return true;
260
+ }
261
+
262
+ // 6. Paths with recognized file extensions
263
+ const clean = s.split(/[?#]/)[0];
264
+ const extMatch = clean.match(/\.([a-zA-Z0-9_-]{1,12})$/);
265
+ if (extMatch) {
266
+ const ext = extMatch[1].toLowerCase();
267
+ const webTlds = ["com", "cn", "org", "net", "io", "ai", "co", "me", "dev", "app", "site", "xyz", "info"];
268
+ if (webTlds.includes(ext) && !s.includes("/") && !s.includes("\\")) {
269
+ return false;
270
+ }
271
+ return true;
272
+ }
273
+
274
+ return false;
275
+ }
276
+
277
+ function cleanPath(str) {
278
+ if (!str) return "";
279
+ let s = str.trim();
280
+ if (s.startsWith("@/")) s = s.slice(1);
281
+ if (s.startsWith("@")) s = s.slice(1);
282
+ s = s.replace(/^<|>$/g, "");
283
+ s = s.replace(/#L\d+(-L\d+)?$/, "").replace(/:\d+(-\d+)?$/, "");
284
+ return s;
285
+ }
286
+
287
+ function getCleanLinkText(el) {
288
+ if (!el) return "";
289
+ try {
290
+ const clone = el.cloneNode(true);
291
+ clone.querySelectorAll("svg, img, [class*='linkIcon'], [class*='previewName']").forEach((n) => n.remove());
292
+ const txt = (clone.innerText || clone.textContent || "").trim();
293
+ if (txt) return txt.replace(/\s+/g, " ");
294
+ } catch {}
295
+ return (el.textContent || "").trim().replace(/\s+/g, " ");
296
+ }
297
+
298
+ // Only extract file paths from actual file links, buttons, or chips.
299
+ // Does NOT intercept general text to preserve 100% native right click!
300
+ function extractFileInfo(target) {
301
+ if (!target || !(target instanceof Element)) return null;
302
+
303
+ // 1. DSH file hyperlinks and file chip buttons
304
+ // File-edit card ("已编辑 README.md"): the path is in aria-label as "查看 <path> 的改动".
305
+ const editCard = target.closest('button[aria-label^="查看 "][aria-label$=" 的改动"]');
306
+ if (editCard) {
307
+ const m = (editCard.getAttribute("aria-label") || "").match(/^查看 (.+) 的改动$/);
308
+ if (m && isGenuineFilePath(m[1])) {
309
+ const filePath = cleanPath(m[1]);
310
+ return { filePath, linkText: filePath.split(/[\\/]/).pop() };
311
+ }
312
+ }
313
+
314
+ const fileEl = target.closest('button[class*="fileLink"], button[class*="fileMention"], [data-ref-chip="file"]');
315
+ if (fileEl) {
316
+ const title = fileEl.getAttribute("title") || fileEl.getAttribute("aria-label");
317
+ const href = fileEl.getAttribute("href");
318
+ const linkText = getCleanLinkText(fileEl);
319
+
320
+ if (title && isGenuineFilePath(title)) {
321
+ return {
322
+ filePath: cleanPath(title),
323
+ linkText: linkText || cleanPath(title).split(/[\\/]/).pop()
324
+ };
325
+ }
326
+
327
+ if (href && isGenuineFilePath(href)) {
328
+ return {
329
+ filePath: cleanPath(href),
330
+ linkText: linkText || cleanPath(href).split(/[\\/]/).pop()
331
+ };
332
+ }
333
+
334
+ if (linkText && isGenuineFilePath(linkText)) {
335
+ return {
336
+ filePath: cleanPath(linkText),
337
+ linkText: linkText
338
+ };
339
+ }
340
+ }
341
+
342
+ // 2. Anchor tags pointing to file:// or local paths
343
+ const anchor = target.closest("a");
344
+ if (anchor) {
345
+ const href = anchor.getAttribute("href") || "";
346
+ const text = (anchor.innerText || anchor.textContent || "").trim();
347
+ if (href.startsWith("file://")) {
348
+ const p = decodeURIComponent(href.slice(7));
349
+ if (isGenuineFilePath(p)) {
350
+ return {
351
+ filePath: cleanPath(p),
352
+ linkText: text || p.split(/[\\/]/).pop()
353
+ };
354
+ }
355
+ }
356
+ if (isGenuineFilePath(href)) {
357
+ return {
358
+ filePath: cleanPath(href),
359
+ linkText: text || href.split(/[\\/]/).pop()
360
+ };
361
+ }
362
+ }
363
+
364
+ return null;
365
+ }
366
+
367
+ async function handleFileAction(action, filePath, extra = {}) {
368
+ closeMenu();
369
+ try {
370
+ if (action === "copy-link-text") {
371
+ const text = extra.linkText || filePath.split(/[\\/]/).pop() || "";
372
+ await copyToClipboard(text);
373
+ showToast(text.length <= 25 ? `已复制链接文本: ${text}` : "已复制链接文本");
374
+ return;
375
+ }
376
+
377
+ if (action === "copy-path") {
378
+ await copyToClipboard(filePath);
379
+ showToast("已复制路径");
380
+ return;
381
+ }
382
+
383
+ if (action === "copy-content") {
384
+ const res = await fetch(`${BRIDGE_PREFIX}/read`, {
385
+ method: "POST",
386
+ headers: { "content-type": "application/json" },
387
+ body: JSON.stringify({ path: filePath })
388
+ });
389
+ const data = await res.json();
390
+ if (data.ok) {
391
+ await copyToClipboard(data.content);
392
+ showToast(`已复制文件内容 (${data.length} 字符)`);
393
+ } else {
394
+ showToast(data.error || "读取文件失败");
395
+ }
396
+ return;
397
+ }
398
+
399
+ if (action === "reveal") {
400
+ const res = await fetch(`${BRIDGE_PREFIX}/reveal`, {
401
+ method: "POST",
402
+ headers: { "content-type": "application/json" },
403
+ body: JSON.stringify({ path: filePath })
404
+ });
405
+ const data = await res.json();
406
+ if (data.ok) {
407
+ showToast(`已${REVEAL_IN}显示`);
408
+ } else {
409
+ showToast(data.error || `无法${REVEAL_IN}显示`);
410
+ }
411
+ return;
412
+ }
413
+
414
+ if (action === "open") {
415
+ const res = await fetch(`${BRIDGE_PREFIX}/open`, {
416
+ method: "POST",
417
+ headers: { "content-type": "application/json" },
418
+ body: JSON.stringify({ path: filePath, app: extra.app, name: extra.name })
419
+ });
420
+ const data = await res.json();
421
+ if (data.ok) {
422
+ showToast(`正在打开...`);
423
+ } else {
424
+ showToast(data.error || "打开失败");
425
+ }
426
+ return;
427
+ }
428
+
429
+ if (action === "save-as") {
430
+ const res = await fetch(`${BRIDGE_PREFIX}/save-as`, {
431
+ method: "POST",
432
+ headers: { "content-type": "application/json" },
433
+ body: JSON.stringify({ path: filePath })
434
+ });
435
+ const data = await res.json();
436
+ if (data.ok) {
437
+ showToast(`已另存为: ${data.baseName}`);
438
+ } else if (data.canceled) {
439
+ // User canceled dialog, do nothing
440
+ } else {
441
+ // Fallback to browser download
442
+ const a = document.createElement("a");
443
+ a.href = `${BRIDGE_PREFIX}/download?path=${encodeURIComponent(filePath)}`;
444
+ a.download = filePath.split(/[\\/]/).pop();
445
+ a.click();
446
+ }
447
+ return;
448
+ }
449
+ } catch (err) {
450
+ showToast("操作出错: " + err.message);
451
+ }
452
+ }
453
+
454
+ async function createMenu(x, y, fileInfo) {
455
+ ensureStyles();
456
+ closeMenu();
457
+
458
+ const { filePath, linkText } = fileInfo;
459
+ const menu = document.createElement("div");
460
+ menu.className = "dsh-fcm-menu";
461
+
462
+ const { apps, lastApp } = await loadApps(filePath);
463
+ const lastEntry = apps.find((a) => a.app === lastApp && a.app !== "");
464
+
465
+ // 1. Top item: re-open with the app used last time, like Codex does.
466
+ if (lastEntry) {
467
+ const lastItem = document.createElement("div");
468
+ lastItem.className = "dsh-fcm-item";
469
+ if (lastEntry.icon) lastItem.appendChild(makeIcon(lastEntry.icon));
470
+ const label = document.createElement("span");
471
+ label.className = "dsh-fcm-label";
472
+ label.textContent = `在 ${lastEntry.name} 中打开`;
473
+ lastItem.appendChild(label);
474
+ lastItem.addEventListener("click", () => handleFileAction("open", filePath, { app: lastEntry.app, name: lastEntry.name }));
475
+ menu.appendChild(lastItem);
476
+
477
+ const topDiv = document.createElement("div");
478
+ topDiv.className = "dsh-fcm-divider";
479
+ menu.appendChild(topDiv);
480
+ }
481
+
482
+ // 2. 打开方式 with submenu, last-used app listed first
483
+ const openWithItem = document.createElement("div");
484
+ openWithItem.className = "dsh-fcm-item";
485
+ openWithItem.innerHTML = `<span class="dsh-fcm-label">打开方式</span><span class="dsh-fcm-arrow"><svg width="8" height="12" viewBox="0 0 8 12" fill="none" aria-hidden="true"><path d="M1.5 1.5 6 6 1.5 10.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>`;
486
+
487
+ const submenu = document.createElement("div");
488
+ submenu.className = "dsh-fcm-menu dsh-fcm-submenu";
489
+ openWithItem.appendChild(submenu);
490
+
491
+ let subTimer = null;
492
+ const showSub = () => {
493
+ if (subTimer) { clearTimeout(subTimer); subTimer = null; }
494
+ submenu.classList.add("dsh-fcm-visible");
495
+ openWithItem.classList.add("active");
496
+ };
497
+ const hideSub = () => {
498
+ if (subTimer) clearTimeout(subTimer);
499
+ subTimer = setTimeout(() => {
500
+ submenu.classList.remove("dsh-fcm-visible");
501
+ openWithItem.classList.remove("active");
502
+ }, 280);
503
+ };
504
+
505
+ openWithItem.addEventListener("mouseenter", showSub);
506
+ openWithItem.addEventListener("mouseleave", hideSub);
507
+ submenu.addEventListener("mouseenter", showSub);
508
+ submenu.addEventListener("mouseleave", hideSub);
509
+
510
+ const ordered = lastEntry ? [lastEntry, ...apps.filter((a) => a !== lastEntry)] : apps;
511
+ ordered.forEach((appItem) => {
512
+ const subItem = document.createElement("div");
513
+ subItem.className = "dsh-fcm-item";
514
+ if (appItem.icon) subItem.appendChild(makeIcon(appItem.icon));
515
+ const label = document.createElement("span");
516
+ label.className = "dsh-fcm-label";
517
+ label.textContent = appItem.name;
518
+ subItem.appendChild(label);
519
+ subItem.addEventListener("click", (e) => {
520
+ e.stopPropagation();
521
+ handleFileAction("open", filePath, { app: appItem.app, name: appItem.name });
522
+ });
523
+ submenu.appendChild(subItem);
524
+ });
525
+
526
+ menu.appendChild(openWithItem);
527
+
528
+ // 2. 另存为...
529
+ const saveAsItem = document.createElement("div");
530
+ saveAsItem.className = "dsh-fcm-item";
531
+ saveAsItem.textContent = "另存为...";
532
+ saveAsItem.addEventListener("click", () => handleFileAction("save-as", filePath));
533
+ menu.appendChild(saveAsItem);
534
+
535
+ // Divider
536
+ const div1 = document.createElement("div");
537
+ div1.className = "dsh-fcm-divider";
538
+ menu.appendChild(div1);
539
+
540
+ // 3. 复制链接文本
541
+ const copyLinkTextItem = document.createElement("div");
542
+ copyLinkTextItem.className = "dsh-fcm-item";
543
+ copyLinkTextItem.textContent = "复制链接文本";
544
+ copyLinkTextItem.addEventListener("click", () => handleFileAction("copy-link-text", filePath, { linkText }));
545
+ menu.appendChild(copyLinkTextItem);
546
+
547
+ // 4. 复制路径
548
+ const copyPathItem = document.createElement("div");
549
+ copyPathItem.className = "dsh-fcm-item";
550
+ copyPathItem.textContent = "复制路径";
551
+ copyPathItem.addEventListener("click", () => handleFileAction("copy-path", filePath));
552
+ menu.appendChild(copyPathItem);
553
+
554
+ // 5. 复制文件内容
555
+ const copyContentItem = document.createElement("div");
556
+ copyContentItem.className = "dsh-fcm-item";
557
+ copyContentItem.textContent = "复制文件内容";
558
+ copyContentItem.addEventListener("click", () => handleFileAction("copy-content", filePath));
559
+ menu.appendChild(copyContentItem);
560
+
561
+ // Divider
562
+ const div2 = document.createElement("div");
563
+ div2.className = "dsh-fcm-divider";
564
+ menu.appendChild(div2);
565
+
566
+ // 6. 在 Finder 中显示
567
+ const revealItem = document.createElement("div");
568
+ revealItem.className = "dsh-fcm-item";
569
+ revealItem.textContent = `${REVEAL_IN}显示`;
570
+ revealItem.addEventListener("click", () => handleFileAction("reveal", filePath));
571
+ menu.appendChild(revealItem);
572
+
573
+ // Sibling items clear submenu when intentionally hovered
574
+ [saveAsItem, copyLinkTextItem, copyPathItem, copyContentItem, revealItem].forEach((it) => {
575
+ if (!it) return;
576
+ it.addEventListener("mouseenter", () => {
577
+ if (subTimer) clearTimeout(subTimer);
578
+ subTimer = setTimeout(() => {
579
+ submenu.classList.remove("dsh-fcm-visible");
580
+ openWithItem.classList.remove("active");
581
+ }, 80);
582
+ });
583
+ });
584
+
585
+ document.body.appendChild(menu);
586
+ currentMenu = menu;
587
+
588
+ // Position adjustment
589
+ const menuRect = menu.getBoundingClientRect();
590
+ let left = x;
591
+ let top = y;
592
+
593
+ if (left + menuRect.width > window.innerWidth - 10) {
594
+ left = window.innerWidth - menuRect.width - 10;
595
+ }
596
+ if (top + menuRect.height > window.innerHeight - 10) {
597
+ top = window.innerHeight - menuRect.height - 10;
598
+ }
599
+ menu.style.left = `${Math.max(10, left)}px`;
600
+ menu.style.top = `${Math.max(10, top)}px`;
601
+
602
+ // Submenu direction check
603
+ if (left + menuRect.width + 175 > window.innerWidth) {
604
+ submenu.classList.add("left");
605
+ }
606
+ }
607
+
608
+ exports.apply = function(ctx) {
609
+ ensureStyles();
610
+
611
+ // Intercept contextmenu in capture phase
612
+ window.addEventListener("contextmenu", (event) => {
613
+ // If Shift key is held down, allow native context menu unconditionally!
614
+ if (event.shiftKey) {
615
+ closeMenu();
616
+ return;
617
+ }
618
+
619
+ const fileInfo = extractFileInfo(event.target);
620
+ if (fileInfo) {
621
+ event.preventDefault();
622
+ event.stopPropagation();
623
+ createMenu(event.clientX, event.clientY, fileInfo);
624
+ } else {
625
+ // Normal right-click on non-file areas: do NOT preventDefault!
626
+ closeMenu();
627
+ }
628
+ }, true);
629
+
630
+ // Close on outside clicks or Escape
631
+ window.addEventListener("click", (e) => {
632
+ if (currentMenu && !currentMenu.contains(e.target)) {
633
+ closeMenu();
634
+ }
635
+ }, true);
636
+
637
+ window.addEventListener("keydown", (e) => {
638
+ if (e.key === "Escape") closeMenu();
639
+ });
640
+
641
+ window.addEventListener("blur", closeMenu);
642
+ };
643
+
644
+ return exports;
645
+ }
646
+ });