From 37d5da88574ce861be0b25436ad529fe8be39ae2 Mon Sep 17 00:00:00 2001 From: Matthew Reichhoff Date: Sun, 14 Jun 2026 09:28:03 -0400 Subject: [PATCH] Make search suggestions nicer --- public/css/hanzi-graph.css | 107 +++++++++++++++++++++++++++++++++--- public/js/modules/search.js | 103 +++++++++++++++++++++++++++++++++- 2 files changed, 198 insertions(+), 12 deletions(-) diff --git a/public/css/hanzi-graph.css b/public/css/hanzi-graph.css index 5cbc72fa..e116c339 100644 --- a/public/css/hanzi-graph.css +++ b/public/css/hanzi-graph.css @@ -71,6 +71,10 @@ --button-border-bottom: 3px solid var(--accent-color); --graph-expand-button-box-shadow: 2px 2px #444; --tag-border: 2px solid #333; + --suggestion-background-color: rgba(255, 255, 255, 0.62); + --suggestion-hover-background-color: rgba(255, 255, 255, 0.88); + --suggestion-definition-color: rgba(0, 0, 0, 0.66); + --suggestion-stem-color: rgba(0, 0, 0, 0.48); --examples-top-margin: 14px; --graph-margin-top: 14px; --checked-input-border: 6px solid black; @@ -178,7 +182,7 @@ ul { .header { display: grid; - grid-template-columns: 50px 44px 1fr 50px; + grid-template-columns: 50px 44px minmax(0, 1fr) 50px; height: var(--primary-header-height); text-align: center; } @@ -193,6 +197,10 @@ ul { line-height: var(--primary-header-height); } +#search-form { + min-width: 0; +} + .header .logo { height: 30px; line-height: 30px; @@ -1503,24 +1511,85 @@ https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_a /* TODO(refactor): get this looking nice */ .search-suggestions { - padding: 2px; + box-sizing: border-box; + width: 90%; + max-width: 600px; + min-width: 0; + padding: 6px; border: var(--border); - margin: 0; - font-size: var(--target-language-font-size); - text-align: center; + margin: 0 auto; + font-size: var(--target-language-secondary-font-size); + text-align: left; list-style-type: none; - overflow: hidden; background-color: var(--background-color); } .search-suggestion { - margin: 12px; + display: block; + min-width: 0; + margin: 4px 0; + padding: 8px 10px; + border-radius: 8px; cursor: pointer; + background-color: var(--suggestion-background-color); + transition: background-color 0.15s ease, transform 0.15s ease; +} + +.search-suggestion:hover, +.search-suggestion:focus-within { + background-color: var(--suggestion-hover-background-color); + transform: translateY(-1px); +} + +.search-suggestion-text { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.search-suggestion-stem { + color: var(--suggestion-stem-color); + font-size: 0.82em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .search-suggestion-current { - margin-left: 8px; + margin-left: 0; font-weight: var(--target-language-font-weight); + font-family: var(--chinese-font); + flex: 0 1 auto; + min-width: 0; + overflow-wrap: anywhere; +} + +.search-suggestion-details { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; + margin-top: 4px; + font-size: var(--base-language-font-size); + line-height: 1.35; +} + +.search-suggestion-pinyin { + display: inline-flex; + flex: 0 1 auto; + flex-wrap: wrap; + gap: 4px; + font-family: var(--pinyin-font); + font-weight: 700; +} + +.search-suggestion-definition { + min-width: 0; + color: var(--suggestion-definition-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } #hourly-graph { @@ -2410,6 +2479,10 @@ main.auth-form { --legend-switch-button-border: 1px solid #ccc; --graph-expand-button-box-shadow: 0; --tag-border: 2px solid #aaaa; + --suggestion-background-color: rgba(255, 255, 255, 0.07); + --suggestion-hover-background-color: rgba(255, 255, 255, 0.13); + --suggestion-definition-color: rgba(255, 255, 255, 0.68); + --suggestion-stem-color: rgba(255, 255, 255, 0.48); --checked-input-border: 6px solid #eeee; --popover-background-color: #121212; --popover-font-color: #eeee; @@ -2448,6 +2521,22 @@ main.auth-form { --legend-font-size: 14px; } + .search-suggestions { + width: 100vw; + max-width: none; + /* Break out of the search grid column by the fixed menu + logo columns. */ + margin-left: -94px; + } + + .search-suggestion-details { + display: block; + } + + .search-suggestion-definition { + display: block; + margin-top: 2px; + } + /* TODO(refactor): this is a result of oddities with grid + cytoscape + window resize */ /* ideally only variables */ .primary-container { @@ -2570,4 +2659,4 @@ main.auth-form { .graph-expander:hover .big-up-arrow { animation: upward-for-some-reason 0.75s 2; } -} \ No newline at end of file +} diff --git a/public/js/modules/search.js b/public/js/modules/search.js index 6ee3b617..597b8102 100644 --- a/public/js/modules/search.js +++ b/public/js/modules/search.js @@ -9,6 +9,7 @@ let searchSuggestionsWorker = null; let pinyinMap = {}; let pendingSentenceGenCallbacks = {}; let pendingRetokenizeCallback = null; +let pendingDefinitionFetches = {}; const mainHeader = document.getElementById('main-header'); const searchSuggestionsContainer = document.getElementById('search-suggestions-container'); @@ -65,15 +66,111 @@ function handleWorkerMessage(message) { renderSearchSuggestions(message.data.query, message.data.suggestions, message.data.tokens, searchSuggestionsContainer); } +function getSyllables(pinyin) { + return pinyin.replace(' - ', ' ').split(' '); +} + +function renderPinyin(pinyin, container) { + if (!pinyin) { + return; + } + const syllables = getSyllables(pinyin); + for (const syllable of syllables) { + const syllableElement = document.createElement('span'); + if (!getActiveGraph().disableToneColors) { + syllableElement.classList.add(`tone${syllable[syllable.length - 1]}`); + } + syllableElement.textContent = syllable; + container.appendChild(syllableElement); + } +} + +function getDefinitionPreview(definitionList) { + if (!definitionList || definitionList.length <= 0) { + return ''; + } + const preview = definitionList + .slice(0, 2) + .map(definition => definition.en) + .filter(x => !!x) + .join('; '); + const maxPreviewLength = 140; + if (preview.length <= maxPreviewLength) { + return preview; + } + return `${preview.substring(0, maxPreviewLength - 3)}...`; +} + +function renderSuggestionDetails(suggestion, container) { + const existingDetails = container.querySelector('.search-suggestion-details'); + if (existingDetails) { + existingDetails.remove(); + } + const definitionList = window.definitions[suggestion]; + if (!definitionList || definitionList.length <= 0) { + return; + } + + const details = document.createElement('div'); + details.classList.add('search-suggestion-details'); + + const pinyin = document.createElement('span'); + pinyin.classList.add('search-suggestion-pinyin'); + renderPinyin(definitionList[0].pinyin, pinyin); + details.appendChild(pinyin); + + const previewText = getDefinitionPreview(definitionList); + if (previewText) { + const definition = document.createElement('span'); + definition.classList.add('search-suggestion-definition'); + definition.textContent = previewText; + details.appendChild(definition); + } + container.appendChild(details); +} + +// TODO: in practice, do we need this? suggestions are generated based on definitions loaded up front iirc +// also a bit duplicative of the logic in explore.js, but fine for now +function fetchDefinitionsForSuggestion(suggestion, container) { + const activeGraph = getActiveGraph(); + if (!activeGraph.definitionsAugmentPath || !activeGraph.partitionCount) { + return; + } + const partition = getPartition(suggestion, activeGraph.partitionCount); + const partitionKey = `${activeGraph.prefix}-${partition}`; + if (!pendingDefinitionFetches[partitionKey]) { + pendingDefinitionFetches[partitionKey] = fetch(`/${activeGraph.definitionsAugmentPath}/${partition}.json`) + .then(response => response.json()) + .then(data => { + Object.assign(window.definitions, data); + return data; + }) + .catch(() => ({})); + } + pendingDefinitionFetches[partitionKey].then(data => { + if (container.isConnected && data[suggestion]) { + renderSuggestionDetails(suggestion, container); + } + }); +} + function renderSuggestion(priorWordsForDisplay, suggestion, container) { + let text = document.createElement('div'); + text.classList.add('search-suggestion-text'); let prior = document.createElement('span'); prior.innerText = priorWordsForDisplay; prior.classList.add('search-suggestion-stem'); let current = document.createElement('span'); current.innerText = suggestion; current.classList.add('search-suggestion-current'); - container.appendChild(prior); - container.appendChild(current); + text.appendChild(prior); + text.appendChild(current); + container.appendChild(text); + if (window.definitions[suggestion]) { + renderSuggestionDetails(suggestion, container); + } else { + fetchDefinitionsForSuggestion(suggestion, container); + } } function renderSearchSuggestions(query, suggestions, tokens, container) { searchControl.style.display = 'none'; @@ -386,4 +483,4 @@ function search(value, locale, mode, skipState) { }); } -export { search, initialize, looksLikeEnglish } \ No newline at end of file +export { search, initialize, looksLikeEnglish }