mirror of
https://github.com/fmhy/edit.git
synced 2026-01-27 10:21:02 +00:00
fix exact search (#4640)
* disable select all text when clicked * fix exact match search * small fixes * improve fuzzy search * ignore invisible characters in search * feature to navigate and scroll multiple search results in the same section * add keyboard shortcut for highlight matches and also combine nearby highlighted matches * comments --------- Co-authored-by: nbats <44333466+nbats@users.noreply.github.com>
This commit is contained in:
@@ -66,11 +66,11 @@ export const search: DefaultTheme.Config['search'] = {
|
||||
_render(src, env, md) {
|
||||
// Check if current file should be excluded from search
|
||||
const relativePath = env.relativePath || env.path || ''
|
||||
const shouldExclude = excluded.some(excludedFile =>
|
||||
relativePath.includes(excludedFile) ||
|
||||
const shouldExclude = excluded.some(excludedFile =>
|
||||
relativePath.includes(excludedFile) ||
|
||||
relativePath.endsWith(excludedFile)
|
||||
)
|
||||
|
||||
|
||||
// Return empty content for excluded files so they don't appear in search
|
||||
if (shouldExclude) {
|
||||
return ''
|
||||
@@ -86,7 +86,7 @@ export const search: DefaultTheme.Config['search'] = {
|
||||
},
|
||||
miniSearch: {
|
||||
options: {
|
||||
tokenize: (text) => text.split(/[\n\r #%*,=/:;?[\]{}()&]+/u), // simplified charset: removed [-_.@] and non-english chars (diacritics etc.)
|
||||
tokenize: (text) => text.replace(/[\u2060\u200B]/g, '').split(/[\n\r #%*,=/:;?[\]{}()&]+/u), // simplified charset: removed [-_.@] and non-english chars (diacritics etc.)
|
||||
processTerm: (term, fieldName) => {
|
||||
// biome-ignore lint/style/noParameterAssign: h
|
||||
term = term
|
||||
@@ -319,9 +319,9 @@ export const sidebar: DefaultTheme.Sidebar | DefaultTheme.NavItemWithLink[] = [
|
||||
items: [
|
||||
meta.build.nsfw
|
||||
? {
|
||||
text: '<span class="i-twemoji:no-one-under-eighteen"></span> NSFW',
|
||||
link: 'https://rentry.org/NSFW-Checkpoint'
|
||||
}
|
||||
text: '<span class="i-twemoji:no-one-under-eighteen"></span> NSFW',
|
||||
link: 'https://rentry.org/NSFW-Checkpoint'
|
||||
}
|
||||
: {},
|
||||
{
|
||||
text: '<span class="i-twemoji:warning"></span> Unsafe Sites',
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VPLocalSearchBox - Enhanced Local Search Modal Component
|
||||
*
|
||||
* Base: VitePress default local search component
|
||||
*
|
||||
* Custom Features Added:
|
||||
* ----------------------
|
||||
* 1. Fuzzy Search Toggle
|
||||
* - Toggle between exact and fuzzy matching modes
|
||||
* - Fuzzy mode includes typo tolerance and multi-word queries
|
||||
* - Searches both space-separated and dash-separated variants
|
||||
*
|
||||
* 2. Smart Highlight Merging (Fuzzy Mode)
|
||||
* - Automatically merges nearby highlights (< 20px apart) in fuzzy mode
|
||||
* - Reduces navigation tedium when multiple words are highlighted
|
||||
* - Preserves text between merged highlights
|
||||
*
|
||||
* 3. Match Navigation System
|
||||
* - Navigate through highlights with left/right arrow keys
|
||||
* - Visual controls: prev/next buttons with match counter (e.g., "2/5")
|
||||
* - Smooth scrolling to center the active match
|
||||
* - Yellow highlight for currently focused match
|
||||
*
|
||||
*/
|
||||
import localSearchIndex from '@localSearchIndex'
|
||||
import {
|
||||
computedAsync,
|
||||
@@ -22,6 +46,7 @@ import {
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
triggerRef,
|
||||
watch,
|
||||
watchEffect,
|
||||
type Ref
|
||||
@@ -46,7 +71,7 @@ const resultsEl = shallowRef<HTMLElement>()
|
||||
|
||||
const searchIndexData = shallowRef(localSearchIndex)
|
||||
|
||||
// hmr
|
||||
// Hot Module Replacement - updates search index without full page reload during development
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept('/@localSearchIndex', (m) => {
|
||||
if (m) {
|
||||
@@ -70,7 +95,12 @@ const { activate } = useFocusTrap(el, {
|
||||
})
|
||||
const { localeIndex, theme } = vitePressData
|
||||
|
||||
// Fuzzy search toggle state (default: false = exact search)
|
||||
/**
|
||||
* Fuzzy search toggle state.
|
||||
* - false: Exact phrase matching (default)
|
||||
* - true: Fuzzy matching with typo tolerance and multi-word queries
|
||||
* Persisted in localStorage for user preference across sessions.
|
||||
*/
|
||||
const isFuzzySearch = useLocalStorage('vitepress:local-search-fuzzy', false)
|
||||
|
||||
const searchIndex = computedAsync(async () =>
|
||||
@@ -80,6 +110,8 @@ const searchIndex = computedAsync(async () =>
|
||||
{
|
||||
fields: ['title', 'titles', 'text'],
|
||||
storeFields: ['title', 'titles'],
|
||||
tokenize: (text: string) =>
|
||||
text.replace(/[\u2060\u200B]/g, '').split(/[^a-zA-Z0-9\u00C0-\u00FF-]+/).filter((t) => t),
|
||||
searchOptions: {
|
||||
fuzzy: false,
|
||||
prefix: true,
|
||||
@@ -148,13 +180,18 @@ const mark = computedAsync(async () => {
|
||||
return markRaw(new Mark(resultsEl.value))
|
||||
}, null)
|
||||
|
||||
const cache = new LRUCache<string, Map<string, string>>(16) // 16 files
|
||||
// LRU cache for rendered excerpts (16 most recently viewed files)
|
||||
const cache = new LRUCache<string, Map<string, string>>(16)
|
||||
|
||||
/**
|
||||
* Main search handler - debounced to avoid excessive re-renders while typing.
|
||||
* Watches: search index, filter text, detail view toggle, and fuzzy search mode.
|
||||
*/
|
||||
debouncedWatch(
|
||||
() => [searchIndex.value, filterText.value, showDetailedList.value, isFuzzySearch.value] as const,
|
||||
async ([index, filterTextValue, showDetailedListValue, fuzzySearchValue], old, onCleanup) => {
|
||||
if (old?.[0] !== index) {
|
||||
// in case of hmr
|
||||
// Clear cache on index change (e.g., locale switch or HMR update)
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@@ -165,62 +202,53 @@ debouncedWatch(
|
||||
|
||||
if (!index) return
|
||||
|
||||
// Search with dynamic fuzzy option
|
||||
/**
|
||||
* Configure search options based on fuzzy mode.
|
||||
* Fuzzy search splits multi-word queries and searches for:
|
||||
* 1. All words present (AND) - matches "PC Optimization Hub"
|
||||
* 2. Dashed version - matches "PC-Optimization-Hub"
|
||||
* This allows flexible matching of space-separated or dash-separated content.
|
||||
*/
|
||||
const searchOptions = {
|
||||
fuzzy: isFuzzySearch.value ? 0.2 : false
|
||||
}
|
||||
let query: any = filterTextValue
|
||||
|
||||
if (isFuzzySearch.value) {
|
||||
const parts = filterTextValue.split(/\s+/).filter((p) => p)
|
||||
if (parts.length > 0) {
|
||||
const dashed = parts.join('-')
|
||||
query = {
|
||||
combineWith: 'OR',
|
||||
queries: [
|
||||
{
|
||||
queries: parts,
|
||||
combineWith: 'AND',
|
||||
fuzzy: 0.2
|
||||
},
|
||||
{
|
||||
queries: [dashed],
|
||||
combineWith: 'AND',
|
||||
fuzzy: 0.2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.value = index
|
||||
.search(filterTextValue, searchOptions)
|
||||
.search(query, searchOptions)
|
||||
.slice(0, 16) as (SearchResult & Result)[]
|
||||
enableNoResults.value = true
|
||||
|
||||
// Highlighting
|
||||
// Fetch and process excerpts for detailed view highlighting
|
||||
const mods = showDetailedListValue
|
||||
? await Promise.all(results.value.map((r) => fetchExcerpt(r.id)))
|
||||
: []
|
||||
if (canceled) return
|
||||
for (const { id, mod } of mods) {
|
||||
const mapId = id.slice(0, id.indexOf('#'))
|
||||
let map = cache.get(mapId)
|
||||
if (map) continue
|
||||
map = new Map()
|
||||
cache.set(mapId, map)
|
||||
const comp = mod.default ?? mod
|
||||
if (comp?.render || comp?.setup) {
|
||||
const app = createApp(comp)
|
||||
app.use(FloatingVue)
|
||||
app.component('Tooltip', Tooltip)
|
||||
// Silence warnings about missing components
|
||||
app.config.warnHandler = () => {}
|
||||
app.provide(dataSymbol, vitePressData)
|
||||
Object.defineProperties(app.config.globalProperties, {
|
||||
$frontmatter: {
|
||||
get() {
|
||||
return vitePressData.frontmatter.value
|
||||
}
|
||||
},
|
||||
$params: {
|
||||
get() {
|
||||
return vitePressData.page.value.params
|
||||
}
|
||||
}
|
||||
})
|
||||
const div = document.createElement('div')
|
||||
app.mount(div)
|
||||
const headings = div.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||
headings.forEach((el) => {
|
||||
const href = el.querySelector('a')?.getAttribute('href')
|
||||
const anchor = href?.startsWith('#') && href.slice(1)
|
||||
if (!anchor) return
|
||||
let html = ''
|
||||
while ((el = el.nextElementSibling!) && !/^h[1-6]$/i.test(el.tagName))
|
||||
html += el.outerHTML
|
||||
map!.set(anchor, html)
|
||||
})
|
||||
app.unmount()
|
||||
}
|
||||
if (canceled) return
|
||||
}
|
||||
|
||||
await processExcerpts(mods, vitePressData, () => canceled)
|
||||
if (canceled) return
|
||||
|
||||
const terms = new Set<string>()
|
||||
|
||||
@@ -228,12 +256,19 @@ debouncedWatch(
|
||||
const [id, anchor] = r.id.split('#')
|
||||
const map = cache.get(id)
|
||||
const text = map?.get(anchor) ?? ''
|
||||
for (const term in r.match) {
|
||||
terms.add(term)
|
||||
if (isFuzzySearch.value) {
|
||||
for (const term in r.match) {
|
||||
terms.add(term)
|
||||
}
|
||||
}
|
||||
return { ...r, text }
|
||||
})
|
||||
|
||||
if (!isFuzzySearch.value) {
|
||||
terms.add(filterTextValue)
|
||||
results.value = filterResults(results.value, filterTextValue)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
if (canceled) return
|
||||
|
||||
@@ -245,18 +280,187 @@ debouncedWatch(
|
||||
})
|
||||
})
|
||||
|
||||
const excerpts = el.value?.querySelectorAll('.result .excerpt') ?? []
|
||||
/**
|
||||
* Custom feature: Merge nearby highlights in fuzzy mode.
|
||||
* Combines individual word highlights that are close together (< 20px apart)
|
||||
* into single continuous highlights, reducing navigation tedium.
|
||||
*/
|
||||
if (isFuzzySearch.value) {
|
||||
await mergeNearbyMarks()
|
||||
}
|
||||
|
||||
const excerpts = Array.from(el.value?.querySelectorAll('.result .excerpt') ?? [])
|
||||
for (const excerpt of excerpts) {
|
||||
excerpt
|
||||
.querySelector('mark[data-markjs="true"]')
|
||||
?.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom feature: Initialize match navigation state.
|
||||
* Collects all highlight marks in each result for prev/next navigation.
|
||||
* Each result tracks its own array of marks and current position.
|
||||
*/
|
||||
const newResultMarks = new Map<number, HTMLElement[]>()
|
||||
const newCurrentMarkIndex = new Map<number, number>()
|
||||
|
||||
results.value.forEach((_, index) => {
|
||||
const item = el.value?.querySelector(`#localsearch-item-${index}`)
|
||||
const marks = Array.from(item?.querySelectorAll('.excerpt mark[data-markjs="true"]') ?? []) as HTMLElement[]
|
||||
if (marks.length > 0) {
|
||||
newResultMarks.set(index, marks)
|
||||
newCurrentMarkIndex.set(index, 0)
|
||||
}
|
||||
})
|
||||
resultMarks.value = newResultMarks
|
||||
currentMarkIndex.value = newCurrentMarkIndex
|
||||
|
||||
// FIXME: without this whole page scrolls to the bottom
|
||||
resultsEl.value?.firstElementChild?.scrollIntoView({ block: 'start' })
|
||||
},
|
||||
{ debounce: 200, immediate: true }
|
||||
)
|
||||
|
||||
/* Custom Feature: Match Navigation State */
|
||||
const resultMarks = shallowRef<Map<number, HTMLElement[]>>(new Map())
|
||||
const currentMarkIndex = shallowRef<Map<number, number>>(new Map())
|
||||
|
||||
/**
|
||||
* Merges adjacent highlight marks that are visually close together.
|
||||
* This reduces the number of navigation stops in fuzzy mode where
|
||||
* each individual word match would otherwise be a separate highlight.
|
||||
*
|
||||
* Merging criteria:
|
||||
* - Marks must be on the same line (within 5px vertical distance)
|
||||
* - Marks must be close horizontally (< 20px apart)
|
||||
*/
|
||||
async function mergeNearbyMarks() {
|
||||
const excerpts = Array.from(el.value?.querySelectorAll('.result .excerpt') ?? [])
|
||||
|
||||
for (const excerpt of excerpts) {
|
||||
const marks = Array.from(excerpt.querySelectorAll('mark[data-markjs="true"]')) as HTMLElement[]
|
||||
if (marks.length <= 1) continue
|
||||
|
||||
// Process marks to merge those within 20 characters of each other
|
||||
let i = 0
|
||||
while (i < marks.length - 1) {
|
||||
const currentMark = marks[i]
|
||||
const nextMark = marks[i + 1]
|
||||
|
||||
// Calculate distance between marks
|
||||
const currentEnd = currentMark.offsetLeft + currentMark.offsetWidth
|
||||
const nextStart = nextMark.offsetLeft
|
||||
const distance = nextStart - currentEnd
|
||||
|
||||
// Also check if they're on the same line (similar offsetTop)
|
||||
const onSameLine = Math.abs(currentMark.offsetTop - nextMark.offsetTop) < 5
|
||||
|
||||
// Merge if they're close (within 20px) and on the same line
|
||||
if (distance >= 0 && distance < 20 && onSameLine) {
|
||||
// Create a merged mark element
|
||||
const textBetween = getTextBetweenMarks(currentMark, nextMark)
|
||||
const mergedText = currentMark.textContent + textBetween + nextMark.textContent
|
||||
currentMark.textContent = mergedText
|
||||
|
||||
// Remove the next mark
|
||||
nextMark.remove()
|
||||
marks.splice(i + 1, 1)
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the plain text content between two mark elements.
|
||||
* Used when merging adjacent highlights to preserve the spacing/text between them.
|
||||
*/
|
||||
function getTextBetweenMarks(mark1: HTMLElement, mark2: HTMLElement): string {
|
||||
const parent = mark1.parentNode
|
||||
if (!parent) return ''
|
||||
|
||||
let text = ''
|
||||
let node: Node | null = mark1.nextSibling
|
||||
|
||||
while (node && node !== mark2) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += node.textContent || ''
|
||||
}
|
||||
node = node.nextSibling
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom feature: Navigate to the next highlighted match in the current result.
|
||||
* Cycles back to the first match when reaching the end.
|
||||
* Smoothly scrolls the excerpt to center the highlighted match.
|
||||
*/
|
||||
function nextMatch(index: number) {
|
||||
const marks = resultMarks.value.get(index)
|
||||
let curr = currentMarkIndex.value.get(index) ?? 0
|
||||
if (!marks) return
|
||||
|
||||
// Remove 'current' class from previous mark
|
||||
marks[curr].classList.remove('current')
|
||||
|
||||
curr = (curr + 1) % marks.length
|
||||
currentMarkIndex.value.set(index, curr)
|
||||
triggerRef(currentMarkIndex)
|
||||
|
||||
// Add 'current' class to new mark
|
||||
const newMark = marks[curr]
|
||||
newMark.classList.add('current')
|
||||
|
||||
const excerpt = newMark.closest('.excerpt')
|
||||
if (excerpt) {
|
||||
const markTop = newMark.offsetTop
|
||||
const markHeight = newMark.offsetHeight
|
||||
const excerptHeight = excerpt.clientHeight
|
||||
|
||||
excerpt.scrollTo({
|
||||
top: markTop - excerptHeight / 2 + markHeight / 2,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom feature: Navigate to the previous highlighted match in the current result.
|
||||
* Cycles to the last match when going before the first.
|
||||
* Smoothly scrolls the excerpt to center the highlighted match.
|
||||
*/
|
||||
function prevMatch(index: number) {
|
||||
const marks = resultMarks.value.get(index)
|
||||
let curr = currentMarkIndex.value.get(index) ?? 0
|
||||
if (!marks) return
|
||||
|
||||
// Remove 'current' class from previous mark
|
||||
marks[curr].classList.remove('current')
|
||||
|
||||
curr = (curr - 1 + marks.length) % marks.length
|
||||
currentMarkIndex.value.set(index, curr)
|
||||
triggerRef(currentMarkIndex)
|
||||
|
||||
// Add 'current' class to new mark
|
||||
const newMark = marks[curr]
|
||||
newMark.classList.add('current')
|
||||
|
||||
const excerpt = newMark.closest('.excerpt')
|
||||
if (excerpt) {
|
||||
const markTop = newMark.offsetTop
|
||||
const markHeight = newMark.offsetHeight
|
||||
const excerptHeight = excerpt.clientHeight
|
||||
|
||||
excerpt.scrollTo({
|
||||
top: markTop - excerptHeight / 2 + markHeight / 2,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExcerpt(id: string) {
|
||||
const file = pathToFile(id.slice(0, id.indexOf('#')))
|
||||
try {
|
||||
@@ -268,6 +472,64 @@ async function fetchExcerpt(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function processExcerpts(
|
||||
mods: { id: string; mod: any }[],
|
||||
vitePressData: any,
|
||||
isCanceled: () => boolean
|
||||
) {
|
||||
for (const { id, mod } of mods) {
|
||||
if (isCanceled()) return
|
||||
const mapId = id.slice(0, id.indexOf('#'))
|
||||
let map = cache.get(mapId)
|
||||
if (map) continue
|
||||
map = new Map()
|
||||
cache.set(mapId, map)
|
||||
const comp = mod.default ?? mod
|
||||
if (comp?.render || comp?.setup) {
|
||||
const app = createApp(comp)
|
||||
app.use(FloatingVue)
|
||||
app.component('Tooltip', Tooltip)
|
||||
app.config.warnHandler = () => {}
|
||||
app.provide(dataSymbol, vitePressData)
|
||||
Object.defineProperties(app.config.globalProperties, {
|
||||
$frontmatter: {
|
||||
get() {
|
||||
return vitePressData.frontmatter.value
|
||||
}
|
||||
},
|
||||
$params: {
|
||||
get() {
|
||||
return vitePressData.page.value.params
|
||||
}
|
||||
}
|
||||
})
|
||||
const div = document.createElement('div')
|
||||
app.mount(div)
|
||||
const headings = div.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||
headings.forEach((el) => {
|
||||
const href = el.querySelector('a')?.getAttribute('href')
|
||||
const anchor = href?.startsWith('#') && href.slice(1)
|
||||
if (!anchor) return
|
||||
let html = ''
|
||||
while ((el = el.nextElementSibling!) && !/^h[1-6]$/i.test(el.tagName))
|
||||
html += el.outerHTML
|
||||
map!.set(anchor, html)
|
||||
})
|
||||
app.unmount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterResults(results: (SearchResult & Result)[], filterTextValue: string) {
|
||||
return results.filter((r) => {
|
||||
const phrase = filterTextValue.toLowerCase()
|
||||
const inText = r.text?.toLowerCase().includes(phrase)
|
||||
const inTitle = r.title.toLowerCase().includes(phrase)
|
||||
const inTitles = r.titles.some((t) => t.toLowerCase().includes(phrase))
|
||||
return inText || inTitle || inTitles
|
||||
})
|
||||
}
|
||||
|
||||
/* Search input focus */
|
||||
|
||||
const searchInput = ref<HTMLInputElement>()
|
||||
@@ -285,7 +547,7 @@ onMounted(() => {
|
||||
|
||||
function onSearchBarClick(event: PointerEvent) {
|
||||
if (event.pointerType === 'mouse') {
|
||||
focusSearchInput()
|
||||
focusSearchInput(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +612,27 @@ onKeyStroke('Escape', () => {
|
||||
emit('close')
|
||||
})
|
||||
|
||||
/**
|
||||
* Custom feature: Keyboard navigation for cycling through highlights.
|
||||
* Left/Right arrow keys navigate prev/next match within the selected result.
|
||||
* Only active when detailed view is enabled and matches exist.
|
||||
*/
|
||||
onKeyStroke('ArrowLeft', (event) => {
|
||||
// Navigate to previous match - only when viewing detailed excerpts with highlights
|
||||
if (showDetailedList.value && selectedIndex.value >= 0 && (resultMarks.value.get(selectedIndex.value)?.length ?? 0) > 0) {
|
||||
event.preventDefault()
|
||||
prevMatch(selectedIndex.value)
|
||||
}
|
||||
})
|
||||
|
||||
onKeyStroke('ArrowRight', (event) => {
|
||||
// Navigate to next match - only when viewing detailed excerpts with highlights
|
||||
if (showDetailedList.value && selectedIndex.value >= 0 && (resultMarks.value.get(selectedIndex.value)?.length ?? 0) > 0) {
|
||||
event.preventDefault()
|
||||
nextMatch(selectedIndex.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Translations
|
||||
const defaultTranslations: { modal: ModalTranslations } = {
|
||||
modal: {
|
||||
@@ -571,6 +854,15 @@ function onMouseMove(e: MouseEvent) {
|
||||
</div>
|
||||
<div class="excerpt-gradient-bottom" />
|
||||
<div class="excerpt-gradient-top" />
|
||||
<div v-if="(resultMarks.get(index)?.length ?? 0) > 1" class="excerpt-actions" @click.prevent.stop @mousedown.prevent.stop>
|
||||
<button class="match-nav-button" @click.prevent.stop="prevMatch(index)" title="Previous match">
|
||||
<span class="vpi-chevron-left navigate-icon" />
|
||||
</button>
|
||||
<span class="match-count">{{ (currentMarkIndex.get(index) ?? 0) + 1 }}/{{ resultMarks.get(index)?.length }}</span>
|
||||
<button class="match-nav-button" @click.prevent.stop="nextMatch(index)" title="Next match">
|
||||
<span class="vpi-chevron-right navigate-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -600,6 +892,15 @@ function onMouseMove(e: MouseEvent) {
|
||||
</kbd>
|
||||
{{ translate('modal.footer.selectText') }}
|
||||
</span>
|
||||
<span v-if="showDetailedList">
|
||||
<kbd>
|
||||
<span class="vpi-arrow-left navigate-icon" />
|
||||
</kbd>
|
||||
<kbd>
|
||||
<span class="vpi-arrow-right navigate-icon" />
|
||||
</kbd>
|
||||
to cycle matches
|
||||
</span>
|
||||
<span>
|
||||
<kbd :aria-label="translate('modal.footer.closeKeyAriaLabel')">esc</kbd>
|
||||
{{ translate('modal.footer.closeText') }}
|
||||
@@ -694,6 +995,46 @@ function onMouseMove(e: MouseEvent) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Custom Feature: Match navigation controls overlay */
|
||||
.excerpt-actions {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background-color: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
box-shadow: var(--vp-shadow-1);
|
||||
}
|
||||
|
||||
.match-nav-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.match-nav-button:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 11px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
color: var(--vp-c-text-2);
|
||||
user-select: none;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.search-input {
|
||||
padding: 6px 4px;
|
||||
@@ -730,6 +1071,7 @@ function onMouseMove(e: MouseEvent) {
|
||||
opacity: 0.37;
|
||||
}
|
||||
|
||||
/* Custom Feature: Fuzzy search toggle button */
|
||||
.toggle-fuzzy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -879,12 +1221,21 @@ function onMouseMove(e: MouseEvent) {
|
||||
line-height: 130% !important;
|
||||
}
|
||||
|
||||
/* Highlight styles - default state */
|
||||
.titles :deep(mark),
|
||||
.excerpt :deep(mark) {
|
||||
background-color: var(--vp-local-search-highlight-bg);
|
||||
color: var(--vp-local-search-highlight-text);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
/* Custom Feature: Currently focused highlight (during navigation) */
|
||||
.excerpt :deep(mark.current) {
|
||||
background-color: var(--vp-c-yellow-3);
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.excerpt :deep(.vp-code-group) .tabs {
|
||||
|
||||
@@ -308,10 +308,10 @@
|
||||
* [Vheer](https://vheer.com/) - Unlimited / No Sign-Up
|
||||
* [AIFreeVideo](https://aifreevideo.com/) - Unlimited / MiniMax Video-01 / Sign-Up Required
|
||||
* [Meta AI](https://www.meta.ai/) - Unlimited / Sign-Up Required
|
||||
* [LMArena](https://lmarena.ai/?chat-modality=video) - 3 Daily / Sign-Up Required / Random Models / Reset Limits w/ Cookie Delete or Temp Mail / End-Watermark / [X](https://x.com/arena) / [Discord](https://discord.com/invite/lmarena)
|
||||
* [LMArena](https://lmarena.ai/?chat-modality=video) - 3 Daily / Sign-Up Required / Random Models / Reset Limits w/ Cookie Delete or Temp Mail / End-Watermark / [X](https://x.com/arena) / [Discord](https://discord.com/invite/lmarena)
|
||||
* [LMArena (Discord)](https://discord.com/invite/lmarena) - 5 Daily / Random Models / Discord Only / Check #how-to-video-bot / End-Watermark
|
||||
* [Klipy](https://klipy.com/) - Klipy / Veo 3 / GIFs / Unlimited / Sign-Up Required
|
||||
* [ModelScope Video](https://modelscope.ai/civision/videoGeneration) - Wan 2.2 14B / 3 Daily / [Note](https://github.com/fmhy/edit/blob/main/docs/.vitepress/notes/modelscope.md)
|
||||
* [ModelScope Video](https://modelscope.ai/civision/videoGeneration) - Wan 2.2 14B / 3 Daily / [Note](https://github.com/fmhy/edit/blob/main/docs/.vitepress/notes/modelscope.md)
|
||||
* [Google Whisk](https://labs.google/fx/en/tools/whisk) - Veo 3 / 10 Monthly
|
||||
* [Google Flow](https://labs.google/fx/tools/flow) - Veo 3.1 (5 Monthly)
|
||||
* [Dreamina](https://dreamina.capcut.com/ai-tool/home) - 120 Credits Daily
|
||||
|
||||
@@ -381,7 +381,7 @@
|
||||
* ⭐ **[Soggfy](https://github.com/Rafiuth/Soggfy)** - Spotify / 160kb Free / 320kb Premium
|
||||
* ⭐ **[Exact Audio Copy](https://www.exactaudiocopy.de/)** / [Guide](https://docs.google.com/document/d/1b1JJsuZj2TdiXs--XDvuKdhFUdKCdB_1qrmOMGkyveg) or [Whipper](https://github.com/whipper-team/whipper) - CD / DVD Audio Ripper
|
||||
* ⭐ **[Firehawk52](https://rentry.co/FMHYB64#firehawk)** - Deezer / Qobuz / Tidal / Sign-Up Required / [Telegram](https://t.me/firehawk52official) / [Discord](https://discord.gg/uqfQbzHj6K)
|
||||
* [OnTheSpot](https://github.com/justin025/onthespot) - Apple Music / Bandcamp / Deezer / Qobuz / Spotify / Tidal/ [Discord](https://discord.com/invite/hz4mAwSujH)
|
||||
* [OnTheSpot](https://github.com/justin025/onthespot) - Apple Music / Bandcamp / Deezer / Qobuz / Spotify / Tidal / [Discord](https://discord.com/invite/hz4mAwSujH)
|
||||
* [Votify](https://github.com/glomatico/votify) - Spotify / 160kb Free / 320kb Premium / Requires WVD Keys / [Discord](https://discord.gg/aBjMEZ9tnq)
|
||||
* [streamrip](https://github.com/nathom/streamrip) - Deezer / Tidal / Qobuz / SoundCloud / 128kb Free / FLAC / Use Firehawk52 / [Colab](https://github.com/privateersclub/rip)
|
||||
* [OrpheusDL](https://github.com/OrfiTeam/OrpheusDL) - Deezer / Qobuz / 128kb Free / FLAC / Use Firehawk52 / [Deezer Module](https://github.com/uhwot/orpheusdl-deezer) / [Qobuz Module](https://github.com/OrfiDev/orpheusdl-qobuz)
|
||||
@@ -403,7 +403,7 @@
|
||||
* ⭐ **[Music Hunters](https://t.me/MusicsHuntersbot)** - Spotify / Apple / Tidal / Deezer / 320kb MP3
|
||||
* [DeezerMusicBot](https://t.me/DeezerMusicBot) - Deezer / Soundcloud / VK / 320kb MP3 / FLAC / [Support](https://t.me/DeezerMusicNews)
|
||||
* [deezload2bot](https://t.me/deezload2bot) - Deezer / 320kb MP3 / [Updates](https://t.me/DEDSEClulz)
|
||||
* [BeatSpotBot](https://t.me/BeatSpotBot) - Spotify /Apple / YouTube / FLAC / 25 Daily
|
||||
* [BeatSpotBot](https://t.me/BeatSpotBot) - Spotify / Apple / YouTube / FLAC / 25 Daily
|
||||
* [Motreeb](https://t.me/motreb_downloader_bot) - Spotify / 320kb MP3
|
||||
* [scdlbot](https://t.me/scdlbot) - YouTube / SoundCloud / Bandcamp / Mixcloud / 128kb MP3
|
||||
* [vkmusbot](https://t.me/vkmusbot) or [Meph Bot](https://t.me/mephbot) - VK / 320kb MP3
|
||||
|
||||
@@ -1411,7 +1411,7 @@
|
||||
|
||||
* 🌐 **[nanoHUB](https://nanohub.org/)** - Nanotechnology Tools
|
||||
* 🌐 **[5th STAAR Resource Curation](https://docs.google.com/document/d/1vxxEKhZe_7dd1XIxl_sETsqP__Rf-yPAnBhtwf8huKU/edit?usp=drivesdk)** - Grade School Tools
|
||||
* ↪️ **[Presentation/ Slideshare tools](https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/storage#wiki_presentation_tools)**
|
||||
* ↪️ **[Presentation / Slideshare tools](https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/storage#wiki_presentation_tools)**
|
||||
* ↪️ **[Data Visualization](https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/storage#wiki_data_visualization_tools)**
|
||||
* ↪️ **[Grammar / Spell Check](https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/text-tools#wiki_.25B7_grammar_check)**
|
||||
* ⭐ **[Excalidraw](https://excalidraw.com/)** / [Sharing](https://excalihub.dev/), **[OpenBoard](https://openboard.ch/index.en.html)**, [DGM](https://dgm.sh/), [NotebookCast](https://www.notebookcast.com/), [WebWhiteboard](https://webwhiteboard.com/), [Microsoft Whiteboard](https://apps.microsoft.com/detail/9MSPC6MP8FM4), [WBO](https://wbo.ophir.dev/), [OurBoard](https://www.ourboard.io/), [Whiteboard.fi](https://whiteboard.fi/) or [Whiteboard Fox](https://r3.whiteboardfox.com/) - Whiteboards
|
||||
|
||||
@@ -535,7 +535,7 @@
|
||||
## ▷ Android File Tools
|
||||
|
||||
* ↪️ **[Mobile / Desktop Transfer](https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/file-tools#wiki_.25BA_file_transfer)**
|
||||
* ⭐ **[1DM](https://play.google.com/store/apps/details?id=idm.internet.download.manager)** / [Features](https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/android#wiki_.25B7_modded_apks) (search), [Go Speed](https://gopeed.com/) / [Plugins](https://github.com/search?q=topic%3Agopeed-extension&type=repositories) / [GitHub](https://github.com/GopeedLab/gopeed), [AB Download Manager](https://abdownloadmanager.com/)/ [Telegram](https://t.me/abdownloadmanager_discussion) / [GitHub](https://github.com/amir1376/ab-download-manager), [ADM](https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/android#wiki_.25B7_modded_apks) (search) or [FDM](https://play.google.com/store/apps/details?id=org.freedownloadmanager.fdm) - Download Managers
|
||||
* ⭐ **[1DM](https://play.google.com/store/apps/details?id=idm.internet.download.manager)** / [Features](https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/android#wiki_.25B7_modded_apks) (search), [Go Speed](https://gopeed.com/) / [Plugins](https://github.com/search?q=topic%3Agopeed-extension&type=repositories) / [GitHub](https://github.com/GopeedLab/gopeed), [AB Download Manager](https://abdownloadmanager.com/) / [Telegram](https://t.me/abdownloadmanager_discussion) / [GitHub](https://github.com/amir1376/ab-download-manager), [ADM](https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/android#wiki_.25B7_modded_apks) (search) or [FDM](https://play.google.com/store/apps/details?id=org.freedownloadmanager.fdm) - Download Managers
|
||||
* ⭐ **[MiXplorer](https://mixplorer.com/beta/)**, [2](https://mixplorer.com/), [3](https://xdaforums.com/t/app-2-2-mixplorer-v6-x-released-fully-featured-file-manager.1523691/), [MT Manager](https://mt2.cn/) - Advanced Root File Explorer / Manager / [Themes](https://play.google.com/store/apps/details?id=de.dertyp7214.mixplorerthemecreator)
|
||||
* ⭐ **[SD Maid SE](https://github.com/d4rken-org/sdmaid-se)** - File Manager / Cleaner / [Unlock Note](https://github.com/fmhy/edit/blob/main/docs/.vitepress/notes/sd-maid.md) / [Discord](https://discord.com/invite/8Fjy6PTfXu)
|
||||
* ⭐ **[Material Files](https://github.com/zhanghai/MaterialFiles)** - File Manager
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* [Numblr](https://github.com/heyLu/numblr) - Self-Hosted Frontend Redirect
|
||||
* [Binternet](https://github.com/Ahwxorg/Binternet) or [Pinvibe](https://www.pinvibe.com/) - Pinterest Frontends / Viewers
|
||||
* [Proxitok](https://github.com/pablouser1/ProxiTok), [Urlebird](https://urlebird.com/) or [OffTikTok](https://www.offtiktok.com/) - TikTok Frontends / Viewers
|
||||
* [TikTok Tools](https://omar-thing.site/) - TikTok Search, Story Viewer, Repost Viewer, and URL Tracker / [Telegram](https://t.me/tiktokinfosite)
|
||||
* [TikTok Tools](https://omar-thing.site/) - TikTok Search, Story Viewer, Repost Viewer, and URL Tracker / [Telegram](https://t.me/tiktokinfosite)
|
||||
* [Social-Searcher](https://www.social-searcher.com/) or [WeVerify](https://cse.google.com/cse?cx=006976128084956795641:ad1xj14zfap) - Social Media Search Engines
|
||||
* [Social Media Hacker List](https://github.com/MobileFirstLLC/social-media-hacker-list) - Social Media Apps / Tools
|
||||
* [ExportComments](https://exportcomments.com/) - Export Social Media Comments
|
||||
|
||||
@@ -105,13 +105,49 @@ files.forEach(file => {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check 8: Asymmetric spaces around slash
|
||||
// We must exclude URLs (http://...)
|
||||
const lineWithoutLinks = line.replace(/https?:\/\/[^\s)]+/g, 'LINK_PLACEHOLDER');
|
||||
|
||||
// Ignore VitePress sidebar links (e.g. "link: /foo")
|
||||
if (!/^\s*link:/i.test(line)) {
|
||||
// A. Missing space after slash: " /Word"
|
||||
// Exception: /> (HTML close tag)
|
||||
// Exception: /Word/ (Path/Board e.g. /co/)
|
||||
const missingSpaceAfter = lineWithoutLinks.matchAll(/\s\/([^\s]+)/g);
|
||||
for (const match of missingSpaceAfter) {
|
||||
const wordAfter = match[1];
|
||||
if (wordAfter.startsWith('>')) continue; // Ignore />
|
||||
// Ignore paths (e.g. /bin), subreddits (/r/foo), or compound words (Word/Word)
|
||||
if (wordAfter.includes('/')) continue;
|
||||
|
||||
errors.push(`Missing space after slash (e.g. "Word /Word"): "${match[0]}"`);
|
||||
break;
|
||||
}
|
||||
|
||||
// B. Missing space before slash: "Word/ "
|
||||
// Exceptions: w/ (with), r/ (reddit), u/ (user), c/ (community)
|
||||
// Exception: /Word/ (Path/Board e.g. /b/)
|
||||
const missingSpaceBefore = lineWithoutLinks.matchAll(/([^\s]+)\/\s/g);
|
||||
for (const match of missingSpaceBefore) {
|
||||
const wordBefore = match[1];
|
||||
// Allow common abbreviations: w/, r/, u/, c/
|
||||
if (/^(w|r|u|c)$/i.test(wordBefore)) continue;
|
||||
// Allow paths ending in slash or containing slash: /b/ or [/int
|
||||
if (wordBefore.includes('/')) continue;
|
||||
|
||||
errors.push(`Missing space before slash (e.g. "Word/ Word"): "${match[0]}"`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
hasErrors = true;
|
||||
errors.forEach(err => {
|
||||
console.log(`${relativePath}:${lineNum} - ${err}`);
|
||||
console.log(` Line: ${line.trim()}`);
|
||||
// file:line - Error (in red/cyan)
|
||||
console.log(`\x1b[36m${relativePath}:${lineNum}\x1b[0m - \x1b[31m${err}\x1b[0m`);
|
||||
// Source line (dimmed)
|
||||
console.log(` \x1b[90m${line.trim()}\x1b[0m`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user