diff --git a/2kki.js b/2kki.js index 00269df8..39b0adae 100644 --- a/2kki.js +++ b/2kki.js @@ -170,8 +170,14 @@ function queryAndSet2kkiLocation(mapId, prevMapId, prevLocations, setLocationFun } function set2kkiClientLocation(mapId, prevMapId, locations, prevLocations, cacheLocation, saveLocation) { - document.getElementById('locationText').innerHTML = getLocalized2kkiLocationsHtml(locations, '
'); - document.documentElement.style.setProperty('--location-width', `${document.querySelector('#locationText > *').offsetWidth}px`); + const localizedLocationsHtml = getLocalized2kkiLocationsHtml(locations, '
'); + fastdom.mutate(() => document.getElementById('locationText').innerHTML = localizedLocationsHtml); + + fastdom.measure(() => { + const width = `${document.querySelector('#locationText > *').offsetWidth}px`; + fastdom.mutate(() => document.getElementById('nextLocationContainer').style.setProperty('--location-width', width)); + }); + onUpdateChatboxInfo(); preloadFilesFromMapId(mapId); if (cacheLocation) { @@ -545,18 +551,6 @@ function getDepthRgba(depth, maxDepth) { return depthColors[Math.min(depth, maxDepth)]; } -function init2kkiFileVersionAppend() { - if (!gameVersion) - return; - const ca = wasmImports.ca; - wasmImports.ca = function (url, file, request, param, arg, onload, onerror, onprogress) { - let _url = UTF8ToString(url); - if (_url) - url = stringToNewUTF8(`${_url}${_url.indexOf('?') > -1 ? '&' : '?'}v=${gameVersion}`); - ca(url, file, request, param, arg, onload, onerror, onprogress); - }; -} - function checkShow2kkiVersionUpdate() { return new Promise(resolve => { const chatboxContainer = document.getElementById('chatboxContainer'); diff --git a/badges.js b/badges.js index bde9fb2f..b1f22690 100644 --- a/badges.js +++ b/badges.js @@ -1,12 +1,15 @@ /** @typedef {Object} Badge @property {string} badgeId - @property {string} game - @property {string} group + @property {string} [game] + @property {string} [group] @property {number} [mapX] available when full=true @property {number} [mapY] available when full=true @property {string[]} [tags] available when full=true - @property {boolean} newUnlock + @property {boolean} [newUnlock] + @property {number} [overlayType] + @property {boolean} [unlocked] + @property {boolean} [secret] Either SimpleBadge or Badge Cross-check with badges.go in ynoserver */ @@ -30,17 +33,18 @@ let badgeTabGame = gameId; let badgeTabGroup; let localizedBadgeGroups; +/** @type Record> */ let localizedBadges; let localizedBadgesIgnoreUpdateTimer = null; let badgeGameIds = []; -const assignTooltipCallbacks = new WeakMap; +const didObserveBadgeCallbacks = new WeakMap; const badgeGalleryModalContent = document.querySelector('#badgeGalleryModal .modalContent'); /** @type {IntersectionObserver?} */ -let observer; +let badgeObserver; let newUnlockBadges = new Set; @@ -132,7 +136,7 @@ function yieldImmediately() { return new Promise(resolve => setTimeout(resolve, 0)); } -async function fetchAndUpdateBadgeModalBadges(slotRow, slotCol, searchMode) { } +let fetchAndUpdateBadgeModalBadges = async (slotRow, slotCol, searchMode) => {}; function initBadgeControls() { const badgeModalContent = document.querySelector('#badgesModal .modalContent'); @@ -211,23 +215,23 @@ function initBadgeControls() { gameBadges = {}; const spacePattern = / /g; let badgeCount = 0; - observer?.disconnect(); - observer = new IntersectionObserver(observed => { + badgeObserver?.disconnect(); + badgeObserver = new IntersectionObserver(observed => { for (const { target } of observed) { if (!target.parentElement) continue; - const assignTooltip = assignTooltipCallbacks.get(target); - if (assignTooltip) { - observer.unobserve(target); - assignTooltipCallbacks.delete(target); - window.requestAnimationFrame(() => assignTooltip()); + const didObserveBadge = didObserveBadgeCallbacks.get(target); + if (didObserveBadge) { + badgeObserver.unobserve(target); + didObserveBadgeCallbacks.delete(target); + didObserveBadge(); } } }, { root: badgeModalContent }); let systemName; for (const badge of playerBadges) { // yield back to the game loop to prevent audio cracking - if (badgeCount++ % 40 === 0) await yieldImmediately(); + if (badgeCount++ % 350 === 0) await yieldImmediately(); if (!gameBadges[badge.game]) { if (badge.game !== 'ynoproject') { @@ -242,8 +246,8 @@ function initBadgeControls() { if (!gameBadges[badge.game][badge.group]) gameBadges[badge.game][badge.group] = []; - const item = getBadgeItem(badge, 'lazy', true, true, true, true, systemName); - observer.observe(item); + const item = getBadgeItem(badge, true, true, true, true, true, systemName, true); + badgeObserver.observe(item); if (badge.badgeId === (playerData?.badge || 'null')) item.children[0].classList.add('selected'); if (!item.classList.contains('disabled')) { @@ -289,7 +293,7 @@ function initBadgeControls() { tab.appendChild(tabLabel); tab.onclick = () => { - if (badgeGameTabs === 'all') return; + if (badgeTabGame === 'all') return; badgeGameTabs.querySelector('.active')?.classList.remove('active'); tab.classList.add('active'); @@ -393,57 +397,60 @@ function initBadgeControls() { if ('all' === badgeTabGroup) return; - window.requestAnimationFrame(() => { + fastdom.mutate(() => { badgeCategoryTabs.querySelector('.active')?.classList.remove('active'); subTab.classList.add('active'); + }); - const game = tab.dataset.game; - if (game !== 'ynoproject') { - const systemName = getDefaultUiTheme(game).replace(spacePattern, '_'); - applyThemeStyles(nullBadge, systemName, game); - } else { - for (const cls of nullBadge.classList) - if (cls.startsWith('theme_')) - nullBadge.classList.remove(cls); - } + const game = tab.dataset.game; + if (game !== 'ynoproject') { + const systemName = getDefaultUiTheme(game).replace(spacePattern, '_'); + applyThemeStyles(nullBadge, systemName, game); + } else { + for (const cls of nullBadge.classList) + if (cls.startsWith('theme_')) + nullBadge.classList.remove(cls); + } + fastdom.mutate(() => { badgeModalContent.replaceChildren(nullBadge); for (const group in gameBadges[game]) badgeModalContent.append(...gameBadges[game][group]); - badgeTabGame = game; - badgeTabGroup = 'all'; }); + badgeTabGame = game; + badgeTabGroup = 'all'; }; } - badgeCategoryTabs.replaceChildren(...subTabs); - if (badgeCategoryTabs.querySelector('[data-i18n]')) { - // accommodates translated tooltips in the format of - locI18next.init(i18next, { ...locI18nextOptions, document: badgeCategoryTabs })('[data-i18n]'); - for (const elm of badgeCategoryTabs.querySelectorAll('[title]')) { - addTooltip(elm, elm.title, true, !elm.classList.contains('helpLink')); - elm.removeAttribute('title'); + fastdom.mutate(() => badgeCategoryTabs.replaceChildren(...subTabs)).then(() => { + if (badgeCategoryTabs.querySelector('[data-i18n]')) { + // accommodates translated tooltips in the format of + locI18next.init(i18next, { ...locI18nextOptions, document: badgeCategoryTabs })('[data-i18n]'); + for (const elm of badgeCategoryTabs.querySelectorAll('[title]')) { + addTooltip(elm, elm.title, true, !elm.classList.contains('helpLink')); + elm.removeAttribute('title'); + } } - } - - let activeSubTab; - if (activeSubTab = subTabs.find(tab => tab.classList.contains('active'))) { - badgeTabGroup = null; - activeSubTab.click(); - } else { - updateBadges(game, badgeTabGroup); - } + let activeSubTab; + if (activeSubTab = subTabs.find(tab => tab.classList.contains('active'))) { + badgeTabGroup = null; + activeSubTab.click(); + } else { + updateBadges(game, badgeTabGroup); + } + }); }; // tab.onclick } - badgeGameTabs.replaceChildren(...tabs); - didUpdateBadgeModal = async () => { + let task = fastdom.mutate(() => badgeGameTabs.replaceChildren(...tabs)); + didUpdateBadgeModal = async (prom) => { + await prom; let activeTab; if (activeTab = tabs.find(tab => tab.classList.contains('active'))) { badgeTabGame = null; // temporarily set to null to populate subtabs activeTab.click(); await updateBadgeVisibility(); if (badgeModalContent.dataset.lastScrollTop) - badgeModalContent.scrollTo(0, +badgeModalContent.dataset.lastScrollTop); + fastdom.mutate(() => badgeModalContent.scrollTo(0, +badgeModalContent.dataset.lastScrollTop)); } else await updateBadgeVisibility(); removeLoader(document.getElementById('badgesModal')); @@ -451,7 +458,7 @@ function initBadgeControls() { if (userSelectedSortOrder) updateBadgeModalOnly(); else - await didUpdateBadgeModal(); + await didUpdateBadgeModal(task); }; /** Updates badge elements based on a `cacheIndex` assigned to them in {@linkcode getBadgeItem} */ @@ -459,13 +466,15 @@ function initBadgeControls() { const cacheIndexes = Array.from({ length: badgeFilterCache.length }, (_, i) => i); cacheIndexes.sort((a, z) => badgeCompareFunc(badgeFilterCache[a], badgeFilterCache[z])); setTimeout(async () => { - for (let idx = 0; idx < cacheIndexes.length; idx++) - badgeFilterCache[cacheIndexes[idx]].el.style.order = idx; - if (!badgeModalContent.childElementCount) - for (const game in gameBadges) - for (const group in gameBadges[game]) - badgeModalContent.append(...gameBadges[game][group]); - await didUpdateBadgeModal?.(); + let task = fastdom.mutate(() => { + for (let idx = 0; idx < cacheIndexes.length; idx++) + badgeFilterCache[cacheIndexes[idx]].el.style.order = idx; + if (!badgeModalContent.childElementCount) + for (const game in gameBadges) + for (const group in gameBadges[game]) + badgeModalContent.append(...gameBadges[game][group]); + }); + await didUpdateBadgeModal?.(task); }, 0); }; @@ -518,7 +527,7 @@ function initBadgeControls() { openModal('badgesModal', null, prevModal || null); addLoader(document.getElementById('badgesModal'), true); - if (!badgeCache.filter(b => !localizedBadges.hasOwnProperty(b.game) || !localizedBadges[b.game].hasOwnProperty(b.badgeId)).length || localizedBadgesIgnoreUpdateTimer) + if (!badgeCache.filter(b => localizedBadges?.[b.game]?.hasOwnProperty(b.badgeId)).length || localizedBadgesIgnoreUpdateTimer) updateBadgesAndPopulateModal(slotRow, slotCol); else updateLocalizedBadges(() => updateBadgesAndPopulateModal(slotRow, slotCol)); @@ -552,9 +561,9 @@ function initBadgeControls() { mapIdToCacheKey[mapId] = key; } - return new Promise(resolve => window.requestAnimationFrame(() => { - badgeModalContent.querySelector('.nullBadgeItem')?.classList.toggle('hidden', exactMatch); - for (let item of badgeFilterCache) { + badgeModalContent.querySelector('.nullBadgeItem')?.classList.toggle('hidden', exactMatch); + for (let item of badgeFilterCache) { + fastdom.measure(() => { let visible = true; if (unlockStatus === 'recentUnlock') visible &= newUnlockBadges.has(item.badgeId); @@ -584,11 +593,11 @@ function initBadgeControls() { } } } - if (!gameVisibilities.hasOwnProperty(item.game)) { + if (!(item.game in gameVisibilities)) { gameVisibilities[item.game] = false; gameGroupVisibilities[item.game] = {}; } - if (item.group && !gameGroupVisibilities[item.game].hasOwnProperty(item.group)) + if (item.group && !(item.group in gameGroupVisibilities[item.game])) gameGroupVisibilities[item.game][item.group] = false; if (visible) { if (!gameVisibilities[item.game]) @@ -596,13 +605,14 @@ function initBadgeControls() { if (item.group && !gameGroupVisibilities[item.game][item.group]) gameGroupVisibilities[item.game][item.group] = true; } - item.el.classList.toggle('hidden', !visible); - } + fastdom.mutate(() => item.el.classList.toggle('hidden', !visible)); + }); + } + fastdom.mutate(() => { for (let header of badgeModalContent.querySelectorAll('.itemCategoryHeader')) header.classList.toggle('hidden', !(header.dataset.group ? gameGroupVisibilities[header.dataset.game][header.dataset.group] : gameVisibilities[header.dataset.game])); - resolve(); - })); + }) }; document.getElementById('badgeUnlockStatus').onchange = updateBadgeVisibility; @@ -735,13 +745,13 @@ function initBadgeControls() { /** @param {MouseEvent} ev */ function highlightRemove(ev) { - if (this.dataset.badgeId === 'null') { - this.classList.remove('removing'); - return; - } - window.requestAnimationFrame(() => + fastdom.mutate(() => { + if (this.dataset.badgeId === 'null') { + this.classList.remove('removing'); + return; + } this.classList.toggle('removing', ev.shiftKey && ev.type !== 'mouseleave') - ); + }); } for (let r = 1; r <= maxBadgeSlotRows; r++) { @@ -881,7 +891,8 @@ function updateBadgeButton() { const badgeId = playerData?.badge || 'null'; const badge = playerData?.badge ? badgeCache.find(b => b.badgeId === badgeId) : null; const badgeButton = document.getElementById('badgeButton'); - badgeButton.innerHTML = getBadgeItem(badge || { badgeId: 'null' }, false, true).innerHTML; + // badgeButton.innerHTML = getBadgeItem(badge || { badgeId: 'null' }, false, true).innerHTML; + badgeButton.replaceChildren(...getBadgeItem(badge || { badgeId: 'null' }, false, true).childNodes) if (badge?.overlayType & BadgeOverlayType.LOCATION) handleBadgeOverlayLocationColorOverride(badgeButton.querySelector('.badgeOverlay'), badgeButton.querySelector('.badgeOverlay2'), cachedLocations); } @@ -899,7 +910,7 @@ function getBadgeUrl(badge, staticOnly) { /** @param {Badge} badge */ -function getBadgeItem(badge, includeTooltip, emptyIcon, lockedIcon, scaled, filterable, parsedSystemName) { +function getBadgeItem(badge, includeTooltip, emptyIcon, lockedIcon, scaled, filterable, parsedSystemName, lazy) { const badgeId = badge.badgeId; const item = document.createElement('div'); @@ -930,46 +941,51 @@ function getBadgeItem(badge, includeTooltip, emptyIcon, lockedIcon, scaled, filt const badgeEl = (badge.unlocked || !badge.secret) && badgeId !== 'null' ? document.createElement('div') : null; const badgeUrl = badgeEl ? getBadgeUrl(badge, !badge.unlocked) : null; + let setBadgeBackgroundImage; + let assignTooltip; + if (badgeEl) { badgeEl.classList.add('badge'); if (scaled) badgeEl.classList.add('scaledBadge'); - badgeEl.style.backgroundImage = `url('${badgeUrl}')`; + setBadgeBackgroundImage = () => { + badgeEl.style.backgroundImage = `url('${badgeUrl}')`; - if (badge.overlayType) { - badgeEl.classList.add('overlayBadge'); + if (badge.overlayType) { + badgeEl.classList.add('overlayBadge'); - const badgeOverlay = document.createElement('div'); - badgeOverlay.classList.add('badgeOverlay'); - if (badge.overlayType & BadgeOverlayType.MULTIPLY) - badgeOverlay.classList.add('badgeOverlayMultiply'); + const badgeOverlay = document.createElement('div'); + badgeOverlay.classList.add('badgeOverlay'); + if (badge.overlayType & BadgeOverlayType.MULTIPLY) + badgeOverlay.classList.add('badgeOverlayMultiply'); - badgeEl.appendChild(badgeOverlay); + badgeEl.appendChild(badgeOverlay); - const badgeMaskValue = badge.overlayType & BadgeOverlayType.MASK - ? `url('${badgeUrl.replace('.', badge.overlayType & BadgeOverlayType.DUAL ? '_mask_fg.' : '_mask.')}')` - : badgeEl.style.backgroundImage; + const badgeMaskValue = badge.overlayType & BadgeOverlayType.MASK + ? `url('${badgeUrl.replace('.', badge.overlayType & BadgeOverlayType.DUAL ? '_mask_fg.' : '_mask.')}')` + : badgeEl.style.backgroundImage; - badgeOverlay.setAttribute('style', `-webkit-mask-image: ${badgeMaskValue}; mask-image: ${badgeMaskValue};`); + badgeOverlay.setAttribute('style', `-webkit-mask-image: ${badgeMaskValue}; mask-image: ${badgeMaskValue};`); - if (badge.overlayType & BadgeOverlayType.DUAL) { - const badgeMask2Value = badge.overlayType & BadgeOverlayType.MASK - ? `url(${badgeUrl.replace('.', '_mask_bg.')})` - : badgeEl.style.backgroundImage; + if (badge.overlayType & BadgeOverlayType.DUAL) { + const badgeMask2Value = badge.overlayType & BadgeOverlayType.MASK + ? `url(${badgeUrl.replace('.', '_mask_bg.')})` + : badgeEl.style.backgroundImage; - badgeOverlay.classList.add('badgeOverlayBase'); + badgeOverlay.classList.add('badgeOverlayBase'); - const badgeOverlay2 = document.createElement('div'); - badgeOverlay2.classList.add('badgeOverlay', 'badgeOverlay2'); - if (badge.overlayType & BadgeOverlayType.MULTIPLY) - badgeOverlay2.classList.add('badgeOverlayMultiply'); - badgeOverlay2.classList.add(getStylePropertyValue('--base-color') !== getStylePropertyValue('--alt-color') ? 'badgeOverlayAlt' : 'badgeOverlayBg'); + const badgeOverlay2 = document.createElement('div'); + badgeOverlay2.classList.add('badgeOverlay', 'badgeOverlay2'); + if (badge.overlayType & BadgeOverlayType.MULTIPLY) + badgeOverlay2.classList.add('badgeOverlayMultiply'); + badgeOverlay2.classList.add(getStylePropertyValue('--base-color') !== getStylePropertyValue('--alt-color') ? 'badgeOverlayAlt' : 'badgeOverlayBg'); - badgeEl.appendChild(badgeOverlay2); + badgeEl.appendChild(badgeOverlay2); - badgeOverlay2.setAttribute('style', `-webkit-mask-image: ${badgeMask2Value}; mask-image: ${badgeMask2Value};`); + badgeOverlay2.setAttribute('style', `-webkit-mask-image: ${badgeMask2Value}; mask-image: ${badgeMask2Value};`); + } } - } + }; badgeContainer.appendChild(badgeEl); if (!badge.unlocked) { @@ -994,7 +1010,7 @@ function getBadgeItem(badge, includeTooltip, emptyIcon, lockedIcon, scaled, filt if (badgeId === 'null') tooltipContent = ``; else { - if (localizedBadges.hasOwnProperty(badge.game) && localizedBadges[badge.game].hasOwnProperty(badgeId)) { + if (localizedBadges.hasOwnProperty(badge.game) && badgeId in localizedBadges[badge.game]) { let badgeTitle = localizedMessages.badges.locked; const localizedTooltip = localizedBadges[badge.game][badgeId]; if ((badge.unlocked || !badge.secret) && localizedTooltip.name) @@ -1055,15 +1071,15 @@ function getBadgeItem(badge, includeTooltip, emptyIcon, lockedIcon, scaled, filt const baseTooltipContent = tooltipContent; const tooltipOptions = {}; - const assignTooltip = instance => { + const assignTooltipOrDefer = instance => { const systemName = parsedSystemName; const assignImmediately = () => { const badgeTippy = addOrUpdateTooltip(item, tooltipContent, false, false, !!badge.mapId, tooltipOptions, instance); if (systemName) applyThemeStyles(badgeTippy.popper.querySelector('.tippy-box'), systemName, badge.game); - }; - if (includeTooltip === 'lazy') - assignTooltipCallbacks.set(item, assignImmediately); + } + if (lazy) + assignTooltip = assignImmediately; else assignImmediately(); }; @@ -1072,68 +1088,74 @@ function getBadgeItem(badge, includeTooltip, emptyIcon, lockedIcon, scaled, filt const mapId = badge.mapId.toString().padStart(4, '0'); if (filterItem) filterItem.mapId = mapId; const setTooltipLocation = instance => { - if (gameLocalizedMapLocations[badge.game] && gameLocalizedMapLocations[badge.game].hasOwnProperty(mapId)) + if (badge.game in gameLocalizedMapLocations && mapId in gameLocalizedMapLocations[badge.game]) tooltipContent = baseTooltipContent.replace('{LOCATION}', getLocalizedMapLocationsHtml(badge.game, mapId, '0000', badge.mapX, badge.mapY, getInfoLabel(' | '))); else if (badge.game === '2kki') { tooltipContent = baseTooltipContent.replace('{LOCATION}', getInfoLabel(getMassagedLabel(localizedMessages.location.queryingLocation))); tooltipOptions.onCreate = instance => getOrQuery2kkiLocationsHtml(mapId, locationsHtml => instance.setContent(baseTooltipContent.replace('{LOCATION}', locationsHtml))); } else tooltipContent = baseTooltipContent.replace('{LOCATION}', getInfoLabel(getMassagedLabel(localizedMessages.location.unknownLocation))); - assignTooltip(instance); + assignTooltipOrDefer(instance); }; - if (gameLocalizedMapLocations.hasOwnProperty(badge.game)) + if (badge.game in gameLocalizedMapLocations) setTooltipLocation(); else { tooltipContent = baseTooltipContent.replace('{LOCATION}', getInfoLabel(getMassagedLabel(localizedMessages.location.queryingLocation))); tooltipOptions.onCreate = instance => { includeTooltip = true; - if (gameLocalizedMapLocations.hasOwnProperty(badge.game)) + if (badge.game in gameLocalizedMapLocations) setTooltipLocation(instance); else fetchAndInitLocations(globalConfig.lang, badge.game).then(() => setTooltipLocation(instance)); }; - assignTooltip(); + assignTooltipOrDefer(); } } else - assignTooltip(); + assignTooltipOrDefer(); } } } + if (lazy) { + didObserveBadgeCallbacks.set(item, () => { + if (setBadgeBackgroundImage) + fastdom.mutate(setBadgeBackgroundImage); + assignTooltip?.(); + }); + } else { + setBadgeBackgroundImage?.(); + assignTooltip?.(); + } + item.appendChild(badgeContainer); return item; } -/** - @param {(_: BadgeCache) => any} -*/ async function fetchPlayerBadges() { - return new Promise(resolve => { - if (badgeCache?.full) { - resolve(badgeCache); - return; - } - apiFetch('badge?command=list') - .then(response => { - if (!response.ok) - throw new Error(response.statusText); - return response.json(); - }) - .then(badges => { - for (const { badgeId, newUnlock } of badges) { - if (newUnlock) { - newUnlockBadges.add(badgeId); - showBadgeToastMessage('badgeUnlocked', 'info', badgeId); - } + if (badgeCache?.full) + return badgeCache; + return await apiFetch('badge?command=list') + .then(response => { + if (!response.ok) + throw new Error(response.statusText); + return response.json(); + }) + .then(badges => { + for (const { badgeId, newUnlock } of badges) { + if (newUnlock) { + newUnlockBadges.add(badgeId); + showBadgeToastMessage('badgeUnlocked', 'info', badgeId); } - badgeCache = badges; - badgeCache.full = true; - resolve(badgeCache); - }) - .catch(err => console.error(err)); - }); -} + } + badgeCache = badges; + badgeCache.full = true; + return badgeCache; + }, err => { + console.error(err); + return badgeCache; + }) +}; function updateBadges(callback) { apiFetch('badge?command=list&simple=true') @@ -1255,7 +1277,7 @@ function addOrUpdatePlayerBadgeGalleryTooltip(badgeElement, name, sysName, mapId badgeElement.dataset.systemName = sysName; if (!badgeElement._badgeGalleryTippy) { - badgeElement._badgeGalleryTippy = tippy(badgeElement, Object.assign({ + badgeElement._badgeGalleryTippy = tippy(badgeElement, { trigger: 'click', interactive: true, content: `
${getMassagedLabel(localizedMessages.badgeGallery.loading, true)}
`, @@ -1435,8 +1457,9 @@ function addOrUpdatePlayerBadgeGalleryTooltip(badgeElement, name, sysName, mapId console.error(err); instance.setContent(''); }); - } - }, tippyConfig)); + }, + ...tippyConfig, + }); } return badgeElement._badgeGalleryTippy; diff --git a/chat.js b/chat.js index 653b51a2..e39d9c40 100644 --- a/chat.js +++ b/chat.js @@ -15,13 +15,16 @@ const SCREENSHOT_FLAGS = { const mentionSe = new Audio('./audio/mention.wav'); -function chatboxAddMessage(msg, type, player, ignoreNotify, mapId, prevMapId, prevLocationsStr, x, y, msgId, timestamp) { +function chatboxAddMessage(msg, type, player, ignoreNotify, mapId, prevMapId, prevLocationsStr, x, y, msgId, timestamp, shouldScroll = true) { const messages = document.getElementById("messages"); if (msgId && messages.querySelector(`.messageContainer[data-msg-id="${msgId}"]`)) return null; + + let task = Promise.resolve(shouldScroll); - const shouldScroll = Math.abs((messages.scrollHeight - messages.scrollTop) - messages.clientHeight) <= 20; + if (shouldScroll) + task = fastdom.measure(() => Math.abs((messages.scrollHeight - messages.scrollTop) - messages.clientHeight) <= 50); const msgContainer = document.createElement("div"); msgContainer.classList.add("messageContainer"); @@ -65,7 +68,7 @@ function chatboxAddMessage(msg, type, player, ignoreNotify, mapId, prevMapId, pr if (showLocation) { const playerLocation = document.createElement("bdi"); - if (gameId === "2kki" && (!localizedMapLocations || !localizedMapLocations.hasOwnProperty(mapId))) { + if (gameId === "2kki" && (!localizedMapLocations.hasOwnProperty(mapId))) { const prevLocations = prevLocationsStr && prevMapId !== "0000" ? decodeURIComponent(window.atob(prevLocationsStr)).split("|").map(l => { return { title: l }; }) : null; set2kkiGlobalChatMessageLocation(playerLocation, mapId, prevMapId, prevLocations); } else { @@ -273,11 +276,13 @@ function chatboxAddMessage(msg, type, player, ignoreNotify, mapId, prevMapId, pr const messageContentsWrapper = document.createElement('div'); messageContentsWrapper.classList.add('messageContentsWrapper'); - messageContentsWrapper.appendChild(messageContents); - messageContentsWrapper.dir = "auto"; - message.appendChild(messageContentsWrapper); - msgContainer.appendChild(message); - messages.appendChild(msgContainer); + fastdom.mutate(() => { + messageContentsWrapper.appendChild(messageContents); + messageContentsWrapper.dir = "auto"; + message.appendChild(messageContentsWrapper); + msgContainer.appendChild(message); + messages.appendChild(msgContainer); + }); if (player) addGameChatMessage(message.innerHTML, type, uuid); @@ -329,12 +334,17 @@ function chatboxAddMessage(msg, type, player, ignoreNotify, mapId, prevMapId, pr tabMessages.shift().remove(); } - if (shouldScroll) - messages.scrollTop = messages.scrollHeight; + task.then(top => top && scrollChatMessages()); return msgContainer; } +function scrollChatMessages() { + fastdom.mutate(() => { + document.getElementById('messages').scrollTo({ top: Number.MAX_SAFE_INTEGER }); + }); +} + let gameChatModeIndex = 0; function addGameChatMessage(messageHtml, messageType, senderUuid) { @@ -630,48 +640,48 @@ function updateChatMessageTimestamps() { timestamp.innerHTML = getChatMessageTimestampLabel(new Date(parseInt(timestamp.dataset.time))); } -function syncChatHistory() { - return new Promise((resolve, reject) => { - const messages = document.getElementById("messages"); - const idMessages = messages.querySelectorAll('.messageContainer[data-msg-id]'); - const lastMessageId = idMessages.length ? idMessages[idMessages.length - 1].dataset.msgId : null; - - updateChatMessageTimestamps(); - - apiFetch(`chathistory?globalMsgLimit=${globalConfig.globalChatHistoryLimit}&partyMsgLimit=${globalConfig.partyChatHistoryLimit}${lastMessageId ? `&lastMsgId=${lastMessageId}` : ''}`) - .then(response => { - if (!response.ok) - reject(response.statusText); - return response.json(); - }) - .then(chatHistory => { - if (chatHistory.players) { - for (let player of chatHistory.players) { - let badge = player.badge; - - if (badge === 'null') - badge = null; - - globalPlayerData[player.uuid] = { - name: player.name, - systemName: player.systemName, - rank: player.rank, - account: player.account, - badge: badge, - medals: player.medals - }; - } - } - - if (chatHistory.messages) { - for (let message of chatHistory.messages) - chatboxAddMessage(message.contents, message.party ? MESSAGE_TYPE.PARTY : MESSAGE_TYPE.GLOBAL, message.uuid, true, message.mapId, message.prevMapId, message.prevLocations, message.x, message.y, message.msgId, new Date(message.timestamp)); - } +async function syncChatHistory() { + const messages = document.getElementById("messages"); + const idMessages = messages.querySelectorAll('.messageContainer[data-msg-id]'); + const lastMessageId = idMessages.length ? idMessages[idMessages.length - 1].dataset.msgId : null; + + updateChatMessageTimestamps(); + + const chatHistory = await apiFetch(`chathistory?globalMsgLimit=${globalConfig.globalChatHistoryLimit}&partyMsgLimit=${globalConfig.partyChatHistoryLimit}${lastMessageId ? `&lastMsgId=${lastMessageId}` : ''}`) + .then(response => { + if (!response.ok) + throw new Error(response.statusText); + return response.json(); + }); + if (chatHistory.players) { + for (let player of chatHistory.players) { + let badge = player.badge; + + if (badge === 'null') + badge = null; + + globalPlayerData[player.uuid] = { + name: player.name, + systemName: player.systemName, + rank: player.rank, + account: player.account, + badge: badge, + medals: player.medals + }; + } + } - resolve(); - }) - .catch(err => reject(err)); - }); + let nmessages = 0; + if (chatHistory.messages) { + const epoch = performance.now(); + for (let message of chatHistory.messages) { + if (nmessages++ % 20 === 0) await yieldImmediately(); + chatboxAddMessage(message.contents, message.party ? MESSAGE_TYPE.PARTY : MESSAGE_TYPE.GLOBAL, message.uuid, true, message.mapId, message.prevMapId, message.prevLocations, message.x, message.y, message.msgId, new Date(message.timestamp), false); + } + const total = performance.now() - epoch; + console.log({ total, avg: total / chatHistory.messages.length }); + scrollChatMessages(); + } } const markdownSyntax = [ diff --git a/friends.js b/friends.js index 2cc957f2..6f3103f0 100644 --- a/friends.js +++ b/friends.js @@ -20,7 +20,7 @@ function onUpdatePlayerFriends(playerFriends) { for (let playerUuid of removedPlayerUuids) removePlayerListEntry(friendsPlayerList, playerUuid); - Array.from(friendsPlayerList.querySelectorAll('.listEntryCategoryHeader')).map(h => h.remove()); + Array.from(friendsPlayerList.querySelectorAll('.listEntryCategoryHeader')).forEach(h => h.remove()); for (let playerFriend of playerFriends) { const uuid = playerFriend.uuid; diff --git a/gamecanvas.css b/gamecanvas.css index c74c6114..aaacc939 100644 --- a/gamecanvas.css +++ b/gamecanvas.css @@ -62,11 +62,6 @@ html { opacity: 0.4; } -#apad > * { - width: var(--controls-size); - height: var(--controls-size); -} - #apad .apadBtn { width: var(--controls-size); height: var(--controls-size); diff --git a/icons.js b/icons.js index 0a50f76b..b30f2fca 100644 --- a/icons.js +++ b/icons.js @@ -43,7 +43,7 @@ const icons = { /** @param {keyof typeof icons} iconId */ function getSvgIcon(iconId, fill) { - if (!icons.hasOwnProperty(iconId)) + if (!(iconId in icons)) return null; const icon = document.createElement('div'); diff --git a/index.php b/index.php index 816d1de5..4bbfef76 100644 --- a/index.php +++ b/index.php @@ -2068,6 +2068,8 @@ class="joystickBase" /> + + diff --git a/init.js b/init.js index 7af0dbef..97cbe757 100644 --- a/init.js +++ b/init.js @@ -40,9 +40,20 @@ const tippyConfig = { arrow: false, animation: 'scale', allowHTML: true, - touch: ['hold', 400], + touch: /** @type {['hold', number]} */(['hold', 400]), }; +Object.defineProperty(Object.prototype, 'hasTitle', { + enumerable: false, + value() { + return typeof this === 'string' || 'title' in this; + } +}) + +Object.defineProperty(String.prototype, 'title', { + get() { return this; }, +}) + let easyrpgPlayer = { initialized: false, game: ynoGameId, @@ -84,10 +95,7 @@ async function injectScripts() { if (gameId === '2kki') { gameVersion = document.querySelector('meta[name="2kkiVersion"]')?.content?.replace(' Patch ', 'p'); - //init2kkiFileVersionAppend(); } - /*if (globalConfig.preloads) - initPreloads();*/ easyrpgPlayerLoadFuncs.push(() => { easyrpgPlayer.initialized = true; @@ -269,11 +277,9 @@ function getSpriteImg(img, spriteData, sprite, idx, frameIdx, width, height, xOf const data = imageData.data; const transPixel = data.slice(0, 3); let yOffset = hasYOffset ? -1 : 0; - const checkPixelTransparent = isBrave - ? o => (data[o] === transPixel[0] || data[o] - 1 === transPixel[0]) && (data[o + 1] === transPixel[1] || data[o + 1] - 1 === transPixel[1]) && (data[o + 2] === transPixel[2] || data[o + 2] - 1 === transPixel[2]) - : o => data[o] === transPixel[0] && data[o + 1] === transPixel[1] && data[o + 2] === transPixel[2]; + const checkPixelTransparent = isBrave ? transparencyChecker.brave : transparencyChecker.default; for (let i = 0; i < data.length; i += 4) { - if (checkPixelTransparent(i)) + if (checkPixelTransparent(data, i, transPixel)) data[i + 3] = 0; else if (yOffset === -1) yOffset = Math.max(Math.min(i >> 7, 15), 3); @@ -299,6 +305,14 @@ function getSpriteImg(img, spriteData, sprite, idx, frameIdx, width, height, xOf }); } +let transparencyChecker = { + default: (data, o, transPixel) => data[o] === transPixel[0] && data[o + 1] === transPixel[1] && data[o + 2] === transPixel[2], + brave: (data, o, transPixel) => (data[o] === transPixel[0] || data[o] - 1 === transPixel[0]) && (data[o + 1] === transPixel[1] || data[o + 1] - 1 === transPixel[1]) && (data[o + 2] === transPixel[2] || data[o + 2] - 1 === transPixel[2]), +} + +/** + * @param {Element} target + */ function addTooltip(target, content, asTooltipContent, delayed, interactive, options) { if (!options) options = {}; @@ -306,12 +320,25 @@ function addTooltip(target, content, asTooltipContent, delayed, interactive, opt options.interactive = true; if (delayed) options.delay = [750, 0]; - options.content = asTooltipContent ? `
${content}
` : content; + if (!asTooltipContent) + options.content = content; + else if (content instanceof Element) { + const tooltipContent = document.createElement('div'); + tooltipContent.classList.add('tooltipContent'); + tooltipContent.appendChild(content); + options.content = tooltipContent; + } else + options.content = `
${content}
`; options.appendTo = document.getElementById('layout'); target._tippy?.destroy(); return tippy(target, Object.assign(options, tippyConfig)); } +const playerTooltipCache = new Map; + +/** + * @param {HTMLElement} target + */ function addPlayerContextMenu(target, player, uuid, messageType) { if (!player || uuid === playerData?.uuid || uuid === defaultUuid) { target.addEventListener('contextmenu', event => event.preventDefault()); @@ -321,10 +348,60 @@ function addPlayerContextMenu(target, player, uuid, messageType) { if (player && !player.hasOwnProperty('uuid')) player = Object.assign({ uuid }, player); + target.addEventListener('contextmenu', function(event) { + event.preventDefault(); + + const cacheKey = `${uuid};${messageType}`; + + let playerTooltip = playerTooltipCache.get(cacheKey); + if (!playerTooltip) { + playerTooltip = createPlayerTooltip(this, player, uuid, messageType); + playerTooltipCache.set(cacheKey, playerTooltip); + } + + const isFriend = !!playerFriendsCache.find(pf => pf.uuid === uuid); + + const addFriendAction = playerTooltip.popper.querySelector('.addPlayerFriendAction'); + const removeFriendAction = playerTooltip.popper.querySelector('.removePlayerFriendAction'); + + if (addFriendAction) + addFriendAction.classList.toggle('hidden', isFriend); + if (removeFriendAction) + removeFriendAction.classList.toggle('hidden', !isFriend); + + const isBlocked = blockedPlayerUuids.indexOf(uuid) > -1; + + const blockAction = playerTooltip.popper.querySelector('.blockPlayerAction'); + const unblockAction = playerTooltip.popper.querySelector('.unblockPlayerAction'); + + if (blockAction) + blockAction.classList.toggle('hidden', isBlocked); + if (unblockAction) + unblockAction.classList.toggle('hidden', !isBlocked); + + playerTooltip.setProps({ + getReferenceClientRect: () => ({ + width: 0, + height: 0, + top: event.clientY, + bottom: event.clientY, + left: event.clientX, + right: event.clientX, + }), + }); + + playerTooltip.show(); + }); +} + +/** + * @param {Element} target + */ +function createPlayerTooltip(target, player, uuid, messageType) { const isMod = playerData?.rank > player?.rank; const isBlockable = playerData?.rank >= player?.rank; const playerName = getPlayerName(player, true, false, true); - + let tooltipHtml = ''; if (messageType) @@ -564,44 +641,12 @@ function addPlayerContextMenu(target, player, uuid, messageType) { }; }); - target.addEventListener('contextmenu', event => { - event.preventDefault(); - - const isFriend = !!playerFriendsCache.find(pf => pf.uuid === uuid); - - const addFriendAction = playerTooltip.popper.querySelector('.addPlayerFriendAction'); - const removeFriendAction = playerTooltip.popper.querySelector('.removePlayerFriendAction'); - - if (addFriendAction) - addFriendAction.classList.toggle('hidden', isFriend); - if (removeFriendAction) - removeFriendAction.classList.toggle('hidden', !isFriend); - - const isBlocked = blockedPlayerUuids.indexOf(uuid) > -1; - - const blockAction = playerTooltip.popper.querySelector('.blockPlayerAction'); - const unblockAction = playerTooltip.popper.querySelector('.unblockPlayerAction'); - - if (blockAction) - blockAction.classList.toggle('hidden', isBlocked); - if (unblockAction) - unblockAction.classList.toggle('hidden', !isBlocked); - - playerTooltip.setProps({ - getReferenceClientRect: () => ({ - width: 0, - height: 0, - top: event.clientY, - bottom: event.clientY, - left: event.clientX, - right: event.clientX, - }), - }); - - playerTooltip.show(); - }); + return playerTooltip; } +/** + * @param {import('tippy.js').Instance} [instance] + */ function addOrUpdateTooltip(target, content, asTooltipContent, delayed, interactive, options, instance) { if (!instance) return addTooltip(target, content, asTooltipContent, delayed, interactive, options); diff --git a/loader.js b/loader.js index 337368e5..202e860e 100644 --- a/loader.js +++ b/loader.js @@ -8,6 +8,8 @@ function addLoader(target, instant) { if (activeLoaders.has(target)) return; activeLoaders.set(target, true); + // TODO: optimize + const targetPosition = getComputedStyle(target).position; const getLoaderSprites = [ getLoaderSpriteImg(playerLoaderSprite, playerLoaderSpriteIdx, 0), @@ -23,7 +25,6 @@ function addLoader(target, instant) { const el = document.createElement('div'); el.classList.add('loader'); - const targetPosition = getComputedStyle(target).position; switch (targetPosition) { case 'fixed': case 'relative': @@ -85,16 +86,19 @@ function addLoader(target, instant) { function updateLoader(target) { if (activeLoaders.has(target)) { const el = activeLoaders.get(target).element; - el.style.top = `${target.offsetTop}px`; - el.style.left = `${target.offsetLeft}px`; - el.style.width = `${target.offsetWidth}px`; - el.style.height = `${target.offsetHeight}px`; - - const scaleX = Math.max(Math.min(Math.floor(target.offsetWidth / 48), 10), 1); - const scaleY = Math.max(Math.min(Math.floor(target.offsetHeight / 64), 10), 1); - const scale = Math.min(scaleX, scaleY); - - el.children[0].style.transform = `scale(${scale})`; + fastdom.measure(() => { + const {offsetTop, offsetLeft, offsetWidth, offsetHeight} = target; + const scaleX = Math.max(Math.min(Math.floor(offsetWidth / 48), 10), 1); + const scaleY = Math.max(Math.min(Math.floor(offsetHeight / 64), 10), 1); + const scale = Math.min(scaleX, scaleY); + fastdom.mutate(() => { + el.style.top = `${offsetTop}px`; + el.style.left = `${offsetLeft}px`; + el.style.width = `${offsetWidth}px`; + el.style.height = `${offsetHeight}px`; + el.children[0].style.transform = `scale(${scale})`; + }); + }); } } @@ -107,8 +111,6 @@ function removeLoader(target) { clearTimeout(longTimer); } activeLoaders.delete(target); - if (typeof loadingMessageTimer !== 'undefined') - clearTimeout(loadingMessageTimer); } async function getLoaderSpriteImg(sprite, idx, frameIdx, dir) { diff --git a/parties.js b/parties.js index 04542826..6def21e4 100644 --- a/parties.js +++ b/parties.js @@ -443,7 +443,7 @@ function addOrUpdatePartyListEntry(party) { const isInParty = party.id === joinedPartyId; const partyList = document.getElementById('partyList'); - let partyListEntry = document.querySelector(`.partyListEntry[data-id="${party.id}"]`); + let partyListEntry = partyList.querySelector(`.partyListEntry[data-id="${party.id}"]`); const partyListEntrySprite = partyListEntry ? partyListEntry.querySelector('.partyListEntrySprite') : document.createElement('img'); const nameText = partyListEntry ? partyListEntry.querySelector('.nameText') : document.createElement('span'); @@ -633,9 +633,7 @@ function addOrUpdatePartyListEntry(party) { } function removePartyListEntry(id) { - const partyListEntry = document.querySelector(`.partyListEntry[data-id="${id}"]`); - if (partyListEntry) - partyListEntry.remove(); + document.getElementById('partyList').querySelector(`.partyListEntry[data-id="${id}"]`)?.remove(); } function clearPartyList() { diff --git a/play.js b/play.js index 23a80989..3ecfe673 100644 --- a/play.js +++ b/play.js @@ -1,35 +1,47 @@ /** - * @typedef {Object} MapTitle + * @typedef {object} Coords + * @property {number} x1 + * @property {number} x2 + * @property {number} y1 + * @property {number} y2 + */ + +/** + * @typedef {object} MapTitle * @property {string} title * @property {string} [urlTitle] - * @property {Object} [coords] - * @property {number} coords.x1 - * @property {number} coords.x2 - * @property {number} coords.y1 - * @property {number} coords.y2 + * @property {Coords} [coords] + * @property {boolean} [explorer] */ /** - * @typedef {string | MapTitle | (string | MapTitle)[] | Record<'else' | (string & {}), MapTitle>} MapDescriptor + * @typedef {string | MapTitle | (string | MapTitle)[] | Record<'else' | number | `0${number}`, string | MapTitle | (string | MapTitle)[]>} MapDescriptor * In the array form, the last element is customarily the fallback title. * * The third object form allows matching the correct world for map IDs shared between worlds: - * a mapping from the previous map ID Urotsuki was on to, to the matching map title. + * a mapping from the previous map ID the player came from, to the matching map title. + */ + +/** + * @typedef {Record>} MapDescriptorRecord + * game -> map-id */ let localizedVersion; -/** @type {import('./lang/en.json')['messages']} */ +/** @type {import('./lang/en.json')['messages']?} */ let localizedMessages; +/** @type {Record?} */ let localizedMapLocations; +/** @type {Record?} */ let mapLocations; let localizedLocationUrlRoot; let locationUrlRoot; -/** @type {Record>} */ +/** @type {MapDescriptorRecord} */ let gameLocalizedMapLocations = {}; -/** @type {Record>} */ +/** @type {MapDescriptorRecord} */ let gameMapLocations = {}; let gameLocalizedLocationUrlRoots = {}; let gameLocationUrlRoots = {}; @@ -374,8 +386,13 @@ function checkUpdateLocation(mapId, mapChanged) { if (!cachedMapId) document.getElementById('location').classList.remove('hidden'); - document.getElementById('locationText').innerHTML = getLocalizedMapLocationsHtml(gameId, mapId, cachedMapId, tpX, tpY, '
'); - document.documentElement.style.setProperty('--location-width', `${document.querySelector('#locationText > *').offsetWidth}px`); + const localizedLocationHtml = getLocalizedMapLocationsHtml(gameId, mapId, cachedMapId, tpX, tpY, '
'); + fastdom.mutate(() => document.getElementById('locationText').innerHTML = localizedLocationHtml).then(() => { + fastdom.measure(() => { + const width = `${document.querySelector('#locationText > *').offsetWidth}px`; + fastdom.mutate(() => document.getElementById('nextLocationContainer').style.setProperty('--location-width', width)); + }) + }); onUpdateChatboxInfo(); if (is2kki) { @@ -586,9 +603,9 @@ function preToggle(buttonElement) { /** * Opens a modal. * @param {string} modalId The modal's ID. - * @param {string} theme The theme for the modal to use. Player-selected or in-game menu theme is used if none is specified. - * @param {string} lastModalId The previously-opened modal, used when opening sub-modals. - * @param {Object} modalData Data to be passed to the modal. + * @param {string} [theme] The theme for the modal to use. Player-selected or in-game menu theme is used if none is specified. + * @param {string} [lastModalId] The previously-opened modal, used when opening sub-modals. + * @param {Object} [modalData] Data to be passed to the modal. */ function openModal(modalId, theme, lastModalId, modalData) { const modalContainer = document.getElementById('modalContainer'); @@ -1460,32 +1477,45 @@ function updateYnomojiContainerPos(isScrollUpdate, chatInput) { function onUpdateChatboxInfo() { const layout = document.getElementById('layout'); - const chatboxContainer = document.getElementById('chatboxContainer'); const chatboxInfo = document.getElementById('chatboxInfo'); const chatboxTabs = document.getElementsByClassName('chatboxTab'); - - const backgroundSize = chatboxContainer.classList.contains('fullBg') ? window.getComputedStyle(chatboxContainer).backgroundSize : null; - - for (let tab of chatboxTabs) { - tab.style.backgroundSize = backgroundSize; - tab.style.backgroundPositionX = `${-8 + tab.parentElement.offsetLeft - tab.getBoundingClientRect().left}px`; - tab.style.backgroundPositionY = `${chatboxContainer.offsetTop - tab.parentElement.getBoundingClientRect().top}px`; - } - const messages = document.getElementById('messages'); const partyPlayerList = document.getElementById('partyPlayerList'); - messages.style.backgroundPositionY = partyPlayerList.style.backgroundPositionY = `${chatboxContainer.offsetTop - partyPlayerList.getBoundingClientRect().top}px`; - if (!layout.classList.contains('immersionMode') && !document.fullscreenElement && window.getComputedStyle(layout).flexWrap === 'wrap') { + fastdom.measure(() => { + const backgroundSize = chatboxContainer.classList.contains('fullBg') ? getComputedStyle(chatboxContainer).backgroundSize : null; + const backgroundPositionY = `${chatboxContainer.offsetTop - partyPlayerList.getBoundingClientRect().top}px`; + const hasFlexWrap = getComputedStyle(layout).flexWrap === 'wrap'; const lastTab = chatboxTabs[chatboxTabs.length - 1]; - const offsetLeft = `${(lastTab.offsetLeft + lastTab.offsetWidth) - 24}px`; - chatboxInfo.style.marginInlineStart = offsetLeft; - chatboxInfo.style.marginBottom = '-32px'; - if (chatboxInfo.offsetHeight >= 72) - chatboxInfo.setAttribute('style', ''); - } else - chatboxInfo.setAttribute('style', ''); + const lastTabOffset = lastTab.offsetLeft + lastTab.offsetRight; + + fastdom.mutate(() => { + for (let tab of chatboxTabs) { + tab.style.backgroundSize = backgroundSize; + fastdom.measure(() => { + const posx = `${-8 + tab.parentElement.offsetLeft - tab.getBoundingClientRect().left}px`; + const posy = `${chatboxContainer.offsetTop - tab.parentElement.getBoundingClientRect().top}px`; + fastdom.mutate(() => { + tab.style.backgroundPositionX = posx; + tab.style.backgroundPositionY = posy; + }); + }); + } + + messages.style.backgroundPositionY = partyPlayerList.style.backgroundPositionY = backgroundPositionY; + if (!layout.classList.contains('immersionMode') && !document.fullscreenElement && hasFlexWrap) { + const offsetLeft = `${lastTabOffset - 24}px`; + chatboxInfo.style.marginInlineStart = offsetLeft; + chatboxInfo.style.marginBottom = '-32px'; + fastdom.measure(() => { + if (chatboxInfo.offsetHeight >= 72) + fastdom.mutate(() => chatboxInfo.setAttribute('style', '')); + }) + } else + chatboxInfo.setAttribute('style', ''); + }); + }); } function isOverflow(scale) { @@ -1902,11 +1932,11 @@ function initLocalizedLocations(game) { gameLocationsMap[game][mapLocation.title] = mapLocation; }; - for (let mapId of Object.keys(gameMapLocations[game])) { + for (let mapId in gameMapLocations[game]) { const locations = gameMapLocations[game][mapId]; if (!locations) continue; - if (locations.hasOwnProperty('title')) // Text location + if (locations.hasTitle()) // Text location trySetLocalizedLocation(locations, gameLocalizedMapLocations[game][mapId]); else if (Array.isArray(locations)) // Multiple locations locations.forEach((location, i) => trySetLocalizedLocation(location, gameLocalizedMapLocations[game][mapId][i])); @@ -1915,7 +1945,7 @@ function initLocalizedLocations(game) { const locationsInner = gameMapLocations[game][mapId][key]; if (!locationsInner) continue; - if (locationsInner.hasOwnProperty('title')) + if (locationsInner.hasTitle()) trySetLocalizedLocation(locationsInner, gameLocalizedMapLocations[game][mapId][key]); else locationsInner.forEach((location, i) => trySetLocalizedLocation(location, gameLocalizedMapLocations[game][mapId][key][i])); @@ -1924,29 +1954,36 @@ function initLocalizedLocations(game) { } } +/** + * @param {Record} mapLocations + * @return {MapTitle[]} + */ function getMapLocationsArray(mapLocations, mapId, prevMapId, x, y) { - if (mapLocations.hasOwnProperty(mapId)) { + if (mapId in mapLocations) { const locations = mapLocations[mapId]; - if (locations.hasOwnProperty('title')) // Text location + if (locations.hasTitle()) // Text location return [ locations ]; if (Array.isArray(locations)) // Multiple locations return getMapLocationsFromArray(locations, x, y); - if (locations.hasOwnProperty(prevMapId)) {// Previous map ID matches a key + if (prevMapId in locations) {// Previous map ID matches a key if (Array.isArray(locations[prevMapId])) return getMapLocationsFromArray(locations[prevMapId], x, y); return [ locations[prevMapId] ]; } - if (locations.hasOwnProperty('else')) { // Else case - if (locations.else.hasOwnProperty('title')) + if ('else' in locations) { // Else case + if (locations.else.hasTitle()) return [ locations.else ]; - if (Array.isArray(locations.else)) - return getMapLocationsFromArray(locations.else, x, y); + return getMapLocationsFromArray(locations.else, x, y); } } } +/** + * @param {MapTitle[]} locations + * @return {MapTitle[]} + */ function getMapLocationsFromArray(locations, x, y) { - if (locations.length && locations[0].hasOwnProperty('coords') && x !== null && y !== null) { + if (Array.isArray(locations) && locations[0].hasOwnProperty('coords') && x !== null && y !== null) { const coordLocation = locations.find(l => l.hasOwnProperty('coords') && ((l.coords.x1 === -1 && l.coords.x2 === -1) || (l.coords.x1 <= x && l.coords.x2 >= x)) && ((l.coords.y1 === -1 && l.coords.y2 === -1) || (l.coords.y1 <= y && l.coords.y2 >= y))); if (coordLocation) return [ coordLocation ]; @@ -1958,47 +1995,50 @@ function getMapLocationsFromArray(locations, x, y) { } function getLocalizedMapLocations(game, mapId, prevMapId, x, y, separator, forDisplay) { - if (gameLocalizedMapLocations[game]?.hasOwnProperty(mapId)) { - const localizedLocations = gameLocalizedMapLocations[game][mapId]; + const localizedLocations = gameLocalizedMapLocations[game]?.[mapId]; + if (localizedLocations) { + // locations have the same type as localizedLocations const locations = gameMapLocations[game][mapId]; - if (localizedLocations.hasOwnProperty('title')) // Text location + if (localizedLocations.hasTitle()) // Text location return getLocalizedLocation(game, localizedLocations, locations, false, forDisplay); if (Array.isArray(localizedLocations)) // Multiple locations return getMapLocationsFromArray(localizedLocations, x, y).map((l, i) => getLocalizedLocation(game, l, getMapLocationsFromArray(locations, x, y)[i], false, forDisplay)).join(separator); - if (localizedLocations.hasOwnProperty(prevMapId)) { // Previous map ID matches a key + if (prevMapId in localizedLocations) { // Previous map ID matches a key if (Array.isArray(localizedLocations[prevMapId])) return getMapLocationsFromArray(localizedLocations[prevMapId], x, y).map((l, i) => getLocalizedLocation(game, l, getMapLocationsFromArray(locations[prevMapId], x, y)[i], false, forDisplay)).join(separator); return getLocalizedLocation(game, localizedLocations[prevMapId], locations[prevMapId]); } - if (localizedLocations.hasOwnProperty('else')) { // Else case - if (localizedLocations.else.hasOwnProperty('title')) + if ('else' in localizedLocations) { // Else case + if (localizedLocations.else.hasTitle()) return getLocalizedLocation(game, localizedLocations.else, locations.else, false, forDisplay); - if (Array.isArray(localizedLocations.else)) - return getMapLocationsFromArray(localizedLocations.else, x, y).map((l, i) => getLocalizedLocation(game, l, getMapLocationsFromArray(locations.else, x, y)[i], false, forDisplay)).join(separator); + return getMapLocationsFromArray(localizedLocations.else, x, y).map((l, i) => getLocalizedLocation(game, l, getMapLocationsFromArray(locations.else, x, y)[i], false, forDisplay)).join(separator); } } return localizedMessages.location.unknownLocation; } +/** + * @param {number | `0${number}`} prevMapId + */ function getLocalizedMapLocationsHtml(game, mapId, prevMapId, x, y, separator) { if (gameLocalizedMapLocations[game]?.hasOwnProperty(mapId)) { const localizedLocations = gameLocalizedMapLocations[game][mapId]; const locations = gameMapLocations[game][mapId]; let locationsHtml; - if (localizedLocations.hasOwnProperty('title')) // Text location + if (localizedLocations.hasTitle()) // Text location locationsHtml = getLocalizedLocation(game, localizedLocations, locations, true); else if (Array.isArray(localizedLocations)) // Multiple locations locationsHtml = getMapLocationsFromArray(localizedLocations, x, y).map((l, i) => getLocalizedLocation(game, l, getMapLocationsFromArray(locations, x, y)[i], true)).join(separator); - else if (localizedLocations.hasOwnProperty(prevMapId)) { // Previous map ID matches a key + else if (prevMapId in localizedLocations) { // Previous map ID matches a key if (Array.isArray(localizedLocations[prevMapId])) locationsHtml = getMapLocationsFromArray(localizedLocations[prevMapId], x, y).map((l, i) => getLocalizedLocation(game, l, getMapLocationsFromArray(locations[prevMapId], x, y)[i], true)).join(separator); else locationsHtml = getLocalizedLocation(game, localizedLocations[prevMapId], locations[prevMapId], true); - } else if (localizedLocations.hasOwnProperty('else')) { // Else case - if (localizedLocations.else.hasOwnProperty('title')) + } else if ('else' in localizedLocations) { // Else case + if (localizedLocations.else.hasTitle()) locationsHtml = getLocalizedLocation(game, localizedLocations.else, locations.else, true); - else if (Array.isArray(localizedLocations.else)) + else locationsHtml = getMapLocationsFromArray(localizedLocations.else, x, y).map((l, i) => getLocalizedLocation(game, l, getMapLocationsFromArray(locations.else, x, y)[i], true)).join(separator); } @@ -2020,7 +2060,7 @@ function massageMapLocations(mapLocations, locationUrlTitles) { } } } else { - if (mapLocations.hasOwnProperty('title')) { + if (mapLocations.hasTitle()) { if (locationUrlTitles?.hasOwnProperty(mapLocations.title)) mapLocations.urlTitle = locationUrlTitles[mapLocations.title]; return; @@ -2037,6 +2077,10 @@ function massageMapLocations(mapLocations, locationUrlTitles) { } } +/** + * @param {MapTitle} location + * @param {MapTitle} locationEn + */ function getLocalizedLocation(game, location, locationEn, asHtml, forDisplay) { let template = getMassagedLabel(localizedMessages[forDisplay ? 'locationDisplay' : 'location'].template); let ret; @@ -2095,7 +2139,7 @@ function massageLabels(data) { function getMassagedLabel(label, isUI) { if (label) - label = label.replace(/\n/g, '
'); + label = label.replaceAll('\n', '
'); if (langLabelMassageFunctions.hasOwnProperty(globalConfig.lang) && label) return langLabelMassageFunctions[globalConfig.lang](label, isUI); return label; @@ -2151,9 +2195,9 @@ function getMapButton(url, label) { function getOrQueryLocationColors(locationName) { return new Promise((resolve, _reject) => { - if (Array.isArray(locationName) && locationName.length && locationName[0].hasOwnProperty('title')) + if (Array.isArray(locationName) && locationName.length && locationName[0].hasTitle()) locationName = locationName[0].title; - else if (locationName?.hasOwnProperty('title')) + else if (locationName?.hasTitle()) locationName = locationName.title; else if (!locationName) { resolve(['#FFFFFF', '#FFFFFF']); @@ -2169,7 +2213,7 @@ function getOrQueryLocationColors(locationName) { if (gameId === '2kki') { const url = `${apiUrl}/2kki?action=getLocationColors&locationName=${locationName}`; - const callback = response => { + send2kkiApiRequest(url, response => { let errCode = null; if (response && !response.err_code) @@ -2181,8 +2225,7 @@ function getOrQueryLocationColors(locationName) { console.error({ error: response.error, errCode: errCode }); resolve([response?.fgColor, response?.bgColor]); - }; - send2kkiApiRequest(url, callback); + }); } else { sendSessionCommand('lcol', [ locationName ], params => { if (params.length === 2) { diff --git a/playerlist.js b/playerlist.js index f5c2ea06..bdbaa8ad 100644 --- a/playerlist.js +++ b/playerlist.js @@ -159,10 +159,11 @@ function getPlayerName(player, includeMarkers, includeBadge, asHtml) { function addOrUpdatePlayerListEntry(playerList, player, showLocation, sortEntries) { const uuid = player.uuid === defaultUuid ? playerData?.uuid || defaultUuid : player.uuid; const name = player.name; + /** @type {string} */ let systemName = player.systemName; let playerGameId = player.game || gameId; - if (!allGameUiThemes.hasOwnProperty(playerGameId)) + if (!(playerGameId in allGameUiThemes)) playerGameId = gameId; if (!playerList) @@ -262,9 +263,9 @@ function addOrUpdatePlayerListEntry(playerList, player, showLocation, sortEntrie playerList.scrollTop = playerList.scrollHeight; } - if (player.hasOwnProperty('spriteName')) { - getSpriteProfileImg(player.spriteName, player.hasOwnProperty('spriteIndex') ? player.spriteIndex : 0, undefined, undefined, playerGameId).then(spriteImg => { - if (spriteImg) + if ('spriteName' in player) { + getSpriteProfileImg(player.spriteName, player.spriteIndex || 0, undefined, undefined, playerGameId).then(spriteImg => { + if (spriteImg && playerListEntrySprite.src !== spriteImg) playerListEntrySprite.src = spriteImg }); } else { @@ -273,7 +274,7 @@ function addOrUpdatePlayerListEntry(playerList, player, showLocation, sortEntrie playerSpriteCacheEntry = playerSpriteCache[defaultUuid]; if (playerSpriteCacheEntry) { getSpriteProfileImg(playerSpriteCacheEntry.sprite, playerSpriteCacheEntry.idx, undefined, undefined, playerGameId).then(spriteImg => { - if (spriteImg) + if (spriteImg && playerListEntrySprite.src !== spriteImg) playerListEntrySprite.src = spriteImg }); if (uuid === defaultUuid) @@ -505,10 +506,10 @@ function addOrUpdatePlayerListEntry(playerList, player, showLocation, sortEntrie playerListEntryMedals.classList.toggle('hidden', !showMedals); if (systemName) { - systemName = systemName.replace(/'/g, ''); - if (playerListEntry.dataset.unnamed || !allGameUiThemes.hasOwnProperty(playerGameId) || allGameUiThemes[playerGameId].indexOf(systemName) === -1) + systemName = systemName.replaceAll("'", ''); + if (playerListEntry.dataset.unnamed || !(playerGameId in allGameUiThemes) || allGameUiThemes[playerGameId].indexOf(systemName) === -1) systemName = getDefaultUiTheme(playerGameId); - const parsedSystemName = systemName.replace(/ /g, '_'); + const parsedSystemName = systemName.replaceAll(' ', '_'); initUiThemeContainerStyles(systemName, playerGameId, false, () => { initUiThemeFontStyles(systemName, playerGameId, 0, false, () => { const gameParsedSystemName = `${playerGameId !== gameId ? `${playerGameId}-` : ''}${parsedSystemName}`; @@ -547,7 +548,7 @@ function addOrUpdatePlayerListEntryLocation(locationVisible, player, entry) { let playerLocation = entry.querySelector('.playerLocation'); const initLocation = !playerLocation; const isValidMap = !!parseInt(player.mapId); - const showLastOnline = player.hasOwnProperty('online') && !player.online && player.hasOwnProperty('lastActive') && player.lastActive; + const showLastOnline = !player.online && player.lastActive; if (initLocation) { playerLocation = document.createElement('small'); @@ -560,12 +561,12 @@ function addOrUpdatePlayerListEntryLocation(locationVisible, player, entry) { playerLocation.classList.toggle('hidden', (!locationVisible || !shouldDisplayLocation) && !showLastOnline); if (locationVisible && player.online && shouldDisplayLocation) { - if (!allGameUiThemes.hasOwnProperty(playerGameId)) + if (!(playerGameId in allGameUiThemes)) playerGameId = gameId; const parsedSystemName = player.systemName ? (allGameUiThemes[playerGameId].indexOf(player.systemName) > -1 ? player.systemName : getDefaultUiTheme(playerGameId)).replace(/ /g, '_') : null; playerLocation.dataset.systemOverride = parsedSystemName || null; if (playerGameId === gameId) { - if (gameId === '2kki' && (!localizedMapLocations || !localizedMapLocations.hasOwnProperty(player.mapId))) { + if (gameId === '2kki' && (!localizedMapLocations?.hasOwnProperty(player.mapId))) { const prevLocations = player.prevLocations && player.prevMapId !== '0000' ? decodeURIComponent(window.atob(player.prevLocations)).split('|').map(l => { return { title: l }; }) : null; set2kkiGlobalChatMessageLocation(playerLocation, player.mapId, player.prevMapId, prevLocations); } else { @@ -580,8 +581,9 @@ function addOrUpdatePlayerListEntryLocation(locationVisible, player, entry) { applyThemeStyles(playerLocation, playerLocation.dataset.systemOverride, playerGameId); } } else if (showLastOnline) { - if (parseInt(getLastOnlineInterval(new Date(player.lastActive))) < 5000) - playerLocation.innerHTML = `${getMassagedLabel(localizedMessages.lastOnline.template).replace('{INTERVAL}', getLastOnlineInterval(new Date(player.lastActive)))}`; + const lastActive = getLastOnlineInterval(new Date(player.lastActive)); + if (parseInt(lastActive) < 5000) + playerLocation.innerHTML = `${getMassagedLabel(localizedMessages.lastOnline.template).replace('{INTERVAL}', lastActive)}`; else playerLocation.innerHTML = `${getMassagedLabel(localizedMessages.lastOnline.longTime)}`; if (playerLocation.dataset.systemOverride) @@ -616,9 +618,8 @@ function sortPlayerListEntries(playerList) { return a.innerText.localeCompare(b.innerText, { sensitivity: 'base' }); }); - entries.forEach(function (ple) { - playerList.appendChild(ple); - }); + for (const ple of entries) + playerList.appendChild(ple); } } diff --git a/screenshots.js b/screenshots.js index 55b484a7..e23f8a7a 100644 --- a/screenshots.js +++ b/screenshots.js @@ -286,7 +286,12 @@ function takeScreenshot() { thumb.src = url; toast.querySelector('.toastMessage').prepend(thumb); toast.classList.add('screenshotToast'); - document.documentElement.style.setProperty('--toast-offset', `-${toast.getBoundingClientRect().height + 8}px`); + fastdom.measure(() => { + const height = toast.getBoundingClientRect().height + 8; + fastdom.mutate(() => { + document.getElementById('toastContainer').style.setProperty('--toast-offset', `-${height}px`); + }); + }); thumb.onclick = () => viewScreenshot(url, dateTaken, { mapId, mapX, mapY }); @@ -452,15 +457,11 @@ function initScreenshotsModal(isCommunity) { locationContent = getInfoLabel(getMassagedLabel(localizedMessages.location.unknownLocation)); screenshotLocation.innerHTML = locationContent; }; - if (gameLocalizedMapLocations.hasOwnProperty(screenshot.game)) + if (screenshot.game in gameLocalizedMapLocations) setScreenshotLocation(); else { - locationContent = getInfoLabel(getMassagedLabel(localizedMessages.location.queryingLocation)); - if (gameLocalizedMapLocations.hasOwnProperty(screenshot.game)) - setScreenshotLocation(); - else - fetchAndInitLocations(globalConfig.lang, screenshot.game).then(() => setScreenshotLocation()); - screenshotLocation.innerHTML = locationContent; + screenshotLocation.innerHTML = getInfoLabel(getMassagedLabel(localizedMessages.location.queryingLocation)); + fetchAndInitLocations(globalConfig.lang, screenshot.game).then(() => setScreenshotLocation()); } } diff --git a/session.js b/session.js index 23b2a5ca..6844b4dc 100644 --- a/session.js +++ b/session.js @@ -9,7 +9,7 @@ let hasConnected; function initSessionWs(attempt) { return new Promise(resolve => { if (sessionWs) - closeSessionWs(sessionWs); + closeSessionWs(); if (config.singlePlayer) { resolve(); return; @@ -50,7 +50,7 @@ function initSessionWs(attempt) { sessionWs.onmessage = event => { const args = event.data.split(wsDelim); const command = args[0]; - if (sessionCommandHandlers.hasOwnProperty(command)) { + if (command in sessionCommandHandlers) { const params = args.slice(1); if (sessionCommandHandlers[command]) sessionCommandHandlers[command](params); @@ -90,4 +90,4 @@ function sendSessionCommand(command, commandParams, callbackFunc, callbackComman } sessionWs.send(args.join(wsDelim)); -} \ No newline at end of file +} diff --git a/system.js b/system.js index 8854caf4..59f70581 100644 --- a/system.js +++ b/system.js @@ -464,19 +464,23 @@ function initUiThemeContainerStyles(uiTheme, themeGameId, setTheme, callback) { if (!rootStyle.getPropertyValue(baseBgColorProp)) { addSystemSvgDropShadow(uiTheme, themeGameId, shadow); - rootStyle.setProperty(baseBgColorProp, color); - rootStyle.setProperty(shadowColorProp, shadow); - rootStyle.setProperty(svgShadowProp, `url(#dropShadow_${themeGameId !== gameId ? `${themeGameId}_` : ''}${parsedUiTheme})`); - rootStyle.setProperty(containerBgImageUrlProp, `url('images/ui/${themeGameId}/${uiTheme}/containerbg.png')`); - rootStyle.setProperty(borderImageUrlProp, `url('images/ui/${themeGameId}/${uiTheme}/border.png')`); + fastdom.mutate(() => { + rootStyle.setProperty(baseBgColorProp, color); + rootStyle.setProperty(shadowColorProp, shadow); + rootStyle.setProperty(svgShadowProp, `url(#dropShadow_${themeGameId !== gameId ? `${themeGameId}_` : ''}${parsedUiTheme})`); + rootStyle.setProperty(containerBgImageUrlProp, `url('images/ui/${themeGameId}/${uiTheme}/containerbg.png')`); + rootStyle.setProperty(borderImageUrlProp, `url('images/ui/${themeGameId}/${uiTheme}/border.png')`); + }); } if (setTheme && themeGameId === gameId) { - rootStyle.setProperty('--base-bg-color', `var(${baseBgColorProp})`); - rootStyle.setProperty('--shadow-color', `var(${shadowColorProp})`); - rootStyle.setProperty('--svg-shadow', `var(${svgShadowProp})`); - rootStyle.setProperty('--container-bg-image-url', `var(${containerBgImageUrlProp})`); - rootStyle.setProperty('--border-image-url', `var(${borderImageUrlProp})`); + fastdom.mutate(() => { + rootStyle.setProperty('--base-bg-color', `var(${baseBgColorProp})`); + rootStyle.setProperty('--shadow-color', `var(${shadowColorProp})`); + rootStyle.setProperty('--svg-shadow', `var(${svgShadowProp})`); + rootStyle.setProperty('--container-bg-image-url', `var(${containerBgImageUrlProp})`); + rootStyle.setProperty('--border-image-url', `var(${borderImageUrlProp})`); + }); } if (callback) @@ -526,27 +530,31 @@ function initUiThemeFontStyles(uiTheme, themeGameId, fontStyle, setTheme, callba if (!rootStyle.getPropertyValue(baseColorProp)) { addSystemSvgGradient(uiTheme, themeGameId, baseColors); addSystemSvgGradient(uiTheme, themeGameId, altColors, true); - rootStyle.setProperty(baseColorProp, getColorRgb(baseColors[8])); - rootStyle.setProperty(altColorProp, getColorRgb(altColors[8])); - rootStyle.setProperty(baseGradientProp, `linear-gradient(to bottom, ${getGradientText(baseColors)})`); - rootStyle.setProperty(baseGradientBProp, `linear-gradient(to bottom, ${getGradientText(baseColors, true)})`); - rootStyle.setProperty(altGradientProp, `linear-gradient(to bottom, ${getGradientText(altColors)})`); - rootStyle.setProperty(altGradientBProp, `linear-gradient(to bottom, ${getGradientText(altColors, true)})`); - rootStyle.setProperty(svgBaseGradientProp, `url(#baseGradient_${themeGameId !== gameId ? `${themeGameId}_` : ''}${parsedUiTheme})`); - rootStyle.setProperty(svgAltGradientProp, `url(#altGradient_${themeGameId !== gameId ? `${themeGameId}_` : ''}${parsedUiTheme})`); - rootStyle.setProperty(baseColorImageUrlProp, `url('images/ui/${themeGameId}/${uiTheme}/font${fontStyle + 1}.png')`); + fastdom.mutate(() => { + rootStyle.setProperty(baseColorProp, getColorRgb(baseColors[8])); + rootStyle.setProperty(altColorProp, getColorRgb(altColors[8])); + rootStyle.setProperty(baseGradientProp, `linear-gradient(to bottom, ${getGradientText(baseColors)})`); + rootStyle.setProperty(baseGradientBProp, `linear-gradient(to bottom, ${getGradientText(baseColors, true)})`); + rootStyle.setProperty(altGradientProp, `linear-gradient(to bottom, ${getGradientText(altColors)})`); + rootStyle.setProperty(altGradientBProp, `linear-gradient(to bottom, ${getGradientText(altColors, true)})`); + rootStyle.setProperty(svgBaseGradientProp, `url(#baseGradient_${themeGameId !== gameId ? `${themeGameId}_` : ''}${parsedUiTheme})`); + rootStyle.setProperty(svgAltGradientProp, `url(#altGradient_${themeGameId !== gameId ? `${themeGameId}_` : ''}${parsedUiTheme})`); + rootStyle.setProperty(baseColorImageUrlProp, `url('images/ui/${themeGameId}/${uiTheme}/font${fontStyle + 1}.png')`); + }); } if (setTheme && themeGameId === gameId) { - rootStyle.setProperty('--base-color', `var(${baseColorProp})`); - rootStyle.setProperty('--alt-color', `var(${altColorProp})`); - rootStyle.setProperty('--base-gradient', `var(${baseGradientProp})`); - rootStyle.setProperty('--base-gradient-b', `var(${baseGradientBProp})`); - rootStyle.setProperty('--alt-gradient', `var(${altGradientProp})`); - rootStyle.setProperty('--alt-gradient-b', `var(${altGradientBProp})`); - rootStyle.setProperty('--svg-base-gradient', `var(${svgBaseGradientProp})`); - rootStyle.setProperty('--svg-alt-gradient', `var(${svgAltGradientProp})`); - rootStyle.setProperty('--base-color-image-url', `var(${baseColorImageUrlProp})`); + fastdom.mutate(() => { + rootStyle.setProperty('--base-color', `var(${baseColorProp})`); + rootStyle.setProperty('--alt-color', `var(${altColorProp})`); + rootStyle.setProperty('--base-gradient', `var(${baseGradientProp})`); + rootStyle.setProperty('--base-gradient-b', `var(${baseGradientBProp})`); + rootStyle.setProperty('--alt-gradient', `var(${altGradientProp})`); + rootStyle.setProperty('--alt-gradient-b', `var(${altGradientBProp})`); + rootStyle.setProperty('--svg-base-gradient', `var(${svgBaseGradientProp})`); + rootStyle.setProperty('--svg-alt-gradient', `var(${svgAltGradientProp})`); + rootStyle.setProperty('--base-color-image-url', `var(${baseColorImageUrlProp})`); + }); } if (callback) @@ -563,6 +571,9 @@ function initUiThemeFontStyles(uiTheme, themeGameId, fontStyle, setTheme, callba }); } +/** + * @type {(el: Element, uiTheme?: string, themeGameId?: string) => void} + */ let applyThemeStyles; { @@ -681,23 +692,28 @@ let applyThemeStyles; const themeStylesId = `theme${themeSuffix}`; let themeStyles = document.getElementById(themeStylesId); + let task = Promise.resolve(); if (!themeStyles) { const themePropSuffix = `-${themeGameId !== gameId ? `${themeGameId}-` : ''}${uiTheme}`; themeStyles = document.createElement('style'); themeStyles.id = themeStylesId; - themeStyles.innerHTML = themeStyleTemplate.replace(/\{THEME\}/g, themeSuffix).replace(/\{THEME_PROP\}/g, themePropSuffix).replace(/\{FULL_BG\|(.*?)\}/, allGameFullBgUiThemes[themeGameId].indexOf(uiTheme) > -1 ? '$1' : ''); - document.head.appendChild(themeStyles); + themeStyles.innerHTML = themeStyleTemplate + .replaceAll('{THEME}', themeSuffix) + .replaceAll('{THEME_PROP}', themePropSuffix) + .replace(/\{FULL_BG\|(.*?)\}/, allGameFullBgUiThemes[themeGameId].indexOf(uiTheme) > -1 ? '$1' : ''); + task = fastdom.mutate(() => void(document.head.appendChild(themeStyles))); } - - for (let cls of el.classList) { - if (cls.startsWith('theme_')) { - if (cls === themeStylesId) - continue; - el.classList.remove(cls); + task.then(() => fastdom.mutate(() => { + for (let cls of el.classList) { + if (cls.startsWith('theme_')) { + if (cls === themeStylesId) + continue; + el.classList.remove(cls); + } } - } - el.classList.add(themeStylesId); + el.classList.add(themeStylesId); + })); }; } @@ -803,6 +819,9 @@ function getUiThemeOption(uiTheme) { return item; } +/** + * @param {HTMLElement} themedContainer + */ function updateThemedContainer(themedContainer) { if (!themedContainer) return; @@ -889,7 +908,7 @@ function getFontShadow(uiTheme, themeGameId, callback) { const img = new Image(); img.onload = function () { const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); + const context = canvas.getContext('2d', { willReadFrequently: true }); context.drawImage(img, 0, 0); pixel = context.getImageData(0, 8, 1, 1).data; uiThemeFontShadows[themeGameId][uiTheme] = [ pixel[0], pixel[1], pixel[2] ]; @@ -910,7 +929,7 @@ function getBaseBgColor(uiTheme, themeGameId, callback) { return callback(getColorRgb(pixel)); img.onload = function () { const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); + const context = canvas.getContext('2d', { willReadFrequently: true }); context.drawImage(img, 0, 0); pixel = context.getImageData(0, 0, 1, 1).data; const pixel2 = context.getImageData(4, 4, 1, 1).data; @@ -974,7 +993,7 @@ function addSystemSvgGradient(systemName, systemGameId, colors, alt) { svgGradient.setAttribute('x2', '0%'); svgGradient.setAttribute('y2', '100%'); updateSvgGradient(svgGradient, colors); - svgDefs.appendChild(svgGradient); + fastdom.mutate(() => svgDefs.appendChild(svgGradient)); } } @@ -992,9 +1011,10 @@ function addSystemSvgDropShadow(systemName, systemGameId, color) { svgDropShadow.setAttribute('dy', '1'); svgDropShadow.setAttribute('stdDeviation', '0.2'); svgDropShadow.setAttribute('flood-color', `rgb(${color})`); - - svgDropShadowFilter.appendChild(svgDropShadow); - svgDefs.appendChild(svgDropShadowFilter); + fastdom.mutate(() => { + svgDropShadowFilter.appendChild(svgDropShadow); + svgDefs.appendChild(svgDropShadowFilter); + }); } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..e5e6bee0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "include": [ + "**/*.js", + "**/*.ts", + ], + "exclude": ["vendor/"], + "compilerOptions": { + "types": ["tippy.js", "wasm-feature-detect"], + "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "module": "Preserve", /* Specify what module code is generated. */ + "resolveJsonModule": true, /* Enable importing .json files. */ + "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + "noEmit": true, /* Disable emitting files from a compilation. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": false, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + "strictNullChecks": false, /* When type checking, take into account 'null' and 'undefined'. */ + "noUncheckedIndexedAccess": true, + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} \ No newline at end of file diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 00000000..56267d7a --- /dev/null +++ b/types.d.ts @@ -0,0 +1,31 @@ +declare const wasmFeatureDetect: typeof import('wasm-feature-detect'); +declare const tippy: typeof import('tippy.js').default; + +interface ParentNode { + querySelector(tag: K): HTMLElementTagNameMap[K] | null; + querySelector(selectors: string): HTMLElement | null; + querySelectorAll(tag: K): NodeListOf; + querySelectorAll(selectors: string): NodeListOf; +} + +interface Object { + /** + * Prefer the `key in obj` syntax over this helper if the object is 1) + * definitely an object and 2) is a simple object (aka null prototype) + */ + hasOwnProperty(key: K): this is Record; + hasTitle(): this is string | Record<'title', string>; +} + +interface String { + get title(): string; +} + +declare class FastdomPromised { + clear>(task: T): void; + initialize(): void; + measure void>(task: T, context?: any): Promise>>; + mutate void>(task: T, context?: any): Promise>>; +} + +declare const fastdom: FastdomPromised; \ No newline at end of file diff --git a/vendor/fastdom-promised.js b/vendor/fastdom-promised.js new file mode 100644 index 00000000..5e391869 --- /dev/null +++ b/vendor/fastdom-promised.js @@ -0,0 +1,82 @@ +!(function() { + +/** + * Wraps fastdom in a Promise API + * for improved control-flow. + * + * @example + * + * // returning a result + * fastdom.measure(() => el.clientWidth) + * .then(result => ...); + * + * // returning promises from tasks + * fastdom.measure(() => { + * var w = el1.clientWidth; + * return fastdom.mutate(() => el2.style.width = w + 'px'); + * }).then(() => console.log('all done')); + * + * // clearing pending tasks + * var promise = fastdom.measure(...) + * fastdom.clear(promise); + * + * @type {Object} + */ +var exports = { + initialize: function() { + this._tasks = new Map(); + }, + + mutate: function(fn, ctx) { + return create(this, 'mutate', fn, ctx); + }, + + measure: function(fn, ctx) { + return create(this, 'measure', fn, ctx); + }, + + clear: function(promise) { + var tasks = this._tasks; + var task = tasks.get(promise); + this.fastdom.clear(task); + tasks.delete(promise); + } +}; + +/** + * Create a fastdom task wrapped in + * a 'cancellable' Promise. + * + * @param {FastDom} fastdom + * @param {String} type - 'measure'|'mutate' + * @param {Function} fn + * @return {Promise} + */ +function create(promised, type, fn, ctx) { + var tasks = promised._tasks; + var fastdom = promised.fastdom; + var task; + + var promise = new Promise(function(resolve, reject) { + task = fastdom[type](function() { + tasks.delete(promise); + try { + const res = ctx ? fn.call(ctx) : fn(); + if (res instanceof Promise) + res.then(resolve, reject); + else resolve(res); + } + catch (e) { reject(e); } + }, ctx); + }); + + tasks.set(promise, task); + return promise; +} + +// Expose to CJS, AMD or global +if ((typeof define)[0] == 'f') define(function() { return exports; }); +else if ((typeof module)[0] == 'o') module.exports = exports; +else window.fastdom = window.fastdom.extend(exports); + +})(); \ No newline at end of file diff --git a/vendor/fastdom.js b/vendor/fastdom.js new file mode 100644 index 00000000..5ef81a1c --- /dev/null +++ b/vendor/fastdom.js @@ -0,0 +1,244 @@ +!(function(win) { + +/** + * FastDom + * + * Eliminates layout thrashing + * by batching DOM read/write + * interactions. + * + * @author Wilson Page + * @author Kornel Lesinski + */ + +'use strict'; + +/** + * Mini logger + * + * @return {Function} + */ +var debug = 0 ? console.log.bind(console, '[fastdom]') : function() {}; + +/** + * Normalized rAF + * + * @type {Function} + */ +var raf = win.requestAnimationFrame + || win.webkitRequestAnimationFrame + || win.mozRequestAnimationFrame + || win.msRequestAnimationFrame + || function(cb) { return setTimeout(cb, 16); }; + +/** + * Initialize a `FastDom`. + * + * @constructor + */ +function FastDom() { + var self = this; + self.reads = []; + self.writes = []; + self.raf = raf.bind(win); // test hook + debug('initialized', self); +} + +FastDom.prototype = { + constructor: FastDom, + + /** + * We run this inside a try catch + * so that if any jobs error, we + * are able to recover and continue + * to flush the batch until it's empty. + * + * @param {Array} tasks + */ + runTasks: function(tasks) { + debug('run tasks'); + var task; while (task = tasks.shift()) task(); + }, + + /** + * Adds a job to the read batch and + * schedules a new frame if need be. + * + * @param {Function} fn + * @param {Object} ctx the context to be bound to `fn` (optional). + * @public + */ + measure: function(fn, ctx) { + debug('measure'); + var task = !ctx ? fn : fn.bind(ctx); + this.reads.push(task); + scheduleFlush(this); + return task; + }, + + /** + * Adds a job to the + * write batch and schedules + * a new frame if need be. + * + * @param {Function} fn + * @param {Object} ctx the context to be bound to `fn` (optional). + * @public + */ + mutate: function(fn, ctx) { + debug('mutate'); + var task = !ctx ? fn : fn.bind(ctx); + this.writes.push(task); + scheduleFlush(this); + return task; + }, + + /** + * Clears a scheduled 'read' or 'write' task. + * + * @param {Object} task + * @return {Boolean} success + * @public + */ + clear: function(task) { + debug('clear', task); + return remove(this.reads, task) || remove(this.writes, task); + }, + + /** + * Extend this FastDom with some + * custom functionality. + * + * Because fastdom must *always* be a + * singleton, we're actually extending + * the fastdom instance. This means tasks + * scheduled by an extension still enter + * fastdom's global task queue. + * + * The 'super' instance can be accessed + * from `this.fastdom`. + * + * @example + * + * var myFastdom = fastdom.extend({ + * initialize: function() { + * // runs on creation + * }, + * + * // override a method + * measure: function(fn) { + * // do extra stuff ... + * + * // then call the original + * return this.fastdom.measure(fn); + * }, + * + * ... + * }); + * + * @param {Object} props properties to mixin + * @return {FastDom} + */ + extend: function(props) { + debug('extend', props); + if (typeof props != 'object') throw new Error('expected object'); + + var child = Object.create(this); + mixin(child, props); + child.fastdom = this; + + // run optional creation hook + if (child.initialize) child.initialize(); + + return child; + }, + + // override this with a function + // to prevent Errors in console + // when tasks throw + catch: null +}; + +/** + * Schedules a new read/write + * batch if one isn't pending. + * + * @private + */ +function scheduleFlush(fastdom) { + if (!fastdom.scheduled) { + fastdom.scheduled = true; + fastdom.raf(flush.bind(null, fastdom)); + debug('flush scheduled'); + } +} + +/** + * Runs queued `read` and `write` tasks. + * + * Errors are caught and thrown by default. + * If a `.catch` function has been defined + * it is called instead. + * + * @private + */ +function flush(fastdom) { + debug('flush'); + + var writes = fastdom.writes; + var reads = fastdom.reads; + var error; + + try { + debug('flushing reads', reads.length); + fastdom.runTasks(reads); + debug('flushing writes', writes.length); + fastdom.runTasks(writes); + } catch (e) { error = e; } + + fastdom.scheduled = false; + + // If the batch errored we may still have tasks queued + if (reads.length || writes.length) scheduleFlush(fastdom); + + if (error) { + debug('task errored', error.message); + if (fastdom.catch) fastdom.catch(error); + else throw error; + } +} + +/** + * Remove an item from an Array. + * + * @param {Array} array + * @param {*} item + * @return {Boolean} + */ +function remove(array, item) { + var index = array.indexOf(item); + return !!~index && !!array.splice(index, 1); +} + +/** + * Mixin own properties of source + * object into the target. + * + * @param {Object} target + * @param {Object} source + */ +function mixin(target, source) { + for (var key in source) { + if (source.hasOwnProperty(key)) target[key] = source[key]; + } +} + +// There should never be more than +// one instance of `FastDom` in an app +var exports = win.fastdom = (win.fastdom || new FastDom()); // jshint ignore:line + +// Expose to CJS & AMD +if ((typeof define) == 'function') define(function() { return exports; }); +else if ((typeof module) == 'object') module.exports = exports; + +})( typeof window !== 'undefined' ? window : typeof this != 'undefined' ? this : globalThis);