PageSourceSearch

https://pupwife.com/scripts/effects/siteContextMenu.js?v=

js pupwife.com collected 2026-09-30 15:06:00 UTC 36,407 bytes, 990 lines download raw bytes

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.