Merge main (as of 10-06-2025) into rh-test (#5965)

## Summary

Merges latest changes from `main` as of 10-06-2025.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5965-Merge-main-as-of-10-06-2025-into-rh-test-2856d73d3650812cb95fd8917278a770)
by [Unito](https://www.unito.io)

---------

Signed-off-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: JakeSchroeder <jake@axiom.co>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: ComfyUI Wiki <contact@comfyui-wiki.com>
This commit is contained in:
Arjan Singh
2025-10-08 19:06:40 -07:00
committed by GitHub
parent 529a4de583
commit 5869b04e57
454 changed files with 32333 additions and 37002 deletions

View File

@@ -0,0 +1,23 @@
import type { ResultItem } from '@/schemas/apiSchema'
const hasAnnotation = (filepath: string): boolean =>
/\[(input|output|temp)\]/i.test(filepath)
const createAnnotation = (filepath: string, rootFolder = 'input'): string =>
!hasAnnotation(filepath) && rootFolder !== 'input' ? ` [${rootFolder}]` : ''
const createPath = (filename: string, subfolder = ''): string =>
subfolder ? `${subfolder}/${filename}` : filename
/** Creates annotated filepath in format used by folder_paths.py */
export function createAnnotatedPath(
item: string | ResultItem,
options: { rootFolder?: string; subfolder?: string } = {}
): string {
const { rootFolder = 'input', subfolder } = options
if (typeof item === 'string')
return `${createPath(item, subfolder)}${createAnnotation(item, rootFolder)}`
return `${createPath(item.filename ?? '', item.subfolder)}${
item.type ? createAnnotation(item.type, rootFolder) : ''
}`
}

View File

@@ -0,0 +1,13 @@
import { electronAPI } from '@/utils/envUtil'
import { isValidUrl } from '@/utils/formatUtil'
/**
* Check if a mirror is reachable from the electron App.
* @param mirror - The mirror to check.
* @returns True if the mirror is reachable, false otherwise.
*/
export const checkMirrorReachable = async (mirror: string) => {
return (
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
)
}

View File

@@ -18,7 +18,7 @@ export class ExecutableGroupNodeChildDTO extends ExecutableNodeDTO {
subgraphNodePath: readonly NodeId[],
/** A flattened map of all DTOs in this node network. Subgraph instances have been expanded into their inner nodes. */
nodesByExecutionId: Map<ExecutionId, ExecutableLGraphNode>,
/** The actual subgraph instance that contains this node, otherise undefined. */
/** The actual subgraph instance that contains this node, otherwise undefined. */
subgraphNode?: SubgraphNode | undefined,
groupNodeHandler?: GroupNodeHandler
) {

View File

@@ -1,475 +0,0 @@
import type { ResultItem } from '@/schemas/apiSchema'
import type { operations } from '@/types/comfyRegistryTypes'
export function formatCamelCase(str: string): string {
// Check if the string is camel case
const isCamelCase = /^([A-Z][a-z]*)+$/.test(str)
if (!isCamelCase) {
return str // Return original string if not camel case
}
// Split the string into words, keeping acronyms together
const words = str.split(/(?=[A-Z][a-z])|\d+/)
// Process each word
const processedWords = words.map((word) => {
// If the word is all uppercase and longer than one character, it's likely an acronym
if (word.length > 1 && word === word.toUpperCase()) {
return word // Keep acronyms as is
}
// For other words, ensure the first letter is capitalized
return word.charAt(0).toUpperCase() + word.slice(1)
})
// Join the words with spaces
return processedWords.join(' ')
}
export function appendJsonExt(path: string) {
if (!path.toLowerCase().endsWith('.json')) {
path += '.json'
}
return path
}
export function highlightQuery(text: string, query: string) {
if (!query) return text
// Escape special regex characters in the query string
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${escapedQuery})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}
export function formatNumberWithSuffix(
num: number,
{
precision = 1,
roundToInt = false
}: { precision?: number; roundToInt?: boolean } = {}
): string {
const suffixes = ['', 'k', 'm', 'b', 't']
const absNum = Math.abs(num)
if (absNum < 1000) {
return roundToInt ? Math.round(num).toString() : num.toFixed(precision)
}
const exp = Math.min(Math.floor(Math.log10(absNum) / 3), suffixes.length - 1)
const formattedNum = (num / Math.pow(1000, exp)).toFixed(precision)
return `${formattedNum}${suffixes[exp]}`
}
export function formatSize(value?: number) {
if (value === null || value === undefined) {
return '-'
}
const bytes = value
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
/**
* Returns various filename components.
* Example:
* - fullFilename: 'file.txt'
* - filename: 'file'
* - suffix: 'txt'
*/
function getFilenameDetails(fullFilename: string) {
if (fullFilename.includes('.')) {
return {
filename: fullFilename.split('.').slice(0, -1).join('.'),
suffix: fullFilename.split('.').pop() ?? null
}
} else {
return { filename: fullFilename, suffix: null }
}
}
/**
* Returns various path components.
* Example:
* - path: 'dir/file.txt'
* - directory: 'dir'
* - fullFilename: 'file.txt'
* - filename: 'file'
* - suffix: 'txt'
*/
export function getPathDetails(path: string) {
const directory = path.split('/').slice(0, -1).join('/')
const fullFilename = path.split('/').pop() ?? path
return { directory, fullFilename, ...getFilenameDetails(fullFilename) }
}
/**
* Normalizes a string to be used as an i18n key.
* Replaces dots with underscores.
*/
export function normalizeI18nKey(key: string) {
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
}
/**
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
* @param input The dynamic prompt to process
* @returns
*/
export function processDynamicPrompt(input: string): string {
/*
* Strips C-style line and block comments from a string
*/
function stripComments(str: string) {
return str.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
}
let i = 0
let result = ''
input = stripComments(input)
const handleEscape = () => {
const nextChar = input[i++]
return '\\' + nextChar
}
function parseChoiceBlock() {
// Parse the content inside {}
const options: string[] = []
let choice = ''
let depth = 0
while (i < input.length) {
const char = input[i++]
if (char === '\\') {
choice += handleEscape()
continue
} else if (char === '{') {
depth++
} else if (char === '}') {
if (!depth) break
depth--
} else if (char === '|') {
if (!depth) {
options.push(choice)
choice = ''
continue
}
}
choice += char
}
options.push(choice)
const chosenOption = options[Math.floor(Math.random() * options.length)]
return processDynamicPrompt(chosenOption)
}
while (i < input.length) {
const char = input[i++]
if (char === '\\') {
result += handleEscape()
} else if (char === '{') {
result += parseChoiceBlock()
} else {
result += char
}
}
return result.replace(/\\([{}|])/g, '$1')
}
export function isValidUrl(url: string): boolean {
try {
new URL(url)
return true
} catch {
return false
}
}
const hasAnnotation = (filepath: string): boolean =>
/\[(input|output|temp)\]/i.test(filepath)
const createAnnotation = (filepath: string, rootFolder = 'input'): string =>
!hasAnnotation(filepath) && rootFolder !== 'input' ? ` [${rootFolder}]` : ''
const createPath = (filename: string, subfolder = ''): string =>
subfolder ? `${subfolder}/${filename}` : filename
/** Creates annotated filepath in format used by folder_paths.py */
export function createAnnotatedPath(
item: string | ResultItem,
options: { rootFolder?: string; subfolder?: string } = {}
): string {
const { rootFolder = 'input', subfolder } = options
if (typeof item === 'string')
return `${createPath(item, subfolder)}${createAnnotation(item, rootFolder)}`
return `${createPath(item.filename ?? '', item.subfolder)}${
item.type ? createAnnotation(item.type, rootFolder) : ''
}`
}
/**
* Parses a filepath into its filename and subfolder components.
*
* @example
* parseFilePath('folder/file.txt') // → { filename: 'file.txt', subfolder: 'folder' }
* parseFilePath('/folder/file.txt') // → { filename: 'file.txt', subfolder: 'folder' }
* parseFilePath('file.txt') // → { filename: 'file.txt', subfolder: '' }
* parseFilePath('folder//file.txt') // → { filename: 'file.txt', subfolder: 'folder' }
*
* @param filepath The filepath to parse
* @returns Object containing filename and subfolder
*/
export function parseFilePath(filepath: string): {
filename: string
subfolder: string
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
const normalizedPath = filepath
.replace(/[\\/]+/g, '/') // Normalize path separators
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash
const lastSlashIndex = normalizedPath.lastIndexOf('/')
if (lastSlashIndex === -1) {
return {
filename: normalizedPath,
subfolder: ''
}
}
return {
filename: normalizedPath.slice(lastSlashIndex + 1),
subfolder: normalizedPath.slice(0, lastSlashIndex)
}
}
// Simple date formatter
const parts = {
d: (d: Date) => d.getDate(),
M: (d: Date) => d.getMonth() + 1,
h: (d: Date) => d.getHours(),
m: (d: Date) => d.getMinutes(),
s: (d: Date) => d.getSeconds()
}
const format =
Object.keys(parts)
.map((k) => k + k + '?')
.join('|') + '|yyy?y?'
export function formatDate(text: string, date: Date) {
return text.replace(new RegExp(format, 'g'), (text: string): string => {
if (text === 'yy') return (date.getFullYear() + '').substring(2)
if (text === 'yyyy') return date.getFullYear().toString()
if (text[0] in parts) {
const p = parts[text[0] as keyof typeof parts](date)
return (p + '').padStart(text.length, '0')
}
return text
})
}
/**
* Generate a cache key from parameters
* Sorts the parameters to ensure consistent keys regardless of parameter order
*/
export const paramsToCacheKey = (params: unknown): string => {
if (typeof params === 'string') return params
if (typeof params === 'object' && params !== null)
return Object.keys(params)
.sort((a, b) => a.localeCompare(b))
.map((key) => `${key}:${params[key as keyof typeof params]}`)
.join('&')
return String(params)
}
/**
* Generates a RFC4122 compliant UUID v4 using the native crypto API when available
* @returns A properly formatted UUID string
*/
export const generateUUID = (): string => {
// Use native crypto.randomUUID() if available (modern browsers)
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID()
}
// Fallback implementation for older browsers
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
/**
* Checks if a URL is a Civitai model URL
* @example
* isCivitaiModelUrl('https://civitai.com/api/download/models/1234567890') // true
* isCivitaiModelUrl('https://civitai.com/api/v1/models/1234567890') // true
* isCivitaiModelUrl('https://civitai.com/api/v1/models-versions/15342') // true
* isCivitaiModelUrl('https://example.com/model.safetensors') // false
*/
export const isCivitaiModelUrl = (url: string): boolean => {
if (!isValidUrl(url)) return false
if (!url.includes('civitai.com')) return false
const urlObj = new URL(url)
const pathname = urlObj.pathname
return (
/^\/api\/download\/models\/(\d+)$/.test(pathname) ||
/^\/api\/v1\/models\/(\d+)$/.test(pathname) ||
/^\/api\/v1\/models-versions\/(\d+)$/.test(pathname)
)
}
/**
* Converts a Hugging Face download URL to a repository page URL
* @param url The download URL to convert
* @returns The repository page URL or the original URL if conversion fails
* @example
* downloadUrlToHfRepoUrl(
* 'https://huggingface.co/bfl/FLUX.1/resolve/main/flux1-canny-dev.safetensors?download=true'
* ) // https://huggingface.co/bfl/FLUX.1
*/
export const downloadUrlToHfRepoUrl = (url: string): string => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
// Use regex to match everything before /resolve/ or /blob/
const regex = /^(.*?)(?:\/resolve\/|\/blob\/|$)/
const repoPathMatch = regex.exec(pathname)
// Extract the repository path and remove leading slash if present
const repoPath = repoPathMatch?.[1]?.replace(/^\//, '') || ''
return `https://huggingface.co/${repoPath}`
} catch (error) {
return url
}
}
/**
* Converts Metronome's integer amount back to a formatted currency string.
* For USD, converts from cents to dollars.
* For all other currencies (including custom pricing units), returns the amount as is.
* This is specific to Metronome's API requirements.
*
* @param amount - The amount in Metronome's integer format (cents for USD, base units for others)
* @param currency - The currency to convert
* @returns The formatted amount in currency with 2 decimal places for USD
* @example
* formatMetronomeCurrency(123, 'usd') // returns "1.23" (cents to USD)
* formatMetronomeCurrency(1000, 'jpy') // returns "1000" (yen)
*/
export function formatMetronomeCurrency(
amount: number,
currency: string
): string {
if (currency === 'usd') {
return (amount / 100).toFixed(2)
}
return amount.toString()
}
/**
* Converts a USD amount to microdollars (1/1,000,000 of a dollar).
* This conversion is commonly used in financial systems to avoid floating-point precision issues
* by representing monetary values as integers.
*
* @param usd - The amount in US dollars to convert
* @returns The amount in microdollars (multiplied by 1,000,000)
* @example
* usdToMicros(1.23) // returns 1230000
*/
export function usdToMicros(usd: number): number {
return Math.round(usd * 1_000_000)
}
/**
* Converts URLs in a string to HTML links.
* @param text - The string to convert
* @returns The string with URLs converted to HTML links
* @example
* linkifyHtml('Visit https://example.com for more info') // returns 'Visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="text-primary-400 hover:underline">https://example.com</a> for more info'
*/
export function linkifyHtml(text: string): string {
if (!text) return ''
const urlRegex =
/(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%?=~_|])|(\bwww\.[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%?=~_|])/gi
return text.replace(urlRegex, (_match, p1, _p2, p3) => {
const url = p1 || p3
const href = p3 ? `http://${url}` : url
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="text-primary-400 hover:underline">${url}</a>`
})
}
/**
* Converts newline characters to HTML <br> tags.
* @param text - The string to convert
* @returns The string with newline characters converted to <br> tags
* @example
* nl2br('Hello\nWorld') // returns 'Hello<br />World'
*/
export function nl2br(text: string): string {
if (!text) return ''
return text.replace(/\n/g, '<br />')
}
/**
* Converts a version string to an anchor-safe format by replacing dots with dashes.
* @param version The version string (e.g., "1.0.0", "2.1.3-beta.1")
* @returns The anchor-safe version string (e.g., "v1-0-0", "v2-1-3-beta-1")
* @example
* formatVersionAnchor("1.0.0") // returns "v1-0-0"
* formatVersionAnchor("2.1.3-beta.1") // returns "v2-1-3-beta-1"
*/
export function formatVersionAnchor(version: string): string {
return `v${version.replace(/\./g, '-')}`
}
/**
* Supported locale types for the application (from OpenAPI schema)
*/
type SupportedLocale = NonNullable<
operations['getReleaseNotes']['parameters']['query']['locale']
>
/**
* Converts a string to a valid locale type with 'en' as default
* @param locale - The locale string to validate and convert
* @returns A valid SupportedLocale type, defaults to 'en' if invalid
* @example
* stringToLocale('fr') // returns 'fr'
* stringToLocale('invalid') // returns 'en'
* stringToLocale('') // returns 'en'
*/
export function stringToLocale(locale: string): SupportedLocale {
const supportedLocales: SupportedLocale[] = [
'en',
'es',
'fr',
'ja',
'ko',
'ru',
'zh'
]
return supportedLocales.includes(locale as SupportedLocale)
? (locale as SupportedLocale)
: 'en'
}

View File

@@ -0,0 +1,98 @@
/**
* Whitelisting helper for enabling SSO on safe, local-only hosts.
*
* Built-ins (always allowed):
* • 'localhost' and any subdomain of '.localhost' (e.g., app.localhost)
* • IPv4 loopback 127.0.0.0/8 (e.g., 127.0.0.1, 127.1.2.3)
* • IPv6 loopback ::1 (supports compressed/expanded textual forms)
*
* No environment variables are used. To add more exact hostnames,
* edit HOST_WHITELIST below.
*/
const HOST_WHITELIST: string[] = ['localhost']
/** Normalize for comparison: lowercase, strip port/brackets, trim trailing dot. */
export function normalizeHost(input: string): string {
let h = (input || '').trim().toLowerCase()
// Trim a trailing dot: 'localhost.' -> 'localhost'
h = h.replace(/\.$/, '')
// Remove ':port' safely.
// Case 1: [IPv6]:port
const mBracket = h.match(/^\[([^\]]+)\]:(\d+)$/)
if (mBracket) {
h = mBracket[1] // keep only the host inside the brackets
} else {
// Case 2: hostname/IPv4:port (exactly one ':')
const mPort = h.match(/^([^:]+):(\d+)$/)
if (mPort) h = mPort[1]
}
// Strip any remaining brackets (e.g., '[::1]' -> '::1')
h = h.replace(/^\[|\]$/g, '')
return h
}
/** Public check used by the UI. */
export function isHostWhitelisted(rawHost: string): boolean {
const host = normalizeHost(rawHost)
if (isLocalhostLabel(host)) return true
if (isIPv4Loopback(host)) return true
if (isIPv6Loopback(host)) return true
if (isComfyOrgHost(host)) return true
const normalizedList = HOST_WHITELIST.map(normalizeHost)
return normalizedList.includes(host)
}
/* -------------------- Helpers -------------------- */
function isLocalhostLabel(h: string): boolean {
// 'localhost' and any subdomain (e.g., 'app.localhost')
return h === 'localhost' || h.endsWith('.localhost')
}
const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|0?\\d?\\d)'
const V4_LOOPBACK_RE = new RegExp(
'^127\\.' + IPV4_OCTET + '\\.' + IPV4_OCTET + '\\.' + IPV4_OCTET + '$'
)
function isIPv4Loopback(h: string): boolean {
// 127/8 with strict 0255 octets (leading zeros allowed, e.g., 127.000.000.001)
return V4_LOOPBACK_RE.test(h)
}
// Fully expanded IPv6 loopback: 0:0:0:0:0:0:0:1 (allow leading zeros up to 4 chars)
const V6_FULL_LOOPBACK_RE = /^(?:0{1,4}:){7}0{0,3}1$/i
// Compressed IPv6 loopback forms around '::' with only zero groups before the final :1
// - Left side: zero groups separated by ':' (no trailing colon required)
// - Right side: zero groups each followed by ':' (so the final ':1' is provided by the pattern)
// The final group is exactly value 1, with up to 3 leading zeros (e.g., '0001').
const V6_COMPRESSED_LOOPBACK_RE =
/^((?:0{1,4}(?::0{1,4}){0,6})?)::((?:0{1,4}:){0,6})0{0,3}1$/i
function isIPv6Loopback(h: string): boolean {
// Exact full form: 0:0:0:0:0:0:0:1 (with up to 3 leading zeros on the final "1" group)
if (V6_FULL_LOOPBACK_RE.test(h)) return true
// Compressed forms that still equal ::1 (e.g., ::1, ::0001, 0:0::1, ::0:1, etc.)
const m = h.match(V6_COMPRESSED_LOOPBACK_RE)
if (!m) return false
// Count explicit zero groups on each side of '::' to ensure at least one group is compressed.
// (leftCount + rightCount) must be ≤ 6 so that the total expanded groups = 8.
const leftCount = m[1] ? m[1].match(/0{1,4}:/gi)?.length ?? 0 : 0
const rightCount = m[2] ? m[2].match(/0{1,4}:/gi)?.length ?? 0 : 0
// Require that at least one group was actually compressed: i.e., leftCount + rightCount ≤ 6.
return leftCount + rightCount <= 6
}
const COMFY_ORG_HOST = /\.comfy\.org$/
function isComfyOrgHost(h: string): boolean {
return COMFY_ORG_HOST.test(h)
}

View File

@@ -70,7 +70,7 @@ function extendLink(link: SerialisedLLinkArray) {
* makes logical sense. Can apply fixes when passed the `fix` argument as true.
*
* Note that fixes are a best-effort attempt. Seems to get it correct in most cases, but there is a
* chance it correct an anomoly that results in placing an incorrect link (say, if there were two
* chance it correct an anomaly that results in placing an incorrect link (say, if there were two
* links in the data). Users should take care to not overwrite work until manually checking the
* result.
*/

View File

@@ -1,76 +0,0 @@
import axios from 'axios'
import { electronAPI } from './envUtil'
import { isValidUrl } from './formatUtil'
const VALID_STATUS_CODES = [200, 201, 301, 302, 307, 308]
export const checkUrlReachable = async (url: string): Promise<boolean> => {
try {
const response = await axios.head(url)
// Additional check for successful response
return VALID_STATUS_CODES.includes(response.status)
} catch {
return false
}
}
/**
* Check if a mirror is reachable from the electron App.
* @param mirror - The mirror to check.
* @returns True if the mirror is reachable, false otherwise.
*/
export const checkMirrorReachable = async (mirror: string) => {
return (
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
)
}
/**
* Checks if the user is likely in mainland China by:
* 1. Checking navigator.language
* 2. Testing connectivity to commonly blocked services
* 3. Testing latency to China-specific domains
*/
export async function isInChina(): Promise<boolean> {
// Quick check based on language/locale
const isChineseLocale = navigator.language.toLowerCase().startsWith('zh-cn')
try {
// Test connectivity to Google - commonly blocked in China
const googleTest = await Promise.race([
fetch('https://www.google.com', {
mode: 'no-cors',
cache: 'no-cache'
}),
new Promise((_, reject) => setTimeout(() => reject(), 2000))
])
// If Google is accessible, user is likely not in China
if (googleTest) {
return false
}
} catch {
// Google is not accessible - potential indicator of being in China
if (isChineseLocale) {
return true
}
// Additional check - test latency to a reliable Chinese domain
try {
const start = performance.now()
await fetch('https://www.baidu.com', {
mode: 'no-cors',
cache: 'no-cache'
})
const latency = performance.now() - start
// If Baidu responds quickly (<150ms), user is likely in China
return latency < 150
} catch {
// If both tests fail, default to locale check
return isChineseLocale
}
}
return false
}

29
src/utils/rafBatch.ts Normal file
View File

@@ -0,0 +1,29 @@
export function createRafBatch(run: () => void) {
let rafId: number | null = null
const schedule = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
rafId = null
run()
})
}
const cancel = () => {
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
const flush = () => {
if (rafId == null) return
cancelAnimationFrame(rafId)
rafId = null
run()
}
const isScheduled = () => rafId != null
return { schedule, cancel, flush, isScheduled }
}

View File

@@ -1,29 +0,0 @@
import { useTimeout } from '@vueuse/core'
import { type Ref, computed, ref, watch } from 'vue'
/**
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.
* If set to `false` before {@link minDuration} has passed, it uses a timer to delay the change.
* @param value The default value to set on this ref
* @param minDuration The minimum time that this ref must be `true` for
* @returns A custom boolean vue ref with a minimum activation time
*/
export function useMinLoadingDurationRef(
value: Ref<boolean>,
minDuration = 250
) {
const current = ref(value.value)
const { ready, start } = useTimeout(minDuration, {
controls: true,
immediate: false
})
watch(value, (newValue) => {
if (newValue && !current.value) start()
current.value = newValue
})
return computed(() => current.value || !ready.value)
}

View File

@@ -27,7 +27,7 @@ export function applyTextReplacements(
let nodes = allNodes.filter(
(n) => n.properties?.['Node name for S&R'] === split[0]
)
// If we cant, see if there is a node with that title
// If we can't, see if there is a node with that title
if (!nodes.length) {
nodes = allNodes.filter((n) => n.title === split[0])
}