diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bbe542f..04ffefa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,7 +24,20 @@ "Bash(git push:*)", "Bash(find /e/www/habbo-next/src/app -type f -path *catalog*)", "Bash(echo \"EXIT:$?\")", - "Bash(find /E/www/habbo-next/src -type f -name *prisma*)" + "Bash(find /E/www/habbo-next/src -type f -name *prisma*)", + "Bash(node -e \"const mysql = require\\(''mysql2/promise''\\); \\(async\\(\\)=>{const c=await mysql.createConnection\\({host:''localhost'',user:''root'',database:''habbo''}\\); const [r]=await c.query\\(''DESCRIBE items_base''\\); console.log\\(r.map\\(x=>x.Field\\).join\\(''\\\\n''\\)\\); await c.end\\(\\);}\\)\\(\\)\")", + "Bash(node -e \"const mysql = require\\(''mysql2/promise''\\); \\(async\\(\\)=>{const c=await mysql.createConnection\\({host:''localhost'',user:''habbo'',password:''habbo'',database:''next''}\\); const [r]=await c.query\\(''DESCRIBE items_base''\\); console.log\\(r.map\\(x=>x.Field\\).join\\(''\\\\n''\\)\\); const [r2]=await c.query\\(''DESCRIBE catalog_items''\\); console.log\\(''---''\\); console.log\\(r2.map\\(x=>x.Field\\).join\\(''\\\\n''\\)\\); await c.end\\(\\);}\\)\\(\\)\")", + "Bash(node -e \"const mysql = require\\(''mysql2/promise''\\); \\(async\\(\\)=>{const c=await mysql.createConnection\\({host:''localhost'',user:''habbo'',password:''habbo'',database:''next''}\\); const [r]=await c.query\\(''DESCRIBE items_base''\\); const cols=r.map\\(x=>x.Field\\); console.log\\(''items_base columns:'', JSON.stringify\\(cols\\)\\); const [r2]=await c.query\\(''DESCRIBE catalog_items''\\); const cols2=r2.map\\(x=>x.Field\\); console.log\\(''catalog_items columns:'', JSON.stringify\\(cols2\\)\\); await c.end\\(\\);}\\)\\(\\)\")", + "Bash(node -e \":*)", + "Bash(npx prisma:*)", + "WebFetch(domain:www.habbo.it)", + "Bash(grep -r \"slider\\\\|height\\\\|rotation\\\\|state\\\\|speed\" /e/www/habbo-next/public/nitro3/src/components/room/widgets/furniture/*.tsx)", + "Bash(grep -r \"processAction\\\\|handleAction\\\\|dispatch\" /e/www/habbo-next/public/nitro3/src/components/room/widgets/furniture/*.tsx)", + "Bash(xargs ls:*)", + "Bash(find /e/www/habbo-next/public/nitro3/src -type f \\\\\\(-name *Modif* -o -name *Manip* -o -name *Floorplan* -o -name *Builder* \\\\\\))", + "Bash(mkdir -p \"E:/www/habbo-next/public/nitro3/src/api/plugins\")", + "Bash(mkdir -p \"E:/www/habbo-next/public/nitro3/src/components/plugins/room-builder\")", + "Bash(ls \"E:/www/habbo-next/public/nitro3/vite.config\"*)" ] } } diff --git a/public/plugins/room-builder.js b/public/plugins/room-builder.js new file mode 100644 index 0000000..b9ae098 --- /dev/null +++ b/public/plugins/room-builder.js @@ -0,0 +1,587 @@ +/** + * Room Builder Plugin - Menu Costruzioni + * + * Plugin esterno per Nitro Client. + * Richiede il RoomBuilderPlugin.jar lato server (Arcturus). + * + * Se rimuovi questo file, il bottone scompare automaticamente dalla UI. + * + * Colori e stili uniformati al tema Nitro Client. + */ +(function () +{ + 'use strict'; + + // ─── Nitro Theme Colors ─── + var THEME = { + // Card / Window + headerBg: '#1E7295', + headerText: '#FFFFFF', + tabsBg: '#185D79', + cardBorder: '#283F5D', + contentBg: '#DFDFDF', + + // Buttons + btnPrimary: '#3c6d82', + btnPrimaryBrd: '#1a617f', + btnPrimaryHov: '#4a8199', + btnActive: '#185D79', + btnActiveBrd: '#0f4a63', + btnActiveHov: '#1E7295', + btnDanger: '#a81a12', + btnDangerBrd: '#b9322a', + btnDangerHov: '#c43a32', + btnSuccess: '#00800b', + btnSuccessBrd: '#006d09', + btnWarning: '#ffc107', + btnWarningBrd: '#f3c12a', + + // Dark panel (infostand style) + darkBg: '#212131', + darkBorder: '#383853', + darkShadow: 'inset 0 5px rgba(38,38,57,0.6), inset 0 -4px rgba(25,25,37,0.6)', + + // Grid items + gridBg: '#CDD3D9', + gridBorder: '#B6BEC5', + gridActiveBg: '#ECECEC', + gridActiveBrd: '#FFFFFF', + + // Text + textLight: '#FFFFFF', + textDark: '#212529', + textMuted: '#B6BEC5', + + // Typography + fontFamily: 'Ubuntu, sans-serif', + fontSm: '0.7875rem', + fontBase: '0.9rem', + + // Misc + borderRadius: '0.5rem', + borderRadiusSm: '0.25rem', + scrollThumb: 'rgba(30, 114, 149, 0.4)', + scrollThumbHov: 'rgba(30, 114, 149, 0.8)' + }; + + function waitForApi(callback, maxRetries) + { + if (maxRetries === undefined) maxRetries = 50; + if (window.NitroPlugins) + { + callback(window.NitroPlugins); + return; + } + if (maxRetries <= 0) + { + console.warn('[RoomBuilder] NitroPlugins API not found after retries'); + return; + } + setTimeout(function () { waitForApi(callback, maxRetries - 1); }, 200); + } + + // ─── Constants ─── + var FLOOR = 10; + var WALL = 20; + + // ─── Send chat command to server via proper API ─── + function sendCommand(api, command) + { + try + { + api.sendChat(':' + command); + } + catch (e) + { + console.warn('[RoomBuilder] sendCommand error:', e); + } + } + + // ─── Section Label Helper ─── + function createSectionLabel(container, text) + { + var label = document.createElement('div'); + label.textContent = text; + label.style.cssText = 'font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';font-weight:bold;color:' + THEME.textDark + ';margin:10px 0 6px 0;padding-bottom:4px;border-bottom:1px solid ' + THEME.gridBorder + ';text-transform:uppercase;letter-spacing:0.5px'; + container.appendChild(label); + } + + // ─── Slider Helper ─── + function createSlider(container, label, min, max, value, step, onChange) + { + var row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px;padding:4px 6px;background:' + THEME.gridBg + ';border:1px solid ' + THEME.gridBorder + ';border-radius:' + THEME.borderRadiusSm; + + var lbl = document.createElement('span'); + lbl.textContent = label; + lbl.style.cssText = 'width:70px;color:' + THEME.textDark + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';font-weight:bold;flex-shrink:0'; + + var slider = document.createElement('input'); + slider.type = 'range'; + slider.min = min; + slider.max = max; + slider.step = step || 1; + slider.value = value; + slider.style.cssText = 'flex:1;height:6px;cursor:pointer;accent-color:' + THEME.headerBg; + + var valDisplay = document.createElement('span'); + valDisplay.textContent = value; + valDisplay.style.cssText = 'width:28px;color:' + THEME.textDark + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';text-align:center;font-weight:bold'; + + var saveBtn = document.createElement('button'); + saveBtn.innerHTML = '💾'; + saveBtn.title = 'Salva'; + saveBtn.style.cssText = 'width:28px;height:28px;min-height:28px;display:flex;align-items:center;justify-content:center;background:' + THEME.btnSuccess + ';border:2px solid ' + THEME.btnSuccessBrd + ';border-radius:' + THEME.borderRadius + ';cursor:pointer;font-size:11px;color:' + THEME.textLight; + + var resetBtn = document.createElement('button'); + resetBtn.innerHTML = '↩'; + resetBtn.title = 'Ripristina'; + resetBtn.style.cssText = 'width:28px;height:28px;min-height:28px;display:flex;align-items:center;justify-content:center;background:' + THEME.btnDanger + ';border:2px solid ' + THEME.btnDangerBrd + ';border-radius:' + THEME.borderRadius + ';color:' + THEME.textLight + ';cursor:pointer;font-size:13px;font-weight:bold'; + + slider.addEventListener('input', function () + { + valDisplay.textContent = slider.value; + }); + + saveBtn.addEventListener('click', function () + { + if (onChange) onChange(Number(slider.value)); + }); + + resetBtn.addEventListener('click', function () + { + slider.value = value; + valDisplay.textContent = value; + if (onChange) onChange(Number(value)); + }); + + row.appendChild(lbl); + row.appendChild(slider); + row.appendChild(valDisplay); + row.appendChild(saveBtn); + row.appendChild(resetBtn); + container.appendChild(row); + + return { slider: slider, valDisplay: valDisplay }; + } + + // ─── Button Helper ─── + function createButton(container, label, onClick, opts) + { + opts = opts || {}; + var isActive = opts.active || false; + var isDanger = opts.danger || false; + + var bgColor = isDanger ? THEME.btnDanger : (isActive ? THEME.btnActive : THEME.btnPrimary); + var borderColor = isDanger ? THEME.btnDangerBrd : (isActive ? THEME.btnActiveBrd : THEME.btnPrimaryBrd); + var hoverColor = isDanger ? THEME.btnDangerHov : (isActive ? THEME.btnActiveHov : THEME.btnPrimaryHov); + + var btn = document.createElement('button'); + btn.textContent = label; + btn.style.cssText = 'padding:0.25rem 0.5rem;border-radius:' + THEME.borderRadius + ';color:' + THEME.textLight + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';font-weight:500;cursor:pointer;border:2px solid ' + borderColor + ';transition:background .15s;background:' + bgColor + ';min-height:28px;box-shadow:none;' + (opts.fullWidth ? 'width:100%;' : '') + (opts.extraStyle || ''); + + btn.addEventListener('mouseenter', function () { btn.style.background = hoverColor; }); + btn.addEventListener('mouseleave', function () { btn.style.background = bgColor; }); + btn.addEventListener('click', function () + { + if (onClick) onClick(btn); + }); + if (container) container.appendChild(btn); + return btn; + } + + // ─── Grid Row Helper ─── + function createButtonRow(container, cols, buttons) + { + var row = document.createElement('div'); + row.style.cssText = 'display:grid;grid-template-columns:repeat(' + cols + ',1fr);gap:6px;margin-bottom:6px'; + buttons.forEach(function (b) { createButton(row, b.label, b.onClick, b); }); + container.appendChild(row); + } + + // ─── Utility: iterate room floor objects ─── + function forEachFloorObject(api, callback) + { + var session = api.getRoomSession(); + var engine = api.getRoomEngine(); + if (!session || !engine) return; + var objects = engine.getRoomObjects(session.roomId, FLOOR); + for (var i = 0; i < objects.length; i++) + { + var obj = engine.getRoomObject(session.roomId, objects[i].id, FLOOR); + if (obj) callback(obj, objects[i].id, session.roomId, engine); + } + } + + // ─── State ─── + var state = { + hidePyramids: false, + hideCarpets: false, + hideWalls: false, + hideWired: false, + frozen: false, + teleporting: false + }; + + // ─── Stack clipboard (client-side memory) ─── + var stackClipboard = null; + + // ─── Plugin Init ─── + waitForApi(function (api) + { + api.register({ + name: 'room-builder', + label: 'Menu costruzioni', + icon: 'icon-cog', + + onOpen: function () + { + var content = api.createWindow('room-builder', 'Menu costruzioni', 440); + if (!content) return; + + // Apply Nitro content area style to the content container + content.style.cssText = 'padding:10px;font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontBase; + + // ─── Warning banner (Nitro tabs style) ─── + var banner = document.createElement('div'); + banner.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:6px;background:' + THEME.tabsBg + ';border:1px solid ' + THEME.cardBorder + ';border-radius:' + THEME.borderRadiusSm + ';padding:8px 12px;margin-bottom:12px'; + banner.innerHTML = 'Assicurati di non spammare per non essere mutato'; + content.appendChild(banner); + + // ─── Sliders Section ─── + createSectionLabel(content, 'Controlli'); + + var slidersDiv = document.createElement('div'); + slidersDiv.style.marginBottom = '8px'; + + createSlider(slidersDiv, 'Altezza', -10, 40, 0, 1, function (val) + { + try + { + sendCommand(api, 'autotile ' + val); + } + catch (e) { console.warn('[RoomBuilder] Height:', e); } + }); + + createSlider(slidersDiv, 'Velocita', 0, 10, 4, 1, function (val) + { + sendCommand(api, 'rb_speed ' + val); + }); + + content.appendChild(slidersDiv); + + // ─── Screenshot ─── + var ssDiv = document.createElement('div'); + ssDiv.style.marginBottom = '8px'; + createButton(ssDiv, 'Fai lo screenshot della stanza', function () + { + api.takeScreenshot(); + }, { fullWidth: true, extraStyle: 'padding:6px 12px;' }); + content.appendChild(ssDiv); + + // ─── Avatar Section ─── + createSectionLabel(content, 'Avatar'); + + createButtonRow(content, 2, [ + { + label: state.frozen ? '\u2713 Avatar bloccato' : 'Blocca avatar', + active: state.frozen, + onClick: function (btn) + { + state.frozen = !state.frozen; + btn.textContent = state.frozen ? '\u2713 Avatar bloccato' : 'Blocca avatar'; + btn.style.background = state.frozen ? THEME.btnActive : THEME.btnPrimary; + btn.style.borderColor = state.frozen ? THEME.btnActiveBrd : THEME.btnPrimaryBrd; + sendCommand(api, 'blocca'); + } + }, + { + label: state.teleporting ? '\u2713 Teletrasporto ON' : 'Teletrasporto', + active: state.teleporting, + onClick: function (btn) + { + state.teleporting = !state.teleporting; + btn.textContent = state.teleporting ? '\u2713 Teletrasporto ON' : 'Teletrasporto'; + btn.style.background = state.teleporting ? THEME.btnActive : THEME.btnPrimary; + btn.style.borderColor = state.teleporting ? THEME.btnActiveBrd : THEME.btnPrimaryBrd; + sendCommand(api, 'rb_teleport'); + } + } + ]); + + // ─── Visibility Section ─── + createSectionLabel(content, 'Visibilita'); + + createButtonRow(content, 3, [ + { + label: state.hidePyramids ? 'Mostra piramidi' : 'Nascondi piramidi', + active: state.hidePyramids, + onClick: function (btn) + { + state.hidePyramids = !state.hidePyramids; + btn.style.background = state.hidePyramids ? THEME.btnActive : THEME.btnPrimary; + btn.style.borderColor = state.hidePyramids ? THEME.btnActiveBrd : THEME.btnPrimaryBrd; + btn.textContent = state.hidePyramids ? 'Mostra piramidi' : 'Nascondi piramidi'; + try + { + forEachFloorObject(api, function (obj, objId, roomId, engine) + { + if (obj.type && obj.type.toLowerCase().indexOf('pyramid') >= 0) + { + engine.changeObjectModelData(roomId, objId, FLOOR, 'furniture_alpha_multiplier', state.hidePyramids ? 0 : 1); + } + }); + } + catch (e) { console.warn('[RoomBuilder]', e); } + } + }, + { + label: state.hideCarpets ? 'Mostra tappeti' : 'Nascondi tappeti', + active: state.hideCarpets, + onClick: function (btn) + { + state.hideCarpets = !state.hideCarpets; + btn.style.background = state.hideCarpets ? THEME.btnActive : THEME.btnPrimary; + btn.style.borderColor = state.hideCarpets ? THEME.btnActiveBrd : THEME.btnPrimaryBrd; + btn.textContent = state.hideCarpets ? 'Mostra tappeti' : 'Nascondi tappeti'; + try + { + forEachFloorObject(api, function (obj, objId, roomId, engine) + { + if (obj.model) + { + var sizeZ = obj.model.getValue('furniture_size_z'); + if (sizeZ !== undefined && sizeZ <= 0.01) + { + engine.changeObjectModelData(roomId, objId, FLOOR, 'furniture_alpha_multiplier', state.hideCarpets ? 0 : 1); + } + } + }); + } + catch (e) { console.warn('[RoomBuilder]', e); } + } + }, + { + label: state.hideWalls ? 'Mostra mura' : 'Nascondi mura', + active: state.hideWalls, + onClick: function (btn) + { + state.hideWalls = !state.hideWalls; + btn.style.background = state.hideWalls ? THEME.btnActive : THEME.btnPrimary; + btn.style.borderColor = state.hideWalls ? THEME.btnActiveBrd : THEME.btnPrimaryBrd; + btn.textContent = state.hideWalls ? 'Mostra mura' : 'Nascondi mura'; + try + { + var session = api.getRoomSession(); + var engine = api.getRoomEngine(); + if (!session || !engine) return; + var objects = engine.getRoomObjects(session.roomId, WALL); + for (var i = 0; i < objects.length; i++) + { + engine.changeObjectModelData(session.roomId, objects[i].id, WALL, 'furniture_alpha_multiplier', state.hideWalls ? 0 : 1); + } + } + catch (e) { console.warn('[RoomBuilder]', e); } + } + } + ]); + + // ─── Stack Section ─── + createSectionLabel(content, 'Pila (Stack)'); + + createButtonRow(content, 4, [ + { + label: 'Annulla pila', + onClick: function () + { + sendCommand(api, 'autotile'); + } + }, + { + label: 'Seleziona pila', + onClick: function () + { + try + { + var session = api.getRoomSession(); + var engine = api.getRoomEngine(); + if (!session || !engine) return; + var objects = engine.getRoomObjects(session.roomId, FLOOR); + stackClipboard = []; + for (var i = 0; i < objects.length; i++) + { + var obj = engine.getRoomObject(session.roomId, objects[i].id, FLOOR); + if (obj && obj.location) + { + stackClipboard.push({ + id: objects[i].id, + x: Math.floor(obj.location.x), + y: Math.floor(obj.location.y), + z: obj.location.z, + dir: obj.direction ? obj.direction.x : 0 + }); + } + } + console.log('[RoomBuilder] Pila selezionata: ' + stackClipboard.length + ' oggetti'); + } + catch (e) { console.warn('[RoomBuilder]', e); } + } + }, + { + label: 'Copia pila', + onClick: function () + { + if (!stackClipboard || stackClipboard.length === 0) + { + console.log('[RoomBuilder] Nessuna pila selezionata'); + return; + } + console.log('[RoomBuilder] Pila copiata: ' + stackClipboard.length + ' oggetti'); + } + }, + { + label: 'Posiziona pila', + onClick: function () + { + if (!stackClipboard || stackClipboard.length === 0) + { + console.log('[RoomBuilder] Nessuna pila da posizionare'); + return; + } + try + { + for (var i = 0; i < stackClipboard.length; i++) + { + var item = stackClipboard[i]; + api.sendStackHeight(item.id, Math.round(item.z * 100)); + } + console.log('[RoomBuilder] Pila posizionata: ' + stackClipboard.length + ' oggetti'); + } + catch (e) { console.warn('[RoomBuilder]', e); } + } + } + ]); + + // ─── Room Management Section ─── + createSectionLabel(content, 'Gestione stanza'); + + createButtonRow(content, 3, [ + { + label: 'Impostazioni', + onClick: function () { api.createLinkEvent('navigator/toggle-room-info'); } + }, + { + label: 'Reload stanza', + onClick: function () + { + try + { + var session = api.getRoomSession(); + if (session) api.createLinkEvent('navigator/goto/' + session.roomId); + } + catch (e) { } + } + }, + { + label: 'Unload stanza', + onClick: function () + { + api.visitDesktop(); + } + } + ]); + + // ─── Floor Tools Section ─── + createSectionLabel(content, 'Strumenti pavimento'); + + createButtonRow(content, 4, [ + { + label: 'Max Tile', + onClick: function () + { + sendCommand(api, 'maxtile'); + } + }, + { + label: 'Auto Tile', + onClick: function () + { + sendCommand(api, 'autotile'); + } + }, + { + label: 'No Item Floor', + danger: true, + onClick: function () + { + if (confirm('Sei sicuro? Tutti i furni verranno rimossi dal pavimento!')) + { + sendCommand(api, 'noitemfloor'); + } + } + }, + { + label: 'Edit Floorplan', + onClick: function () { api.createLinkEvent('floor-editor/toggle'); } + } + ]); + + // ─── Wired Section ─── + createSectionLabel(content, 'Wired'); + + createButtonRow(content, 2, [ + { + label: state.hideWired ? 'Mostra wired' : 'Nascondi wired', + active: state.hideWired, + onClick: function (btn) + { + state.hideWired = !state.hideWired; + btn.style.background = state.hideWired ? THEME.btnActive : THEME.btnPrimary; + btn.style.borderColor = state.hideWired ? THEME.btnActiveBrd : THEME.btnPrimaryBrd; + btn.textContent = state.hideWired ? 'Mostra wired' : 'Nascondi wired'; + try + { + forEachFloorObject(api, function (obj, objId, roomId, engine) + { + if (obj.type && (obj.type.toLowerCase().indexOf('wf_') >= 0 || obj.type.toLowerCase().indexOf('wired') >= 0)) + { + engine.changeObjectModelData(roomId, objId, FLOOR, 'furniture_alpha_multiplier', state.hideWired ? 0 : 1); + } + }); + } + catch (e) { console.warn('[RoomBuilder]', e); } + } + }, + { + label: 'Prendi tutti gli wired', + danger: true, + onClick: function () + { + sendCommand(api, 'pickwired'); + } + } + ]); + + // ─── Spacer ─── + var spacer = document.createElement('div'); + spacer.style.cssText = 'height:1px;background:' + THEME.gridBorder + ';margin:10px 0'; + content.appendChild(spacer); + + // ─── Back button (danger style like close button) ─── + var backDiv = document.createElement('div'); + createButton(backDiv, 'Torna indietro', function () + { + api.destroyWindow('room-builder'); + }, { fullWidth: true, danger: true, extraStyle: 'padding:6px 12px;' }); + content.appendChild(backDiv); + }, + + onClose: function () + { + if (window.NitroPlugins) window.NitroPlugins.destroyWindow('room-builder'); + } + }); + + console.log('[NitroPlugins] Room Builder plugin loaded (Nitro theme)'); + }); +})(); diff --git a/public/ui-config.json b/public/ui-config.json index 55dbad6..9692188 100644 --- a/public/ui-config.json +++ b/public/ui-config.json @@ -1,4 +1,7 @@ { + "external.plugins": [ + "plugins/room-builder.js" + ], "image.library.notifications.url": "${image.library.url}notifications/%image%.png", "achievements.images.url": "${image.library.url}Quests/%image%.png", "camera.url": "/swf/usercontent/camera/", @@ -10,7 +13,8 @@ "chat.viewer.height.percentage": 0.4, "widget.dimmer.colorwheel": false, "avatar.wardrobe.max.slots": 10, - "user.badges.max.slots": 5, + "user.badges.max.slots": 6, + "user.badges.group.slot.enabled": false, "user.tags.enabled": false, "camera.publish.disabled": false, "hc.disabled": false, diff --git a/src/components/inventory/views/badge/InventoryBadgeItemView.tsx b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx index 4553621..4bf666b 100644 --- a/src/components/inventory/views/badge/InventoryBadgeItemView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx @@ -11,8 +11,22 @@ export const InventoryBadgeItemView: FC const { isUnseen = null } = useInventoryUnseenTracker(); const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode)); + const onDragStart = (event: React.DragEvent) => + { + event.dataTransfer.setData('badgeCode', badgeCode); + event.dataTransfer.setData('source', 'inventory'); + event.dataTransfer.effectAllowed = 'move'; + }; + return ( - toggleBadge(selectedBadgeCode) } onMouseDown={ event => setSelectedBadgeCode(badgeCode) } { ...rest }> + toggleBadge(selectedBadgeCode) } + onDragStart={ onDragStart } + onMouseDown={ event => setSelectedBadgeCode(badgeCode) } + { ...rest }> { children } diff --git a/src/components/inventory/views/badge/InventoryBadgeView.tsx b/src/components/inventory/views/badge/InventoryBadgeView.tsx index 8a60522..1bf6d13 100644 --- a/src/components/inventory/views/badge/InventoryBadgeView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeView.tsx @@ -1,5 +1,5 @@ import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api'; import { LayoutBadgeImageView } from '../../../../common'; @@ -7,14 +7,74 @@ import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from ' import { InfiniteGrid, NitroButton } from '../../../../layout'; import { InventoryBadgeItemView } from './InventoryBadgeItemView'; +const ActiveBadgeSlot: FC<{ + slotIndex: number; + badgeCode?: string; + onDropBadge: (badgeCode: string, slotIndex: number, sourceSlot?: number) => void; + onRemoveBadge: (badgeCode: string) => void; + onDragStartFromSlot: (event: React.DragEvent, badgeCode: string, slotIndex: number) => void; + onSelectBadge: (badgeCode: string) => void; + isSelected: boolean; +}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) => +{ + const [ isDragOver, setIsDragOver ] = useState(false); + + const onDragOver = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }, []); + + const onDragLeave = useCallback(() => setIsDragOver(false), []); + + const onDrop = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + setIsDragOver(false); + + const droppedBadgeCode = event.dataTransfer.getData('badgeCode'); + const sourceSlotStr = event.dataTransfer.getData('activeSlot'); + const sourceSlot = sourceSlotStr ? parseInt(sourceSlotStr) : undefined; + + if(droppedBadgeCode) onDropBadge(droppedBadgeCode, slotIndex, sourceSlot); + }, [ slotIndex, onDropBadge ]); + + const onDragStart = useCallback((event: React.DragEvent) => + { + if(!badgeCode) return; + onDragStartFromSlot(event, badgeCode, slotIndex); + }, [ badgeCode, slotIndex, onDragStartFromSlot ]); + + return ( +
badgeCode && onSelectBadge(badgeCode) }> + { badgeCode + ? + : { slotIndex + 1 } } +
+ ); +}; + export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props => { const { filteredBadgeCodes = null } = props; const [ isVisible, setIsVisible ] = useState(false); - const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, activate = null, deactivate = null } = useInventoryBadges(); + const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, setBadgeAtSlot = null, removeBadge = null, reorderBadges = null, setSelectedBadgeCode = null, activate = null, deactivate = null } = useInventoryBadges(); const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker(); const { showConfirm = null } = useNotification(); + const [ isDragOverInventory, setIsDragOverInventory ] = useState(false); + const maxSlots = 5; const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes); const attemptDeleteBadge = () => @@ -31,6 +91,58 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = ); }; + const handleDropOnSlot = useCallback((badgeCode: string, slotIndex: number, sourceSlot?: number) => + { + if(sourceSlot !== undefined) + { + // Reorder within active badges + reorderBadges(sourceSlot, slotIndex); + } + else + { + // Drop from inventory to active slot + setBadgeAtSlot(badgeCode, slotIndex); + } + }, [ setBadgeAtSlot, reorderBadges ]); + + const handleDragStartFromSlot = useCallback((event: React.DragEvent, badgeCode: string, slotIndex: number) => + { + event.dataTransfer.setData('badgeCode', badgeCode); + event.dataTransfer.setData('activeSlot', slotIndex.toString()); + event.dataTransfer.setData('source', 'active'); + event.dataTransfer.effectAllowed = 'move'; + }, []); + + const handleRemoveBadge = useCallback((badgeCode: string) => + { + removeBadge(badgeCode); + }, [ removeBadge ]); + + // Handle drop on inventory area (remove from active) + const onInventoryDragOver = useCallback((event: React.DragEvent) => + { + const source = event.dataTransfer.types.includes('activeslot') ? 'active' : ''; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setIsDragOverInventory(true); + }, []); + + const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []); + + const onInventoryDrop = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + setIsDragOverInventory(false); + + const badgeCode = event.dataTransfer.getData('badgeCode'); + const source = event.dataTransfer.getData('source'); + + if(source === 'active' && badgeCode) + { + removeBadge(badgeCode); + } + }, [ removeBadge ]); + useEffect(() => { if(!selectedBadgeCode || !isUnseen(UnseenItemCategory.BADGE, getBadgeId(selectedBadgeCode))) return; @@ -56,7 +168,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = return (
-
+
columnCount={ 5 } estimateSize={ 50 } @@ -66,11 +182,20 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
{ LocalizeText('inventory.badges.activebadges') } - - columnCount={ 3 } - estimateSize={ 50 } - itemRender={ item => } - items={ activeBadgeCodes } /> +
+ { Array.from({ length: maxSlots }).map((_, index) => ( + + )) } +
{ !!selectedBadgeCode &&
diff --git a/src/components/plugins/NitroPluginApi.ts b/src/components/plugins/NitroPluginApi.ts index d52ffa0..5fbbf8b 100644 --- a/src/components/plugins/NitroPluginApi.ts +++ b/src/components/plugins/NitroPluginApi.ts @@ -1,5 +1,5 @@ -import { GetRoomEngine } from '@nitrots/nitro-renderer'; -import { CreateLinkEvent, GetRoomSession, SendMessageComposer } from '../../api'; +import { FurnitureStackHeightComposer, GetRoomEngine, TextureUtils } from '@nitrots/nitro-renderer'; +import { CreateLinkEvent, GetRoomSession, SendMessageComposer, VisitDesktop } from '../../api'; /** * Plugin descriptor registered by external plugin scripts. @@ -39,6 +39,14 @@ export interface INitroPluginApi getRoomSession: () => ReturnType; /** Send a message composer to the server */ sendMessage: typeof SendMessageComposer; + /** Send a chat message to the server (processed as command if starts with ':') */ + sendChat: (text: string, styleId?: number) => void; + /** Send stack height update for a furniture item (objectId, heightInCentimeters) */ + sendStackHeight: (objectId: number, height: number) => void; + /** Take a screenshot of the room and download it as PNG */ + takeScreenshot: () => Promise; + /** Leave the room and go to hotel view */ + visitDesktop: () => void; /** Create a draggable floating window and return its container element */ createWindow: (id: string, title: string, width: number) => HTMLDivElement | null; /** Destroy a floating window by id */ @@ -96,6 +104,50 @@ const pluginApi: INitroPluginApi = { sendMessage: SendMessageComposer, + sendChat(text: string, styleId: number = 0) + { + const session = GetRoomSession(); + if (!session) return; + session.sendChatMessage(text, styleId, ''); + }, + + sendStackHeight(objectId: number, height: number) + { + SendMessageComposer(new FurnitureStackHeightComposer(objectId, height)); + }, + + async takeScreenshot() + { + try + { + const session = GetRoomSession(); + if (!session) return; + + const texture = GetRoomEngine().createTextureFromRoom(session.roomId, 1); + if (!texture) return; + + const imageUrl = await TextureUtils.generateImageUrl(texture); + if (!imageUrl) return; + + // Download the image + const link = document.createElement('a'); + link.href = imageUrl; + link.download = `room_${session.roomId}_${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + catch (e) + { + console.warn('[NitroPlugins] Screenshot failed:', e); + } + }, + + visitDesktop() + { + VisitDesktop(); + }, + createWindow(id: string, title: string, width: number): HTMLDivElement | null { // Remove existing window with same id diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx new file mode 100644 index 0000000..05c54d4 --- /dev/null +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -0,0 +1,172 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { FaPlus } from 'react-icons/fa'; +import { LayoutBadgeImageView } from '../../../../../common'; +import { useInventoryBadges } from '../../../../../hooks'; + +interface InfoStandBadgeSlotProps +{ + slotIndex: number; + badgeCode?: string; + isOwnUser: boolean; +} + +const BadgeMiniPicker: FC<{ + onSelect: (badgeCode: string) => void; + onClose: () => void; + activeBadgeCodes: string[]; +}> = ({ onSelect, onClose, activeBadgeCodes }) => +{ + const { badgeCodes = [], requestBadges = null } = useInventoryBadges(); + const ref = useRef(null); + const [ search, setSearch ] = useState(''); + + useEffect(() => + { + if(badgeCodes.length === 0) requestBadges(); + }, []); + + const availableBadges = badgeCodes.filter(code => !activeBadgeCodes.includes(code)); + const filtered = search.length > 0 + ? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase())) + : availableBadges; + + useEffect(() => + { + const handleClickOutside = (event: MouseEvent) => + { + if(ref.current && !ref.current.contains(event.target as Node)) onClose(); + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [ onClose ]); + + return ( +
e.stopPropagation() }> + setSearch(e.target.value) } + /> + { badgeCodes.length === 0 + ? Caricamento... + : ( +
+ { filtered.slice(0, 40).map(code => ( +
onSelect(code) }> + +
+ )) } + { filtered.length === 0 && ( + Nessun badge + ) } +
+ ) } +
+ ); +}; + +export const InfoStandBadgeSlotView: FC = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) => +{ + const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges(); + const [ isDragOver, setIsDragOver ] = useState(false); + const [ showPicker, setShowPicker ] = useState(false); + + // For own user, use activeBadgeCodes from the hook (updates immediately on drag/drop) + // For other users, use the badge code from props (from server via avatarInfo) + const badgeCode = isOwnUser ? (activeBadgeCodes[slotIndex] ?? null) : badgeCodeFromProps; + + const onDragStart = useCallback((event: React.DragEvent) => + { + if(!badgeCode || !isOwnUser) return; + event.dataTransfer.setData('badgeCode', badgeCode); + event.dataTransfer.setData('infostandSlot', slotIndex.toString()); + event.dataTransfer.effectAllowed = 'move'; + }, [ badgeCode, slotIndex, isOwnUser ]); + + const onDragOver = useCallback((event: React.DragEvent) => + { + if(!isOwnUser) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }, [ isOwnUser ]); + + const onDragLeave = useCallback(() => setIsDragOver(false), []); + + const onDrop = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + setIsDragOver(false); + if(!isOwnUser) return; + + const droppedBadgeCode = event.dataTransfer.getData('badgeCode'); + const sourceSlotStr = event.dataTransfer.getData('infostandSlot'); + + if(!droppedBadgeCode) return; + + if(sourceSlotStr !== '') + { + // Dragged from another infostand slot -> always swap (works with empty slots too) + const sourceSlot = parseInt(sourceSlotStr); + + if(sourceSlot !== slotIndex) swapBadges(sourceSlot, slotIndex); + } + else + { + // Dragged from inventory or external -> place at this slot + setBadgeAtSlot(droppedBadgeCode, slotIndex); + } + }, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]); + + const handleSlotClick = useCallback(() => + { + if(!isOwnUser || badgeCode) return; + + setShowPicker(true); + }, [ isOwnUser, badgeCode ]); + + const handlePickerSelect = useCallback((code: string) => + { + setBadgeAtSlot(code, slotIndex); + setShowPicker(false); + }, [ setBadgeAtSlot, slotIndex ]); + + return ( +
+
+ { badgeCode + ? + : isOwnUser && } +
+ { showPicker && ( + setShowPicker(false) } + onSelect={ handlePickerSelect } + /> + ) } +
+ ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index a53e35c..1791979 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -4,6 +4,7 @@ import { FaPencilAlt, FaTimes } from 'react-icons/fa'; import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common'; import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks'; +import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView'; import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView'; import { InfoStandWidgetUserTagsView } from './InfoStandWidgetUserTagsView'; import { BackgroundsView } from '../../../../backgrounds/BackgroundsView'; @@ -158,31 +159,43 @@ export const InfoStandWidgetUserView: FC = props = /> )} -
-
- {avatarInfo.badges[0] && } -
- 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> - {avatarInfo.groupId > 0 && - } - -
- -
- {avatarInfo.badges[1] && } -
-
- {avatarInfo.badges[2] && } -
-
- -
- {avatarInfo.badges[3] && } -
-
- {avatarInfo.badges[4] && } -
-
+ { GetConfigurationValue('user.badges.group.slot.enabled', true) + ? ( + <> +
+ + 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> + {avatarInfo.groupId > 0 && + } + +
+ + + + + + + + + + ) + : ( + <> + + + + + + + + + + + + + + ) + }

