1/** 2 * Site-wide custom right-click context menu. 3 * Page-specific items register via SiteContextMenu.registerProvider(). 4 * Chatroom and other native zones are excluded automatically. 5 */ 6(function (global) { 7 'use strict'; 8 9 var MENU_CLASS = 'site-ctx-menu'; 10 var SUBMENU_CLASS = 'site-ctx-submenu'; 11 var ITEM_CLASS = 'site-ctx-item'; 12 var PAGE_EXCLUDE = /(?:^|\/)(?:chatroom|drawtogether)(?:\/|$|\.html)/i; 13 var NATIVE_ZONE_SELECTORS = [ 14 '#puppypaint-app', 15 '.pp-context-menu-host', 16 '.context-menu', 17 '[data-site-context-menu="off"]', 18 '[data-site-context-menu="interactive"]', 19 '[data-native-context-menu]' 20 ]; 21 22 var providers = []; 23 var appScopes = []; 24 var routeProviders = Object.create(null); 25 var menuEl = null; 26 var toastEl = null; 27 var toastTimer = null; 28 var activeIndex = -1; 29 var openSubmenuWrap = null; 30 var openSubmenuPanel = null; 31 var submenuCloseTimer = null; 32 var bound = false; 33 var menuOpenedAt = 0; 34 var RESIZE_HIDE_GRACE_MS = 150; 35 36 function getSettings() { 37 if (global.SettingsMenu && typeof global.SettingsMenu.getSetting === 'function') { 38 return global.SettingsMenu; 39 } 40 return null; 41 } 42 43 function getSetting(key, fallback) { 44 var sm = getSettings(); 45 if (sm) return sm.getSetting(key); 46 return fallback; 47 } 48 49 function setSetting(key, value) { 50 var sm = getSettings(); 51 if (sm && typeof sm.setSetting === 'function') { 52 sm.setSetting(key, value); 53 } 54 } 55 56 function normalizeRouteId(path) { 57 path = (path || global.location.pathname || '').replace(/\/$/, '') || '/'; 58 if (path === '/' || /\/index(?:\.html)?$/i.test(path)) return 'index.html'; 59 var parts = path.split('/'); 60 var last = parts[parts.length - 1] || 'index.html'; 61 return last.indexOf('.html') === -1 ? last + '.html' : last; 62 } 63 64 function hrefToRouteId(href) { 65 if (!href) return ''; 66 var path = href.split('?')[0].split('#')[0]; 67 if (/^https?:\/\//i.test(path)) { 68 try { 69 path = new URL(path).pathname; 70 } catch (e) { 71 return ''; 72 } 73 } 74 return normalizeRouteId(path); 75 } 76 77 function isCitrusOsPage() { 78 if (global.document.body && global.document.body.classList.contains('page-citrusos')) { 79 return true; 80 } 81 return !!global.document.getElementById('citrusos-shell'); 82 } 83 84 function getRouteId() { 85 if (isCitrusOsPage()) { 86 return 'index.html'; 87 } 88 if (global.PupwifeSpa && typeof global.PupwifeSpa.getCurrentRoute === 'function') { 89 var spaRoute = global.PupwifeSpa.getCurrentRoute(); 90 if (spaRoute) return spaRoute; 91 } 92 return normalizeRouteId(global.location.pathname); 93 } 94 95 function isExcludedPage() { 96 if (global.document.body && global.document.body.getAttribute('data-site-context-menu') === 'off') { 97 return true; 98 } 99 if (PAGE_EXCLUDE.test(global.location.pathname || '')) return true; 100 if (global.document.body && global.document.body.classList.contains('page-chatroom')) return true; 101 return false; 102 } 103 104 function isTextEntry(el) { 105 if (!el || el.nodeType !== 1) return false; 106 var tag = el.tagName; 107 if (tag === 'TEXTAREA' || tag === 'SELECT') return true; 108 if (tag === 'INPUT') { 109 var type = (el.type || 'text').toLowerCase(); 110 return type !== 'button' && type !== 'submit' && type !== 'reset' && type !== 'checkbox' && type !== 'radio'; 111 } 112 if (el.isContentEditable) return true; 113 return !!el.closest('[contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]'); 114 } 115 116 function isNativeZone(target, event) { 117 if (event && typeof event.composedPath === 'function') { 118 var path = event.composedPath(); 119 for (var p = 0; p < path.length; p++) { 120 var node = path[p]; 121 if (!node || node.nodeType !== 1 || !node.matches) continue; 122 for (var i = 0; i < NATIVE_ZONE_SELECTORS.length; i++) { 123 if (node.matches(NATIVE_ZONE_SELECTORS[i])) return true; 124 } 125 if (node.matches('canvas')) return true; 126 } 127 } 128 if (!target || !target.closest) return false;
129 for (var i = 0; i < NATIVE_ZONE_SELECTORS.length; i++) { 130 if (target.closest(NATIVE_ZONE_SELECTORS[i])) return true; 131 } 132 if (target.closest('canvas')) return true; 133 return false; 134 } 135 136 function isAppScopeSuppressed(event) { 137 if (!appScopes.length) return false; 138 var ctx = { event: event, target: normalizeEventTarget(event.target) }; 139 for (var s = 0; s < appScopes.length; s++) { 140 var scope = appScopes[s]; 141 var selectors = scope.suppressSelectors; 142 if (!selectors || !selectors.length) continue; 143 for (var i = 0; i < selectors.length; i++) { 144 if (!contextClosest(ctx, selectors[i])) continue; 145 if (scope.appId) { 146 var win = contextClosest(ctx, '.citrus-window'); 147 var appId = win ? win.getAttribute('data-app-id') : ''; 148 if (appId && appId !== scope.appId) continue; 149 } 150 return true; 151 } 152 } 153 return false; 154 } 155 156 function shouldSkip(event) { 157 if (!event || event.defaultPrevented) return true; 158 if (isExcludedPage()) return true; 159 var target = normalizeEventTarget(event.target); 160 if (!target) return true; 161 if (isTextEntry(target)) return true; 162 if (isNativeZone(target, event)) return true; 163 if (isAppScopeSuppressed(event)) return true; 164 if (target.closest && target.closest('.' + MENU_CLASS + ', .' + SUBMENU_CLASS)) return true; 165 return false; 166 } 167 168 function normalizeEventTarget(target) { 169 if (!target) return null; 170 if (target.nodeType === 1) return target; 171 if (target.nodeType === 3 && target.parentElement) return target.parentElement; 172 return null; 173 } 174 175 function contextClosest(eventOrCtx, selector) { 176 var event = eventOrCtx && eventOrCtx.event ? eventOrCtx.event : eventOrCtx; 177 var target = eventOrCtx && eventOrCtx.target ? eventOrCtx.target : null; 178 if (event && typeof event.composedPath === 'function') { 179 var path = event.composedPath(); 180 for (var i = 0; i < path.length; i++) { 181 var node = path[i]; 182 if (node && node.nodeType === 1 && node.matches && node.matches(selector)) { 183 return node; 184 } 185 } 186 } 187 var el = target || (event ? normalizeEventTarget(event.target) : null); 188 if (!el) return null; 189 if (el.nodeType === 3 && el.parentElement) el = el.parentElement; 190 return el && el.closest ? el.closest(selector) : null; 191 } 192 193 function enrichCitrusContext(ctx) { 194 if (!isCitrusOsPage()) return; 195 var win = contextClosest(ctx, '.citrus-window'); 196 var titlebar = contextClosest(ctx, '.citrus-window__titlebar'); 197 var taskbarBtn = contextClosest(ctx, '.citrus-taskbar-app'); 198 var windowContent = contextClosest(ctx, '.citrus-window__content'); 199 var fullscreenContent = contextClosest(ctx, '.citrus-fullscreen__content'); 200 var desktopSurface = contextClosest(ctx, '#citrusos-desktop'); 201 202 ctx.citrusWindow = win; 203 ctx.citrusWindowId = ''; 204 ctx.citrusWindowAppId = win ? (win.getAttribute('data-app-id') || '') : ''; 205 if (taskbarBtn) { 206 ctx.citrusWindowId = taskbarBtn.getAttribute('data-window-id') || 207 taskbarBtn.getAttribute('data-app-id') || ''; 208 ctx.citrusWindowAppId = taskbarBtn.getAttribute('data-app-id') || ctx.citrusWindowAppId; 209 } else if (win) { 210 ctx.citrusWindowId = win.getAttribute('data-app-id') || ''; 211 } 212 ctx.citrusTitlebar = titlebar; 213 ctx.citrusOnTitlebarChrome = !!titlebar; 214 ctx.citrusTaskbarBtn = taskbarBtn; 215 ctx.citrusInWindowContent = !!(windowContent || fullscreenContent); 216 ctx.citrusOnDesktop = !!(desktopSurface && !win); 217 218 var dndFloatChrome = contextClosest(ctx, '#citrusos-windows .dv-floating-overlay-host .dv-floating-titlebar, #citrusos-windows .dv-floating-overlay-host .dv-tabs-and-actions-container, .dnd-desktop-float-host .dv-floating-titlebar, .dnd-desktop-float-host .dv-tabs-and-actions-container'); 219 ctx.dndFloatChrome = dndFloatChrome; 220 ctx.dndFloatContainer = dndFloatChrome ? dndFloatChrome.closest('.dv-resize-container') : null; 221 } 222 223 function buildContext(event) { 224 var target = normalizeEventTarget(event.target); 225 var linkEl = target && target.closest ? target.closest('a[href]') : null; 226 var imgEl = target && target.closest ? target.closest('img') : null; 227 var selection = ''; 228 try { 229 selection = (global.getSelection && global.getSelection().toString()) || ''; 230 } catch (e) { /* ignore */ } 231 232 var ctx = { 233 event: event, 234 target: target, 235 x: event.clientX, 236 y: event.clientY, 237 routeId: getRouteId(), 238 linkEl: linkEl, 239 imgEl: imgEl, 240 selectionText: selection.trim(), 241 bodyClass: global.document.body ? global.document.body.className : '' 242 }; 243 enrichCitrusContext(ctx); 244 return ctx; 245 } 246 247 function resolveValue(value, ctx) { 248 return typeof value === 'function' ? value(ctx) : value; 249 } 250
251 function resolveChildren(source, ctx) { 252 var out = []; 253 if (!source || !source.length) return out; 254 for (var i = 0; i < source.length; i++) { 255 var resolved = resolveItem(source[i], ctx); 256 if (resolved) out.push(resolved); 257 } 258 return trimDividers(out); 259 } 260 261 function resolveItem(item, ctx) { 262 if (!item) return null; 263 if (typeof item.when === 'function' && !item.when(ctx)) return null; 264 if (item.when === false) return null; 265 266 if (item.type === 'divider') return { type: 'divider' }; 267 if (item.type === 'header') { 268 return { type: 'header', label: resolveValue(item.label, ctx) || '' }; 269 } 270 271 if (item.type === 'submenu') { 272 var rawChildren = typeof item.children === 'function' ? item.children(ctx) : item.children; 273 var children = resolveChildren(rawChildren, ctx); 274 if (!children.length) return null; 275 var subLabel = resolveValue(item.label, ctx) || ''; 276 if (!subLabel) return null; 277 return { 278 type: 'submenu', 279 id: item.id || subLabel, 280 label: subLabel, 281 icon: resolveValue(item.icon, ctx) || '', 282 children: children 283 }; 284 } 285 286 var hidden = resolveValue(item.hidden, ctx); 287 if (hidden) return null; 288 289 var disabled = !!resolveValue(item.disabled, ctx); 290 var label = resolveValue(item.label, ctx) || ''; 291 if (!label) return null; 292 293 return { 294 type: 'item', 295 id: item.id || label, 296 label: label, 297 icon: resolveValue(item.icon, ctx) || '', 298 hint: resolveValue(item.hint, ctx) || '', 299 checked: !!resolveValue(item.checked, ctx), 300 danger: !!resolveValue(item.danger, ctx), 301 disabled: disabled, 302 defaultFocus: !!item.defaultFocus, 303 action: item.action 304 }; 305 } 306 307 function isPupocalypsePage() { 308 return !!(global.document.body && global.document.body.classList.contains('pupocalypse-page')); 309 } 310 311 function collectItems(ctx) { 312 var globalItems = []; 313 var pageItems = []; 314 var i; 315 var list; 316 var resolved; 317 318 function pushList(source, target) { 319 if (!source || !source.length) return; 320 for (i = 0; i < source.length; i++) { 321 resolved = resolveItem(source[i], ctx); 322 if (resolved) target.push(resolved); 323 } 324 } 325 326 if (!isPupocalypsePage()) { 327 for (i = 0; i < appScopes.length; i++) { 328 var scope = appScopes[i]; 329 if (scope.appId && ctx.citrusWindowAppId && scope.appId !== ctx.citrusWindowAppId) continue; 330 if (scope.rootSelector && !contextClosest(ctx, scope.rootSelector)) continue; 331 if (!scope.provider) continue; 332 try { 333 list = scope.provider(ctx); 334 if (list && list.length) { 335 pushList(list, pageItems); 336 } 337 } catch (err) { 338 if (typeof global.devWarn === 'function') { 339 global.devWarn('[SiteContextMenu] app scope provider failed', err); 340 } 341 } 342 } 343 344 for (i = 0; i < providers.length; i++) { 345 try { 346 list = providers[i](ctx); 347 pushList(list, globalItems); 348 } catch (err) { 349 if (typeof global.devWarn === 'function') { 350 global.devWarn('[SiteContextMenu] provider failed', err); 351 } 352 } 353 } 354 } 355 356 if (routeProviders[ctx.routeId]) { 357 list = routeProviders[ctx.routeId]; 358 pushList(typeof list === 'function' ? list(ctx) : list, pageItems); 359 } 360 361 if (pageItems.length) { 362 if (isPupocalypsePage()) { 363 return trimDividers(pageItems); 364 } 365 var insertAt = 1; 366 for (i = 0; i < globalItems.length; i++) { 367 if (globalItems[i].defaultFocus) { 368 insertAt = i + 1; 369 break; 370 } 371 } 372 globalItems = globalItems.slice(0, insertAt) 373 .concat(pageItems, globalItems.slice(insertAt)); 374 } 375 376 return trimDividers(globalItems); 377 } 378 379 function trimDividers(items) { 380 var out = []; 381 var lastWasDivider = true; 382 for (var i = 0; i < items.length; i++) { 383 var item = items[i]; 384 if (item.type === 'divider') { 385 if (lastWasDivider || !out.length) continue; 386 out.push(item); 387 lastWasDivider = true; 388 continue; 389 } 390 if (item.type === 'header') { 391 var nextIsUseful = false;
392 for (var j = i + 1; j < items.length; j++) { 393 if (items[j].type === 'divider' || items[j].type === 'header') break; 394 nextIsUseful = true; 395 break; 396 } 397 if (!nextIsUseful) continue; 398 } 399 out.push(item); 400 lastWasDivider = false; 401 } 402 while (out.length && out[out.length - 1].type === 'divider') out.pop(); 403 return out; 404 } 405 406 function ensureToast() { 407 if (toastEl) return toastEl; 408 toastEl = global.document.createElement('div'); 409 toastEl.className = 'site-ctx-toast'; 410 toastEl.setAttribute('role', 'status'); 411 toastEl.setAttribute('aria-live', 'polite'); 412 global.document.body.appendChild(toastEl); 413 return toastEl; 414 } 415 416 function showToast(message) { 417 if (!message) return; 418 var el = ensureToast(); 419 el.textContent = message; 420 el.classList.add('is-visible'); 421 clearTimeout(toastTimer); 422 toastTimer = global.setTimeout(function () { 423 el.classList.remove('is-visible'); 424 }, 2200); 425 } 426 427 function copyText(text, successMsg) { 428 if (!text) { 429 showToast('Nothing to copy'); 430 return Promise.resolve(false); 431 } 432 if (global.navigator && global.navigator.clipboard && global.navigator.clipboard.writeText) { 433 return global.navigator.clipboard.writeText(text).then(function () { 434 showToast(successMsg || 'Copied!'); 435 return true; 436 }).catch(function () { 437 showToast('Copy failed'); 438 return false; 439 }); 440 } 441 showToast('Copy not supported here'); 442 return Promise.resolve(false); 443 } 444 445 function closeSubmenu() { 446 if (submenuCloseTimer) { 447 clearTimeout(submenuCloseTimer); 448 submenuCloseTimer = null; 449 } 450 if (openSubmenuWrap) { 451 openSubmenuWrap.classList.remove('is-open'); 452 var trigger = openSubmenuWrap.querySelector('[data-submenu-trigger="1"]'); 453 if (trigger) trigger.setAttribute('aria-expanded', 'false'); 454 openSubmenuWrap = null; 455 } 456 if (openSubmenuPanel) { 457 openSubmenuPanel.hidden = true; 458 openSubmenuPanel = null; 459 } 460 } 461 462 function hideMenu() { 463 if (submenuCloseTimer) { 464 clearTimeout(submenuCloseTimer); 465 submenuCloseTimer = null; 466 } 467 global.document.querySelectorAll('.' + SUBMENU_CLASS).forEach(function (node) { 468 node.remove(); 469 }); 470 if (openSubmenuWrap) { 471 openSubmenuWrap.classList.remove('is-open'); 472 var trigger = openSubmenuWrap.querySelector('[data-submenu-trigger="1"]'); 473 if (trigger) trigger.setAttribute('aria-expanded', 'false'); 474 openSubmenuWrap = null; 475 } 476 openSubmenuPanel = null; 477 if (!menuEl) return; 478 menuEl.remove(); 479 menuEl = null; 480 activeIndex = -1; 481 } 482 483 function getFocusableItems(root) { 484 root = root || menuEl; 485 if (!root) return []; 486 return Array.prototype.slice.call( 487 root.querySelectorAll('button.' + ITEM_CLASS + ':not([disabled]):not([data-submenu-trigger="1"])') 488 ); 489 } 490 491 function getSubmenuTriggerItems(root) { 492 root = root || menuEl; 493 if (!root) return []; 494 return Array.prototype.slice.call( 495 root.querySelectorAll('button.' + ITEM_CLASS + '[data-submenu-trigger="1"]:not([disabled])') 496 ); 497 } 498 499 function getAllMainFocusables() { 500 if (!menuEl) return []; 501 return getSubmenuTriggerItems().concat(getFocusableItems()); 502 } 503 504 function focusItemAt(index) { 505 var items = getAllMainFocusables(); 506 if (!items.length) return; 507 if (index < 0) index = items.length - 1; 508 if (index >= items.length) index = 0; 509 activeIndex = index; 510 items[index].focus(); 511 } 512 513 function setInitialActiveIndex() { 514 if (!menuEl) return; 515 var items = getAllMainFocusables(); 516 activeIndex = 0; 517 for (var i = 0; i < items.length; i++) { 518 if (items[i].getAttribute('data-default-focus') === '1') { 519 activeIndex = i; 520 return; 521 } 522 } 523 } 524 525 function clearMenuFocus(exceptBtn) { 526 if (!menuEl) return; 527 var focused = menuEl.querySelectorAll('button.' + ITEM_CLASS + ':focus'); 528 for (var i = 0; i < focused.length; i++) {
529 if (focused[i] !== exceptBtn) focused[i].blur(); 530 } 531 } 532 533 function clampPanelPosition(panel, anchorRect, preferRight) { 534 var rect = panel.getBoundingClientRect(); 535 var left; 536 var top = anchorRect.top; 537 538 if (preferRight) { 539 left = anchorRect.right + 4; 540 if (left + rect.width > global.innerWidth - 8) { 541 left = anchorRect.left - rect.width - 4; 542 panel.classList.add('site-ctx-submenu--left'); 543 } else { 544 panel.classList.remove('site-ctx-submenu--left'); 545 } 546 } else { 547 left = anchorRect.left; 548 } 549 550 if (top + rect.height > global.innerHeight - 8) { 551 top = Math.max(8, global.innerHeight - rect.height - 8); 552 } 553 554 panel.style.left = Math.max(8, left) + 'px'; 555 panel.style.top = Math.max(8, top) + 'px'; 556 } 557 558 function clampMenuPosition(left, top) { 559 var rect = menuEl.getBoundingClientRect(); 560 var maxLeft = global.innerWidth - rect.width - 8; 561 var maxTop = global.innerHeight - rect.height - 8; 562 menuEl.style.left = Math.max(8, Math.min(left, maxLeft)) + 'px'; 563 menuEl.style.top = Math.max(8, Math.min(top, maxTop)) + 'px'; 564 } 565 566 function runItemAction(item, ctx) { 567 if (!item || item.disabled || typeof item.action !== 'function') return; 568 hideMenu(); 569 try { 570 item.action(ctx); 571 } catch (err) { 572 if (typeof global.devWarn === 'function') { 573 global.devWarn('[SiteContextMenu]', err); 574 } 575 } 576 } 577 578 function buildItemButton(item, ctx, opts) { 579 opts = opts || {}; 580 var btn = global.document.createElement('button'); 581 btn.type = 'button'; 582 btn.className = ITEM_CLASS + (item.danger ? ' site-ctx-item--danger' : ''); 583 btn.setAttribute('role', opts.submenuTrigger ? 'menuitem' : 'menuitem'); 584 btn.dataset.itemId = item.id; 585 if (item.defaultFocus) btn.setAttribute('data-default-focus', '1'); 586 if (item.disabled) btn.disabled = true; 587 588 var icon = global.document.createElement('span'); 589 icon.className = 'site-ctx-item__icon'; 590 icon.setAttribute('aria-hidden', 'true'); 591 icon.innerHTML = item.icon ? '<i class="' + item.icon + '"></i>' : ''; 592 593 var label = global.document.createElement('span'); 594 label.className = 'site-ctx-item__label'; 595 label.textContent = item.label; 596 597 btn.appendChild(icon); 598 btn.appendChild(label); 599 600 if (opts.submenuTrigger) { 601 btn.setAttribute('data-submenu-trigger', '1'); 602 btn.setAttribute('aria-haspopup', 'true'); 603 btn.setAttribute('aria-expanded', 'false'); 604 var arrow = global.document.createElement('span'); 605 arrow.className = 'site-ctx-item__arrow'; 606 arrow.setAttribute('aria-hidden', 'true'); 607 arrow.innerHTML = '<i class="fa-solid fa-chevron-right"></i>'; 608 btn.appendChild(arrow); 609 return btn; 610 } 611 612 if (item.hint) { 613 var hint = global.document.createElement('span'); 614 hint.className = 'site-ctx-item__hint'; 615 hint.textContent = item.hint; 616 btn.appendChild(hint); 617 } 618 619 var check = global.document.createElement('span'); 620 check.className = 'site-ctx-item__check' + (item.checked ? ' is-on' : ''); 621 check.setAttribute('aria-hidden', 'true'); 622 check.innerHTML = item.checked ? '<i class="fa-solid fa-check"></i>' : ''; 623 btn.appendChild(check); 624 625 btn.addEventListener('click', function () { 626 runItemAction(item, ctx); 627 }); 628 629 btn.addEventListener('mouseenter', function () { 630 clearMenuFocus(btn); 631 }); 632 633 return btn; 634 } 635
636 function renderSubmenuPanel(children, ctx, parentBtn) { 637 var panel = global.document.createElement('div'); 638 panel.className = SUBMENU_CLASS; 639 if (global.document.body && global.document.body.classList.contains('page-citrusos')) { 640 panel.classList.add('site-ctx-submenu--citrusos'); 641 } 642 panel.setAttribute('role', 'menu'); 643 644 children.forEach(function (child) { 645 if (child.type === 'divider') { 646 var divider = global.document.createElement('div'); 647 divider.className = 'site-ctx-menu__divider'; 648 divider.setAttribute('role', 'separator'); 649 panel.appendChild(divider); 650 return; 651 } 652 if (child.type !== 'item') return; 653 panel.appendChild(buildItemButton(child, ctx)); 654 }); 655 656 panel.addEventListener('wheel', function (event) { 657 event.stopPropagation(); 658 }, { passive: true }); 659 panel.addEventListener('scroll', function (event) { 660 event.stopPropagation(); 661 }); 662 663 global.document.body.appendChild(panel); 664 665 panel.addEventListener('wheel', function (event) { 666 event.stopPropagation(); 667 }, { passive: true }); 668 panel.addEventListener('scroll', function (event) { 669 event.stopPropagation(); 670 }); 671 672 return panel; 673 } 674 675 function openSubmenu(wrap, triggerBtn, panel) { 676 closeSubmenu(); 677 openSubmenuWrap = wrap; 678 openSubmenuPanel = panel; 679 wrap.classList.add('is-open'); 680 triggerBtn.setAttribute('aria-expanded', 'true'); 681 panel.hidden = false; 682 global.requestAnimationFrame(function () { 683 clampPanelPosition(panel, triggerBtn.getBoundingClientRect(), true); 684 }); 685 } 686 687 function attachSubmenu(wrap, triggerBtn, item, ctx) { 688 var panel = renderSubmenuPanel(item.children, ctx, triggerBtn); 689 panel.hidden = true; 690 691 function scheduleClose() { 692 clearTimeout(submenuCloseTimer); 693 submenuCloseTimer = global.setTimeout(function () { 694 var overTrigger = wrap.matches(':hover'); 695 var overPanel = panel.matches(':hover'); 696 if (!overTrigger && !overPanel) { 697 closeSubmenu(); 698 } 699 }, 140); 700 } 701 702 triggerBtn.addEventListener('mouseenter', function () { 703 clearTimeout(submenuCloseTimer); 704 clearMenuFocus(triggerBtn); 705 openSubmenu(wrap, triggerBtn, panel); 706 }); 707 triggerBtn.addEventListener('mouseleave', scheduleClose); 708 panel.addEventListener('mouseenter', function () { 709 clearTimeout(submenuCloseTimer); 710 openSubmenu(wrap, triggerBtn, panel); 711 }); 712 panel.addEventListener('mouseleave', scheduleClose); 713 714 triggerBtn.addEventListener('click', function (event) { 715 event.preventDefault(); 716 event.stopPropagation(); 717 if (wrap.classList.contains('is-open')) { 718 closeSubmenu(); 719 } else { 720 openSubmenu(wrap, triggerBtn, panel); 721 } 722 }); 723 724 triggerBtn.addEventListener('keydown', function (event) { 725 if (event.key === 'ArrowRight' || event.key === 'Enter' || event.key === ' ') { 726 event.preventDefault(); 727 openSubmenu(wrap, triggerBtn, panel); 728 var first = panel.querySelector('button.' + ITEM_CLASS + ':not([disabled])'); 729 if (first) first.focus(); 730 } 731 }); 732 } 733 734 function renderMenu(ctx, items) { 735 hideMenu(); 736 if (!items.length) return; 737 738 menuEl = global.document.createElement('div'); 739 menuEl.className = MENU_CLASS; 740 if (global.document.body && global.document.body.classList.contains('page-citrusos')) { 741 menuEl.classList.add('site-ctx-menu--citrusos'); 742 } 743 if (isPupocalypsePage()) { 744 menuEl.classList.add('pup-ctx-menu'); 745 } 746 menuEl.setAttribute('role', 'menu'); 747 menuEl.setAttribute('tabindex', '-1'); 748 menuEl.style.left = ctx.x + 'px'; 749 menuEl.style.top = ctx.y + 'px'; 750
751 items.forEach(function (item) { 752 if (item.type === 'divider') { 753 var divider = global.document.createElement('div'); 754 divider.className = 'site-ctx-menu__divider'; 755 divider.setAttribute('role', 'separator'); 756 menuEl.appendChild(divider); 757 return; 758 } 759 if (item.type === 'header') { 760 var header = global.document.createElement('div'); 761 header.className = 'site-ctx-menu__header'; 762 header.textContent = item.label; 763 menuEl.appendChild(header); 764 return; 765 } 766 if (item.type === 'submenu') { 767 var wrap = global.document.createElement('div'); 768 wrap.className = 'site-ctx-submenu-wrap'; 769 var trigger = buildItemButton(item, ctx, { submenuTrigger: true }); 770 wrap.appendChild(trigger); 771 attachSubmenu(wrap, trigger, item, ctx); 772 menuEl.appendChild(wrap); 773 return; 774 } 775 776 menuEl.appendChild(buildItemButton(item, ctx)); 777 }); 778 779 global.document.body.appendChild(menuEl); 780 menuOpenedAt = Date.now(); 781 782 menuEl.addEventListener('wheel', function (event) { 783 event.stopPropagation(); 784 }, { passive: true }); 785 menuEl.addEventListener('scroll', function (event) { 786 event.stopPropagation(); 787 }); 788 789 global.requestAnimationFrame(function () { 790 clampMenuPosition(ctx.x, ctx.y); 791 setInitialActiveIndex(); 792 }); 793 } 794 795 function showMenu(event) { 796 var ctx = buildContext(event); 797 var items = collectItems(ctx); 798 if (!items.length) return; 799 renderMenu(ctx, items); 800 } 801 802 function onContextMenu(event) { 803 if (shouldSkip(event)) return; 804 if ( 805 global.PupocalypseMenu && 806 typeof global.PupocalypseMenu.tryContextToggle === 'function' && 807 global.PupocalypseMenu.tryContextToggle(event) 808 ) { 809 return; 810 } 811 event.preventDefault(); 812 hideMenu(); 813 showMenu(event); 814 } 815 816 function onDocumentPointerDown(event) { 817 if (!menuEl) return; 818 if (menuEl.contains(event.target)) return; 819 if (event.target && event.target.closest && event.target.closest('.' + SUBMENU_CLASS)) return; 820 hideMenu(); 821 } 822 823 function onScroll(event) { 824 if (!menuEl) return; 825 var target = event.target; 826 if (target === menuEl || (target && menuEl.contains(target))) return; 827 if (target && target.closest && target.closest('.' + SUBMENU_CLASS)) return; 828 hideMenu(); 829 } 830 831 function onKeyDown(event) { 832 if (!menuEl) return; 833 834 if (event.key === 'Escape') { 835 if (openSubmenuWrap) { 836 event.preventDefault(); 837 closeSubmenu(); 838 var trigger = openSubmenuWrap.querySelector('[data-submenu-trigger="1"]'); 839 if (trigger) trigger.focus(); 840 return; 841 } 842 event.preventDefault(); 843 hideMenu(); 844 return; 845 } 846 847 var inSubmenu = event.target && event.target.closest && event.target.closest('.' + SUBMENU_CLASS); 848 if (inSubmenu) { 849 var subItems = getFocusableItems(inSubmenu); 850 var subIndex = subItems.indexOf(event.target); 851 if (event.key === 'ArrowDown') { 852 event.preventDefault(); 853 if (subIndex < subItems.length - 1) subItems[subIndex + 1].focus(); 854 else subItems[0].focus(); 855 } else if (event.key === 'ArrowUp') { 856 event.preventDefault(); 857 if (subIndex > 0) subItems[subIndex - 1].focus(); 858 else subItems[subItems.length - 1].focus(); 859 } else if (event.key === 'ArrowLeft') { 860 event.preventDefault(); 861 closeSubmenu(); 862 var parentTrigger = menuEl.querySelector('.site-ctx-submenu-wrap.is-open [data-submenu-trigger="1"]') 863 || menuEl.querySelector('[data-submenu-trigger="1"]'); 864 if (parentTrigger) parentTrigger.focus(); 865 } 866 return; 867 } 868 869 var items = getAllMainFocusables(); 870 if (!items.length) return; 871 872 if (event.key === 'ArrowDown') { 873 event.preventDefault(); 874 focusItemAt(activeIndex + 1); 875 return; 876 } 877 if (event.key === 'ArrowUp') { 878 event.preventDefault(); 879 focusItemAt(activeIndex - 1); 880 return; 881 } 882 if (event.key === 'Home') { 883 event.preventDefault(); 884 focusItemAt(0); 885 return; 886 } 887 if (event.key === 'End') { 888 event.preventDefault(); 889 focusItemAt(items.length - 1); 890 return; 891 } 892 if (event.key === 'ArrowRight') { 893 if (event.target && event.target.getAttribute('data-submenu-trigger') === '1') { 894 event.preventDefault(); 895 event.target.click(); 896 } 897 return; 898 } 899 if (event.key === 'Enter' || event.key === ' ') { 900 if (document.activeElement && menuEl.contains(document.activeElement)) { 901 if (document.activeElement.getAttribute('data-submenu-trigger') === '1') return; 902 event.preventDefault(); 903 document.activeElement.click(); 904 } 905 } 906 } 907 908 function onWindowResizeHideMenu() { 909 if (!menuEl) return; 910 if (Date.now() - menuOpenedAt < RESIZE_HIDE_GRACE_MS) return; 911 hideMenu(); 912 } 913 914 function bind() { 915 if (bound || isExcludedPage()) return; 916 bound = true; 917 global.document.addEventListener('contextmenu', onContextMenu, true); 918 global.document.addEventListener('mousedown', onDocumentPointerDown, true); 919 global.document.addEventListener('scroll', onScroll, true); 920 global.document.addEventListener('keydown', onKeyDown, true); 921 global.addEventListener('blur', hideMenu); 922 global.addEventListener('resize', onWindowResizeHideMenu); 923 global.document.addEventListener('pupwife:spa-ready', hideMenu); 924 } 925 926 function registerProvider(fn) { 927 if (typeof fn === 'function') providers.push(fn); 928 } 929 930 function registerPage(routeId, itemsOrFn) { 931 routeProviders[routeId] = itemsOrFn; 932 } 933 934 function registerAppScope(opts) { 935 opts = opts || {}; 936 var entry = { 937 appId: opts.appId || '', 938 rootSelector: opts.rootSelector || '', 939 suppressSelectors: Array.isArray(opts.suppressSelectors) ? opts.suppressSelectors.slice() : [], 940 provider: typeof opts.provider === 'function' ? opts.provider : null 941 }; 942 appScopes.push(entry); 943 return function unregisterAppScope() { 944 var idx = appScopes.indexOf(entry); 945 if (idx !== -1) appScopes.splice(idx, 1); 946 }; 947 } 948 949 global.SiteContextMenu = { 950 registerProvider: registerProvider, 951 registerPage: registerPage, 952 registerAppScope: registerAppScope, 953 hide: hideMenu, 954 showToast: showToast, 955 copyText: copyText, 956 getRouteId: getRouteId, 957 hrefToRouteId: hrefToRouteId, 958 getSetting: getSetting, 959 setSetting: setSetting, 960 contextClosest: contextClosest, 961 isCitrusOsPage: isCitrusOsPage, 962 navigate: function (routeId) { 963 hideMenu(); 964 if (isCitrusOsPage() && 965 global.CitrusDesktop && typeof global.CitrusDesktop.openApp === 'function' && 966 global.CitrusAppRegistry && typeof global.CitrusAppRegistry.getApp === 'function') { 967 var key = (routeId || '').replace(/^\//, ''); 968 var appId = key.replace(/\.html$/i, ''); 969 if (appId === 'index') appId = 'dashboard'; 970 if (key === 'dashboard.html') appId = 'dashboard'; 971 var app = global.CitrusAppRegistry.getApp(appId); 972 if (app) { 973 global.CitrusDesktop.openApp(app.id); 974 return; 975 } 976 } 977 if (global.PupwifeSpa && typeof global.PupwifeSpa.navigate === 'function') { 978 return global.PupwifeSpa.navigate(routeId); 979 } 980 global.location.href = routeId.charAt(0) === '/' ? routeId : '/' + routeId; 981 }, 982 init: bind 983 }; 984 985 if (global.document.readyState === 'loading') { 986 global.document.addEventListener('DOMContentLoaded', bind); 987 } else { 988 bind(); 989 } 990})(typeof window !== 'undefined' ? window : this);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.