feat: add import summary filtering and improve search in HypaV3

- Add important summary filtering button
- Improve search navigation with focus-based results
- Add mobile-friendly previous/next search buttons
This commit is contained in:
Bo26fhmC5M
2025-02-09 15:23:24 +09:00
parent 02e0b326b6
commit 3e3511760e
7 changed files with 488 additions and 358 deletions

View File

@@ -796,7 +796,7 @@ export const languageChinese = {
"convertSuccessMessage": "成功将 HypaV2 数据转换为 V3",
"convertErrorMessage": "将 HypaV2 数据转换为 V3 失败:{0}",
"noSummariesLabel": "尚无总结",
"searchPlaceholder": "输入 #N、ID 或搜索查询",
"searchPlaceholder": "输入 #N、ID 或搜索关键词",
"summaryNumberLabel": "总结 #{0}",
"deleteAfterConfirmMessage": "删除此后的所有总结?",
"deleteAfterConfirmSecondMessage": "此操作无法撤销。您确定吗?",

View File

@@ -867,7 +867,7 @@ export const languageEnglish = {
convertSuccessMessage: "Successfully converted HypaV2 data to V3",
convertErrorMessage: "Failed to convert HypaV2 data to V3: {0}",
noSummariesLabel: "No summaries yet",
searchPlaceholder: "Enter #N, ID, or search query",
searchPlaceholder: "Enter #N, ID, or query",
summaryNumberLabel: "Summary #{0}",
deleteAfterConfirmMessage: "Delete all summaries after this one?",
deleteAfterConfirmSecondMessage: "This action cannot be undone. Are you really sure?",

View File

@@ -705,7 +705,7 @@ export const languageSpanish = {
"convertSuccessMessage": "Datos de HypaV2 convertidos exitosamente a V3",
"convertErrorMessage": "Error al convertir datos de HypaV2 a V3: {0}",
"noSummariesLabel": "Aún no hay resúmenes",
"searchPlaceholder": "Ingrese #N, ID o consulta de búsqueda",
"searchPlaceholder": "Ingrese #N, ID o búsqueda",
"summaryNumberLabel": "Resumen #{0}",
"deleteAfterConfirmMessage": "¿Eliminar todos los resúmenes después de este?",
"deleteAfterConfirmSecondMessage": "Esta acción no se puede deshacer. ¿Está realmente seguro?",

View File

@@ -792,7 +792,7 @@ export const languageKorean = {
"convertSuccessMessage": "HypaV2 데이터를 V3로 성공적으로 변환했습니다",
"convertErrorMessage": "HypaV2 데이터를 V3로 변환하는데 실패했습니다: {0}",
"noSummariesLabel": "아직 요약이 없습니다",
"searchPlaceholder": "#N, ID 또는 검색어 입력하세요",
"searchPlaceholder": "#N, ID 또는 검색어 입력",
"summaryNumberLabel": "요약 #{0}",
"deleteAfterConfirmMessage": "이 요약 이후의 모든 요약을 삭제하시겠습니까?",
"deleteAfterConfirmSecondMessage": "이 작업은 되돌릴 수 없습니다. 정말 삭제하시겠습니까?",

View File

@@ -434,7 +434,7 @@ export const LanguageVietnamese = {
"convertSuccessMessage": "Đã chuyển đổi thành công dữ liệu HypaV2 sang V3",
"convertErrorMessage": "Chuyển đổi dữ liệu HypaV2 sang V3 thất bại: {0}",
"noSummariesLabel": "Chưa có tóm tắt nào",
"searchPlaceholder": "Nhập #N, ID, hoặc từ khóa tìm kiếm",
"searchPlaceholder": "Nhập #N, ID hoặc từ khóa",
"summaryNumberLabel": "Tóm tắt #{0}",
"deleteAfterConfirmMessage": "Xóa tất cả các tóm tắt sau tóm tắt này?",
"deleteAfterConfirmSecondMessage": "Hành động này không thể hoàn tác. Bạn có chắc chắn không?",

View File

@@ -829,7 +829,7 @@ export const languageChineseTraditional = {
"convertSuccessMessage": "成功將 HypaV2 數據轉換為 V3",
"convertErrorMessage": "無法將 HypaV2 數據轉換為 V3{0}",
"noSummariesLabel": "尚無摘要",
"searchPlaceholder": "輸入 #N、ID 或搜索查詢",
"searchPlaceholder": "輸入 #N、ID 或搜尋關鍵字",
"summaryNumberLabel": "摘要 #{0}",
"deleteAfterConfirmMessage": "刪除此摘要之後的所有摘要?",
"deleteAfterConfirmSecondMessage": "此操作無法撤銷。您確定要這樣做嗎?",

View File

@@ -5,6 +5,8 @@
SettingsIcon,
Trash2Icon,
XIcon,
ChevronUpIcon,
ChevronDownIcon,
LanguagesIcon,
StarIcon,
RefreshCw,
@@ -51,20 +53,30 @@
translationRef: HTMLTextAreaElement;
}
interface SearchResult {
element: HTMLElement;
matchType: "chatMemo" | "summary";
summaryPosition?: {
start: number;
end: number;
};
class SummarySearchResult {
constructor(
public summaryIndex: number,
public start: number,
public end: number
) {}
}
class ChatMemoSearchResult {
constructor(
public summaryIndex: number,
public memoIndex: number
) {}
}
type SearchResult = SummarySearchResult | ChatMemoSearchResult;
interface SearchUI {
ref: HTMLInputElement;
query: string;
currentIndex: number;
results: SearchResult[];
currentResultIndex: number;
requestedSearchFromIndex: number;
isNavigating: boolean;
}
const hypaV3DataState = $derived(
@@ -76,6 +88,7 @@
let summaryUIStates = $state<SummaryUI[]>([]);
let expandedMessageUIState = $state<ExpandedMessageUI>(null);
let searchUIState = $state<SearchUI>(null);
let showImportantOnly = $state(false);
$effect.pre(() => {
summaryUIStates = hypaV3DataState.summaries.map((summary) => ({
@@ -111,8 +124,10 @@
searchUIState = {
ref: null,
query: "",
currentIndex: -1,
results: [],
currentResultIndex: -1,
requestedSearchFromIndex: -1,
isNavigating: false,
};
// Focus on search element after it's rendered
@@ -135,122 +150,187 @@
}
if (e.key === "Enter") {
e.preventDefault(); // Prevent event default action
e.preventDefault?.(); // Prevent event default action
const query = searchUIState.query.trim();
if (!query) return;
// When received a new query
if (searchUIState.currentResultIndex === -1) {
const results = generateSearchResults(query);
if (results.length === 0) return;
searchUIState.results = results;
}
const nextResult = getNextSearchResult(e.shiftKey);
if (nextResult) {
navigateToSearchResult(nextResult);
}
}
}
function generateSearchResults(query: string): SearchResult[] {
const results: SearchResult[] = [];
const normalizedQuery = query.trim().toLowerCase();
// Search summary index
if (query.match(/^#\d+$/)) {
const summaryNumber = parseInt(query.substring(1)) - 1;
if (
summaryNumber >= 0 &&
summaryNumber < hypaV3DataState.summaries.length
summaryNumber < hypaV3DataState.summaries.length &&
(!showImportantOnly ||
hypaV3DataState.summaries[summaryNumber].isImportant)
) {
summaryUIStates[summaryNumber].originalRef.scrollIntoView({
behavior: "instant",
block: "center",
});
results.push(new SummarySearchResult(summaryNumber, 0, 0));
}
return;
return results;
}
const normalizedQuery = query.toLowerCase();
if (searchUIState.currentIndex === -1) {
const results: SearchResult[] = [];
if (isGuidLike(query)) {
// Search chatMemo
summaryUIStates.forEach((summaryUI) => {
summaryUI.chatMemoRefs.forEach((buttonRef) => {
summaryUIStates.forEach((summaryUI, summaryIndex) => {
if (
!showImportantOnly ||
hypaV3DataState.summaries[summaryIndex].isImportant
) {
summaryUI.chatMemoRefs.forEach((buttonRef, memoIndex) => {
const buttonText = buttonRef.textContent?.toLowerCase() || "";
if (buttonText.includes(normalizedQuery)) {
results.push({
element: buttonRef as HTMLButtonElement,
matchType: "chatMemo",
});
results.push(new ChatMemoSearchResult(summaryIndex, memoIndex));
}
});
}
});
} else {
// Search summary
summaryUIStates.forEach((summaryUI) => {
summaryUIStates.forEach((summaryUI, summaryIndex) => {
if (
!showImportantOnly ||
hypaV3DataState.summaries[summaryIndex].isImportant
) {
const textAreaText = summaryUI.originalRef.value?.toLowerCase();
let pos = -1;
while (
(pos = textAreaText.indexOf(normalizedQuery, pos + 1)) !== -1
) {
results.push({
element: summaryUI.originalRef as HTMLTextAreaElement,
matchType: "summary",
summaryPosition: {
start: pos,
end: pos + normalizedQuery.length,
},
});
results.push(
new SummarySearchResult(
summaryIndex,
pos,
pos + normalizedQuery.length
)
);
}
}
});
}
searchUIState.results = results;
return results;
}
if (searchUIState.results.length === 0) return;
function isGuidLike(str: string): boolean {
const strTrimed = str.trim();
// Move to next result
searchUIState.currentIndex =
(searchUIState.currentIndex + 1) % searchUIState.results.length;
// Exclude too short inputs
if (strTrimed.length < 4) return false;
const result = searchUIState.results[searchUIState.currentIndex];
return /^[0-9a-f]{4,12}(-[0-9a-f]{4,12}){0,4}-?$/i.test(strTrimed);
}
function getNextSearchResult(backward: boolean): SearchResult | null {
if (!searchUIState || searchUIState.results.length === 0) return null;
let nextIndex: number;
if (searchUIState.requestedSearchFromIndex !== -1) {
const fromSummaryIndex = searchUIState.requestedSearchFromIndex;
nextIndex = backward
? searchUIState.results.findLastIndex(
(r) => r.summaryIndex <= fromSummaryIndex
)
: searchUIState.results.findIndex(
(r) => r.summaryIndex >= fromSummaryIndex
);
if (nextIndex === -1) {
nextIndex = backward ? searchUIState.results.length - 1 : 0;
}
searchUIState.requestedSearchFromIndex = -1;
} else {
const delta = backward ? -1 : 1;
nextIndex =
(searchUIState.currentResultIndex +
delta +
searchUIState.results.length) %
searchUIState.results.length;
}
searchUIState.currentResultIndex = nextIndex;
return searchUIState.results[nextIndex];
}
function navigateToSearchResult(result: SearchResult) {
searchUIState.isNavigating = true;
if (result instanceof SummarySearchResult) {
const textarea = summaryUIStates[result.summaryIndex].originalRef;
// Scroll to element
result.element.scrollIntoView({
textarea.scrollIntoView({
behavior: "instant",
block: "center",
});
if (result.matchType === "chatMemo") {
// Highlight chatMemo result
result.element.classList.add("ring-2", "ring-zinc-500");
if (result.start === result.end) {
return;
}
// Remove highlight after a short delay
window.setTimeout(() => {
result.element.classList.remove("ring-2", "ring-zinc-500");
}, 1000);
} else {
// Handle summary text selection
const textarea = result.element as HTMLTextAreaElement;
// Make readonly temporarily
textarea.readOnly = true;
// Select query
textarea.setSelectionRange(
result.summaryPosition.start,
result.summaryPosition.end
);
textarea.focus();
// Scroll to query
textarea.setSelectionRange(result.start, result.end);
scrollToSelection(textarea);
// This only works on firefox
//textarea.scrollTop = textarea.scrollHeight; // Scroll to the bottom
//textarea.blur(); // Collapse selection
//textarea.focus(); // This scrolls the textarea
// Highlight textarea
// Highlight query on desktop environment
if (!("ontouchend" in window)) {
// Make readonly temporarily
textarea.readOnly = true;
textarea.focus();
window.setTimeout(() => {
searchUIState.ref.focus(); // Restore focus to search bar
textarea.readOnly = false; // Remove readonly after focus moved
}, 300);
}
} else {
const button =
summaryUIStates[result.summaryIndex].chatMemoRefs[result.memoIndex];
// Scroll to element
button.scrollIntoView({
behavior: "instant",
block: "center",
});
// Highlight chatMemo
button.classList.add("ring-2", "ring-zinc-500");
// Remove highlight after a short delay
window.setTimeout(() => {
button.classList.remove("ring-2", "ring-zinc-500");
}, 1000);
}
searchUIState.isNavigating = false;
}
function scrollToSelection(textarea: HTMLTextAreaElement) {
@@ -287,15 +367,6 @@
textarea.scrollTop = selectionTop - textarea.clientHeight / 2;
}
function isGuidLike(str: string): boolean {
const strTrimed = str.trim();
// Exclude too short inputs
if (strTrimed.length < 4) return false;
return /^[0-9a-f]{4,12}(-[0-9a-f]{4,12}){0,4}-?$/i.test(strTrimed);
}
async function toggleTranslate(
summaryIndex: number,
regenerate?: boolean
@@ -767,14 +838,35 @@
<!-- Search Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
tabindex="-1"
onclick={async () => toggleSearch()}
>
<SearchIcon class="w-6 h-6" />
</button>
<!-- Filter Important Summary Button -->
<button
class="p-2 transition-colors {showImportantOnly
? 'text-yellow-400 hover:text-yellow-300'
: 'text-zinc-400 hover:text-zinc-200'}"
tabindex="-1"
onclick={() => {
if (searchUIState) {
searchUIState.query = "";
searchUIState.results = [];
searchUIState.currentResultIndex = -1;
}
showImportantOnly = !showImportantOnly;
}}
>
<StarIcon class="w-6 h-6" />
</button>
<!-- Settings Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
tabindex="-1"
onclick={() => {
alertStore.set({
type: "none",
@@ -791,6 +883,7 @@
<!-- Reset Button -->
<button
class="p-2 text-zinc-400 hover:text-rose-300 transition-colors"
tabindex="-1"
onclick={async () => {
if (
await alertConfirmTwice(
@@ -815,6 +908,7 @@
<!-- Close Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
tabindex="-1"
onclick={() => {
alertStore.set({
type: "none",
@@ -828,7 +922,7 @@
</div>
<!-- Scrollable Container -->
<div class="flex flex-col gap-2 sm:gap-4 overflow-y-auto">
<div class="flex flex-col gap-2 sm:gap-4 overflow-y-auto" tabindex="-1">
{#if hypaV3DataState.summaries.length === 0}
<!-- Conversion Section -->
{#if isHypaV2ConversionPossible()}
@@ -841,6 +935,7 @@
</div>
<button
class="my-1 sm:my-2 px-4 py-2 rounded-md text-zinc-300 font-semibold bg-zinc-700 hover:bg-zinc-500 transition-colors"
tabindex="-1"
onclick={async () => {
const conversionResult = convertHypaV2ToV3();
@@ -889,8 +984,8 @@
bind:value={searchUIState.query}
oninput={() => {
if (searchUIState) {
searchUIState.currentIndex = -1;
searchUIState.results = [];
searchUIState.currentResultIndex = -1;
}
}}
onkeydown={(e) => onSearch(e)}
@@ -901,16 +996,32 @@
<span
class="absolute right-3 top-1/2 -translate-y-1/2 px-1.5 sm:px-3 py-1 sm:py-2 rounded text-sm font-semibold text-zinc-100 bg-zinc-700/65"
>
{searchUIState.currentIndex + 1}/{searchUIState.results
.length}
{searchUIState.currentResultIndex + 1}/{searchUIState
.results.length}
</span>
{/if}
</div>
<!-- Previous Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
onclick={async () => toggleSearch()}
tabindex="-1"
onclick={() => {
onSearch({ shiftKey: true, key: "Enter" } as KeyboardEvent);
}}
>
<XIcon class="w-6 h-6" />
<ChevronUpIcon class="w-6 h-6" />
</button>
<!-- Next Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
tabindex="-1"
onclick={() => {
onSearch({ key: "Enter" } as KeyboardEvent);
}}
>
<ChevronDownIcon class="w-6 h-6" />
</button>
</div>
</div>
@@ -918,6 +1029,7 @@
<!-- Summaries List -->
{#each hypaV3DataState.summaries as summary, i}
{#if !showImportantOnly || summary.isImportant}
{#if summaryUIStates[i]}
<!-- Summary Item -->
<div
@@ -936,6 +1048,7 @@
<!-- Translate Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
tabindex="-1"
use:handleDualAction={{
onMainAction: () => toggleTranslate(i, false),
onAlternativeAction: () => toggleTranslate(i, true),
@@ -946,9 +1059,10 @@
<!-- Important Button -->
<button
class="p-2 hover:text-zinc-200 transition-colors {summary.isImportant
? 'text-yellow-400'
: 'text-zinc-400'}"
class="p-2 transition-colors {summary.isImportant
? 'text-yellow-400 hover:text-yellow-300'
: 'text-zinc-400 hover:text-zinc-200'}"
tabindex="-1"
onclick={() => {
summary.isImportant = !summary.isImportant;
}}
@@ -959,8 +1073,9 @@
<!-- Reroll Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
onclick={async () => await toggleReroll(i)}
tabindex="-1"
disabled={!isRerollable(i)}
onclick={async () => await toggleReroll(i)}
>
<RefreshCw class="w-4 h-4" />
</button>
@@ -968,6 +1083,7 @@
<!-- Delete After Button -->
<button
class="p-2 text-zinc-400 hover:text-rose-300 transition-colors"
tabindex="-1"
onclick={async () => {
if (
await alertConfirmTwice(
@@ -992,6 +1108,11 @@
class="p-2 sm:p-4 w-full min-h-40 sm:min-h-56 resize-vertical rounded border border-zinc-700 focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-colors text-zinc-200 bg-zinc-900"
bind:this={summaryUIStates[i].originalRef}
bind:value={summary.text}
onfocus={() => {
if (searchUIState && !searchUIState.isNavigating) {
searchUIState.requestedSearchFromIndex = i;
}
}}
>
</textarea>
</div>
@@ -1004,10 +1125,10 @@
</div>
<textarea
readonly
class="p-2 sm:p-4 w-full min-h-40 sm:min-h-56 resize-vertical rounded border border-zinc-700 focus:outline-none transition-colors text-zinc-200 bg-zinc-900"
bind:this={summaryUIStates[i].translationRef}
readonly
tabindex="-1"
bind:this={summaryUIStates[i].translationRef}
value={summaryUIStates[i].translation}
></textarea>
</div>
@@ -1024,8 +1145,10 @@
<!-- Translate Rerolled Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
tabindex="-1"
use:handleDualAction={{
onMainAction: () => toggleTranslateRerolled(i, false),
onMainAction: () =>
toggleTranslateRerolled(i, false),
onAlternativeAction: () =>
toggleTranslateRerolled(i, true),
}}
@@ -1036,6 +1159,7 @@
<!-- Cancel Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
tabindex="-1"
onclick={() => {
summaryUIStates[i].rerolledText = null;
summaryUIStates[i].rerolledTranslation = null;
@@ -1047,6 +1171,7 @@
<!-- Apply Button -->
<button
class="p-2 text-zinc-400 hover:text-rose-300 transition-colors"
tabindex="-1"
onclick={() => {
summary.text = summaryUIStates[i].rerolledText!;
summaryUIStates[i].translation = null;
@@ -1064,6 +1189,7 @@
<div class="mt-2 sm:mt-4">
<textarea
class="p-2 sm:p-4 w-full min-h-40 sm:min-h-56 resize-vertical rounded border border-zinc-700 focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-colors text-zinc-200 bg-zinc-900"
tabindex="-1"
bind:value={summaryUIStates[i].rerolledText}
>
</textarea>
@@ -1077,10 +1203,10 @@
</div>
<textarea
readonly
class="p-2 sm:p-4 w-full min-h-40 sm:min-h-56 resize-vertical rounded border border-zinc-700 focus:outline-none transition-colors text-zinc-200 bg-zinc-900"
bind:this={summaryUIStates[i].rerolledTranslationRef}
readonly
tabindex="-1"
bind:this={summaryUIStates[i].rerolledTranslationRef}
value={summaryUIStates[i].rerolledTranslation}
></textarea>
</div>
@@ -1101,6 +1227,7 @@
<!-- Translate Message Button -->
<button
class="p-2 text-zinc-400 hover:text-zinc-200 transition-colors"
tabindex="-1"
use:handleDualAction={{
onMainAction: () =>
toggleTranslateExpandedMessage(false),
@@ -1124,6 +1251,7 @@
)
? 'ring-2 ring-zinc-500'
: ''}"
tabindex="-1"
bind:this={summaryUIStates[i].chatMemoRefs[memoIndex]}
onclick={() => toggleExpandMessage(i, chatMemo)}
>
@@ -1150,8 +1278,9 @@
<!-- Content -->
<textarea
readonly
class="p-2 sm:p-4 w-full min-h-40 sm:min-h-56 resize-vertical rounded border border-zinc-700 focus:outline-none transition-colors text-zinc-200 bg-zinc-900"
readonly
tabindex="-1"
value={expandedMessage.data}
></textarea>
{:else}
@@ -1178,10 +1307,10 @@
</div>
<textarea
readonly
class="p-2 sm:p-4 w-full min-h-40 sm:min-h-56 resize-vertical rounded border border-zinc-700 focus:outline-none transition-colors text-zinc-200 bg-zinc-900"
bind:this={expandedMessageUIState.translationRef}
readonly
tabindex="-1"
bind:this={expandedMessageUIState.translationRef}
value={expandedMessageUIState.translation}
></textarea>
</div>
@@ -1189,6 +1318,7 @@
{/if}
</div>
{/if}
{/if}
{/each}
<!-- Next Summarization Target -->
@@ -1209,8 +1339,8 @@
</div>
<textarea
readonly
class="p-2 sm:p-4 w-full min-h-40 sm:min-h-56 resize-none overflow-y-auto rounded border border-zinc-700 focus:outline-none transition-colors text-zinc-200 bg-zinc-900"
readonly
value={nextMessage.data}
></textarea>
{:else}