diff --git a/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx b/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx index 19d382a..5105cf5 100644 --- a/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx +++ b/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx @@ -5,6 +5,7 @@ import { FC, useEffect, useState } from 'react'; import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api'; import { Text } from '../../../../common'; import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks'; +import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi'; export const RoomToolsWidgetView: FC<{}> = props => { const [areBubblesMuted, setAreBubblesMuted] = useState(false); @@ -15,12 +16,20 @@ export const RoomToolsWidgetView: FC<{}> = props => { const [isOpen, setIsOpen] = useState(false); const [isOpenHistory, setIsOpenHistory] = useState(false); const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]); + const [plugins, setPlugins] = useState([]); const { navigatorData = null } = useNavigator(); const { roomSession = null } = useRoom(); + // Subscribe to external plugin changes + useEffect(() => + { + setPlugins(getRegisteredPlugins()); + return subscribePlugins(() => setPlugins(getRegisteredPlugins())); + }, []); + const handleToolClick = (action: string, value?: string) => { if (!roomSession) return; - + switch (action) { case 'settings': CreateLinkEvent('navigator/toggle-room-info'); @@ -114,12 +123,20 @@ export const RoomToolsWidgetView: FC<{}> = props => {
handleToolClick('zoom')} />
handleToolClick('chat_history')} />
handleToolClick('hiddenbubbles')} /> - + {navigatorData.canRate && (
handleToolClick('like_room')} /> )}
handleToolClick('toggle_room_link')} />
handleToolClick('room_history')} /> + {plugins.map(plugin => ( +
plugin.onOpen()} + /> + ))}
@@ -159,4 +176,4 @@ export const RoomToolsWidgetView: FC<{}> = props => {
); -}; \ No newline at end of file +}; diff --git a/src/hooks/inventory/useInventoryBadges.ts b/src/hooks/inventory/useInventoryBadges.ts index aebe155..39e0667 100644 --- a/src/hooks/inventory/useInventoryBadges.ts +++ b/src/hooks/inventory/useInventoryBadges.ts @@ -1,5 +1,5 @@ import { BadgeReceivedEvent, BadgesEvent, RequestBadgesComposer, SetActivatedBadgesComposer } from '@nitrots/nitro-renderer'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useBetween } from 'use-between'; import { GetConfigurationValue, SendMessageComposer, UnseenItemCategory } from '../../api'; import { useMessageEvent } from '../events'; @@ -17,9 +17,18 @@ const useInventoryBadgesState = () => const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker(); const maxBadgeCount = GetConfigurationValue('user.badges.max.slots', 5); + const localChangeRef = useRef(false); const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0); const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount); + const sendActiveBadges = (badges: string[]) => + { + localChangeRef.current = true; + const composer = new SetActivatedBadgesComposer(); + for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? ''); + SendMessageComposer(composer); + }; + const toggleBadge = (badgeCode: string) => { setActiveBadgeCodes(prevValue => @@ -30,7 +39,7 @@ const useInventoryBadgesState = () => if(index === -1) { - if(!canWearBadges()) return prevValue; + if(newValue.length >= maxBadgeCount) return prevValue; newValue.push(badgeCode); } @@ -39,11 +48,7 @@ const useInventoryBadgesState = () => newValue.splice(index, 1); } - const composer = new SetActivatedBadgesComposer(); - - for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(newValue[i] ?? ''); - - SendMessageComposer(composer); + sendActiveBadges(newValue); return newValue; }); @@ -77,7 +82,16 @@ const useInventoryBadgesState = () => return newValue; }); - setActiveBadgeCodes(parser.getActiveBadgeCodes()); + // Skip overwriting activeBadgeCodes if we recently made a local change + if(localChangeRef.current) + { + localChangeRef.current = false; + } + else + { + setActiveBadgeCodes(parser.getActiveBadgeCodes()); + } + setBadgeCodes(allBadgeCodes); }); @@ -141,7 +155,83 @@ const useInventoryBadgesState = () => setNeedsUpdate(false); }, [ isVisible, needsUpdate ]); - return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, activate, deactivate }; + const setBadgeAtSlot = (badgeCode: string, slotIndex: number) => + { + setActiveBadgeCodes(prevValue => + { + // Build a fixed-size array of maxBadgeCount slots + const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); + + // Remove badge if already in another slot + const existingIndex = slots.indexOf(badgeCode); + if(existingIndex >= 0) slots[existingIndex] = null; + + // Place badge at target slot + slots[slotIndex] = badgeCode; + + // Compact: remove nulls, keep order + const result = slots.filter(Boolean) as string[]; + + sendActiveBadges(result); + return result; + }); + }; + + const removeBadge = (badgeCode: string) => + { + setActiveBadgeCodes(prevValue => + { + const result = prevValue.filter(code => code !== badgeCode); + + sendActiveBadges(result); + return result; + }); + }; + + const reorderBadges = (fromIndex: number, toIndex: number) => + { + setActiveBadgeCodes(prevValue => + { + if(fromIndex === toIndex) return prevValue; + if(fromIndex >= prevValue.length) return prevValue; + + const newValue = [ ...prevValue ]; + const [ moved ] = newValue.splice(fromIndex, 1); + newValue.splice(toIndex, 0, moved); + + sendActiveBadges(newValue); + return newValue; + }); + }; + + const swapBadges = (fromIndex: number, toIndex: number) => + { + setActiveBadgeCodes(prevValue => + { + if(fromIndex === toIndex) return prevValue; + + // Build fixed-size array so swap works even with empty slots + const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); + + // Swap the two slots + const temp = slots[fromIndex]; + slots[fromIndex] = slots[toIndex]; + slots[toIndex] = temp; + + // Compact: remove nulls, keep order + const result = slots.filter(Boolean) as string[]; + + sendActiveBadges(result); + return result; + }); + }; + + const requestBadges = () => + { + SendMessageComposer(new RequestBadgesComposer()); + }; + + return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, setBadgeAtSlot, removeBadge, reorderBadges, swapBadges, requestBadges, activate, deactivate }; }; export const useInventoryBadges = () => useBetween(useInventoryBadgesState); diff --git a/src/hooks/rooms/widgets/useChatInputWidget.ts b/src/hooks/rooms/widgets/useChatInputWidget.ts index 3f32d3f..b21efab 100644 --- a/src/hooks/rooms/widgets/useChatInputWidget.ts +++ b/src/hooks/rooms/widgets/useChatInputWidget.ts @@ -116,12 +116,22 @@ const useChatInputWidgetState = () => (async () => { - const image = new Image(); + try + { + const imageUrl = await TextureUtils.generateImageUrl(texture); + if (!imageUrl) return; - image.src = await TextureUtils.generateImageUrl(texture); - - const newWindow = window.open(''); - newWindow.document.write(image.outerHTML); + const link = document.createElement('a'); + link.href = imageUrl; + link.download = `room_${ roomSession.roomId }_${ Date.now() }.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + catch (e) + { + console.warn('[Screenshot] Failed:', e); + } })(); return null; case ':pickall':