mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
415 lines
13 KiB
TypeScript
415 lines
13 KiB
TypeScript
import type { ControllerFieldGroupingStep } from '../../shared/controller-field-grouping';
|
|
import type {
|
|
ControllerFieldGroupingAction,
|
|
KikuDuplicateCardInfo,
|
|
KikuFieldGroupingChoice,
|
|
KikuMergePreviewResponse,
|
|
} from '../../types';
|
|
import type { ModalStateReader, RendererContext } from '../context';
|
|
import type { RendererDom } from '../utils/dom';
|
|
|
|
/** Pixels the merge preview moves per controller scroll step (repeats while held). */
|
|
export const KIKU_CONTROLLER_SCROLL_STEP_PX = 48;
|
|
|
|
/**
|
|
* Where each controller action's hint tag is drawn. The two scroll actions share one
|
|
* "Scroll" tag in the preview header instead.
|
|
*/
|
|
export const KIKU_CONTROLLER_HINT_TARGETS: Record<
|
|
Exclude<ControllerFieldGroupingAction, 'scrollUp' | 'scrollDown'>,
|
|
keyof RendererDom
|
|
> = {
|
|
selectOriginal: 'kikuCard1Label',
|
|
selectNew: 'kikuCard2Label',
|
|
toggleDeleteDuplicate: 'kikuDeleteToggle',
|
|
continue: 'kikuConfirmButton',
|
|
cancel: 'kikuCancelButton',
|
|
confirmMerge: 'kikuFinalConfirmButton',
|
|
back: 'kikuBackButton',
|
|
cancelMerge: 'kikuFinalCancelButton',
|
|
toggleView: 'kikuPreviewToggle',
|
|
};
|
|
|
|
/** One tag for both scroll directions, e.g. `▲ ▼`; null when neither is bound. */
|
|
export function formatKikuScrollHint(up: string | null, down: string | null): string | null {
|
|
if (up && down) return `${up} ${down}`;
|
|
return up ?? down ?? null;
|
|
}
|
|
|
|
function setHint(element: HTMLElement, hint: string | null | undefined): void {
|
|
if (hint === null || hint === undefined) {
|
|
if (element.dataset.controllerHint !== undefined) delete element.dataset.controllerHint;
|
|
} else if (element.dataset.controllerHint !== hint) {
|
|
element.dataset.controllerHint = hint;
|
|
}
|
|
}
|
|
|
|
export function createKikuModal(
|
|
ctx: RendererContext,
|
|
options: {
|
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
|
syncSettingsModalSubtitleSuppression: () => void;
|
|
},
|
|
) {
|
|
function formatMediaMeta(card: KikuDuplicateCardInfo): string {
|
|
const parts: string[] = [];
|
|
parts.push(card.hasAudio ? 'Audio: Yes' : 'Audio: No');
|
|
parts.push(card.hasImage ? 'Image: Yes' : 'Image: No');
|
|
return parts.join(' | ');
|
|
}
|
|
|
|
function updateKikuCardSelection(): void {
|
|
ctx.dom.kikuCard1.classList.toggle('active', ctx.state.kikuSelectedCard === 1);
|
|
ctx.dom.kikuCard2.classList.toggle('active', ctx.state.kikuSelectedCard === 2);
|
|
}
|
|
|
|
function setKikuModalStep(step: 'select' | 'preview'): void {
|
|
ctx.state.kikuModalStep = step;
|
|
const isSelect = step === 'select';
|
|
ctx.dom.kikuSelectionStep.classList.toggle('hidden', !isSelect);
|
|
ctx.dom.kikuPreviewStep.classList.toggle('hidden', isSelect);
|
|
ctx.dom.kikuHint.textContent = isSelect
|
|
? 'Press 1 or 2 to select · Enter to continue · Esc to cancel'
|
|
: 'Enter to confirm merge · Backspace to go back · Esc to cancel';
|
|
}
|
|
|
|
function updateKikuPreviewToggle(): void {
|
|
ctx.dom.kikuPreviewCompactButton.classList.toggle(
|
|
'active',
|
|
ctx.state.kikuPreviewMode === 'compact',
|
|
);
|
|
ctx.dom.kikuPreviewFullButton.classList.toggle('active', ctx.state.kikuPreviewMode === 'full');
|
|
}
|
|
|
|
function renderKikuPreview(): void {
|
|
const payload =
|
|
ctx.state.kikuPreviewMode === 'compact'
|
|
? ctx.state.kikuPreviewCompactData
|
|
: ctx.state.kikuPreviewFullData;
|
|
ctx.dom.kikuPreviewJson.textContent = payload ? JSON.stringify(payload, null, 2) : '{}';
|
|
updateKikuPreviewToggle();
|
|
}
|
|
|
|
function setKikuPreviewError(message: string | null): void {
|
|
if (!message) {
|
|
ctx.dom.kikuPreviewError.textContent = '';
|
|
ctx.dom.kikuPreviewError.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
ctx.dom.kikuPreviewError.textContent = message;
|
|
ctx.dom.kikuPreviewError.classList.remove('hidden');
|
|
}
|
|
|
|
function openKikuFieldGroupingModal(data: {
|
|
original: KikuDuplicateCardInfo;
|
|
duplicate: KikuDuplicateCardInfo;
|
|
}): void {
|
|
if (ctx.state.kikuModalOpen) return;
|
|
|
|
ctx.state.kikuModalOpen = true;
|
|
ctx.state.kikuOriginalData = data.original;
|
|
ctx.state.kikuDuplicateData = data.duplicate;
|
|
ctx.state.kikuSelectedCard = 1;
|
|
|
|
ctx.dom.kikuCard1Expression.textContent = data.original.expression;
|
|
ctx.dom.kikuCard1Sentence.textContent = data.original.sentencePreview || '(no sentence)';
|
|
ctx.dom.kikuCard1Meta.textContent = formatMediaMeta(data.original);
|
|
|
|
ctx.dom.kikuCard2Expression.textContent = data.duplicate.expression;
|
|
ctx.dom.kikuCard2Sentence.textContent = data.duplicate.sentencePreview || '(current subtitle)';
|
|
ctx.dom.kikuCard2Meta.textContent = formatMediaMeta(data.duplicate);
|
|
|
|
ctx.dom.kikuDeleteDuplicateCheckbox.checked = true;
|
|
ctx.state.kikuPendingChoice = null;
|
|
ctx.state.kikuPreviewCompactData = null;
|
|
ctx.state.kikuPreviewFullData = null;
|
|
ctx.state.kikuPreviewMode = 'compact';
|
|
|
|
renderKikuPreview();
|
|
setKikuPreviewError(null);
|
|
setKikuModalStep('select');
|
|
updateKikuCardSelection();
|
|
|
|
options.syncSettingsModalSubtitleSuppression();
|
|
ctx.dom.overlay.classList.add('interactive');
|
|
ctx.dom.kikuModal.classList.remove('hidden');
|
|
ctx.dom.kikuModal.setAttribute('aria-hidden', 'false');
|
|
}
|
|
|
|
function closeKikuFieldGroupingModal(): void {
|
|
if (!ctx.state.kikuModalOpen) return;
|
|
|
|
ctx.state.kikuModalOpen = false;
|
|
options.syncSettingsModalSubtitleSuppression();
|
|
|
|
ctx.dom.kikuModal.classList.add('hidden');
|
|
ctx.dom.kikuModal.setAttribute('aria-hidden', 'true');
|
|
|
|
setKikuPreviewError(null);
|
|
ctx.dom.kikuPreviewJson.textContent = '';
|
|
window.electronAPI.notifyOverlayModalClosed('kiku');
|
|
|
|
ctx.state.kikuPendingChoice = null;
|
|
ctx.state.kikuPreviewCompactData = null;
|
|
ctx.state.kikuPreviewFullData = null;
|
|
ctx.state.kikuPreviewMode = 'compact';
|
|
setKikuModalStep('select');
|
|
ctx.state.kikuOriginalData = null;
|
|
ctx.state.kikuDuplicateData = null;
|
|
|
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
|
ctx.dom.overlay.classList.remove('interactive');
|
|
}
|
|
}
|
|
|
|
async function confirmKikuSelection(): Promise<void> {
|
|
if (!ctx.state.kikuOriginalData || !ctx.state.kikuDuplicateData) return;
|
|
|
|
const keepData =
|
|
ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuOriginalData : ctx.state.kikuDuplicateData;
|
|
const deleteData =
|
|
ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuDuplicateData : ctx.state.kikuOriginalData;
|
|
|
|
const choice: KikuFieldGroupingChoice = {
|
|
keepNoteId: keepData.noteId,
|
|
deleteNoteId: deleteData.noteId,
|
|
deleteDuplicate: ctx.dom.kikuDeleteDuplicateCheckbox.checked,
|
|
cancelled: false,
|
|
};
|
|
|
|
ctx.state.kikuPendingChoice = choice;
|
|
setKikuPreviewError(null);
|
|
ctx.dom.kikuConfirmButton.disabled = true;
|
|
|
|
try {
|
|
const preview: KikuMergePreviewResponse = await window.electronAPI.kikuBuildMergePreview({
|
|
keepNoteId: choice.keepNoteId,
|
|
deleteNoteId: choice.deleteNoteId,
|
|
deleteDuplicate: choice.deleteDuplicate,
|
|
});
|
|
|
|
if (!preview.ok) {
|
|
setKikuPreviewError(preview.error || 'Failed to build merge preview');
|
|
return;
|
|
}
|
|
|
|
ctx.state.kikuPreviewCompactData = preview.compact || {};
|
|
ctx.state.kikuPreviewFullData = preview.full || {};
|
|
ctx.state.kikuPreviewMode = 'compact';
|
|
renderKikuPreview();
|
|
setKikuModalStep('preview');
|
|
} finally {
|
|
ctx.dom.kikuConfirmButton.disabled = false;
|
|
}
|
|
}
|
|
|
|
function confirmKikuMerge(): void {
|
|
if (!ctx.state.kikuPendingChoice) return;
|
|
window.electronAPI.kikuFieldGroupingRespond(ctx.state.kikuPendingChoice);
|
|
closeKikuFieldGroupingModal();
|
|
}
|
|
|
|
function goBackFromKikuPreview(): void {
|
|
setKikuPreviewError(null);
|
|
setKikuModalStep('select');
|
|
}
|
|
|
|
function getControllerStep(): ControllerFieldGroupingStep {
|
|
return ctx.state.kikuModalStep;
|
|
}
|
|
|
|
function selectCard(card: 1 | 2): void {
|
|
ctx.state.kikuSelectedCard = card;
|
|
updateKikuCardSelection();
|
|
}
|
|
|
|
/** Runs a controller action with the same guards as the matching click or key. */
|
|
function handleControllerAction(action: ControllerFieldGroupingAction): boolean {
|
|
if (!ctx.state.kikuModalOpen) return false;
|
|
const onPreview = ctx.state.kikuModalStep === 'preview';
|
|
switch (action) {
|
|
case 'selectOriginal':
|
|
case 'selectNew':
|
|
if (onPreview) return false;
|
|
selectCard(action === 'selectOriginal' ? 1 : 2);
|
|
return true;
|
|
case 'toggleDeleteDuplicate':
|
|
if (onPreview) return false;
|
|
ctx.dom.kikuDeleteDuplicateCheckbox.checked = !ctx.dom.kikuDeleteDuplicateCheckbox.checked;
|
|
return true;
|
|
case 'continue':
|
|
// Disabled while the merge preview is being built.
|
|
if (onPreview || ctx.dom.kikuConfirmButton.disabled) return false;
|
|
void confirmKikuSelection();
|
|
return true;
|
|
case 'cancel':
|
|
case 'cancelMerge':
|
|
if (onPreview !== (action === 'cancelMerge')) return false;
|
|
cancelKikuFieldGrouping();
|
|
return true;
|
|
case 'confirmMerge':
|
|
if (!onPreview) return false;
|
|
confirmKikuMerge();
|
|
return true;
|
|
case 'back':
|
|
if (!onPreview) return false;
|
|
goBackFromKikuPreview();
|
|
return true;
|
|
case 'toggleView':
|
|
if (!onPreview) return false;
|
|
ctx.state.kikuPreviewMode = ctx.state.kikuPreviewMode === 'compact' ? 'full' : 'compact';
|
|
renderKikuPreview();
|
|
return true;
|
|
case 'scrollUp':
|
|
case 'scrollDown':
|
|
if (!onPreview) return false;
|
|
ctx.dom.kikuPreviewJson.scrollTop +=
|
|
action === 'scrollUp' ? -KIKU_CONTROLLER_SCROLL_STEP_PX : KIKU_CONTROLLER_SCROLL_STEP_PX;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hint tags are drawn by CSS from `data-controller-hint`. Unbound actions show no tag;
|
|
* only changed attributes are written, since this runs every frame.
|
|
*/
|
|
function setControllerHints(hints: Record<ControllerFieldGroupingAction, string | null>): void {
|
|
for (const [action, domKey] of Object.entries(KIKU_CONTROLLER_HINT_TARGETS) as Array<
|
|
[keyof typeof KIKU_CONTROLLER_HINT_TARGETS, keyof RendererDom]
|
|
>) {
|
|
setHint(ctx.dom[domKey] as HTMLElement, hints[action]);
|
|
}
|
|
setHint(ctx.dom.kikuPreviewScrollHint, formatKikuScrollHint(hints.scrollUp, hints.scrollDown));
|
|
}
|
|
|
|
function setControllerHintsVisible(visible: boolean): void {
|
|
ctx.dom.kikuModal.classList.toggle('controller-hints-visible', visible);
|
|
}
|
|
|
|
function cancelKikuFieldGrouping(): void {
|
|
const choice: KikuFieldGroupingChoice = {
|
|
keepNoteId: 0,
|
|
deleteNoteId: 0,
|
|
deleteDuplicate: true,
|
|
cancelled: true,
|
|
};
|
|
|
|
window.electronAPI.kikuFieldGroupingRespond(choice);
|
|
closeKikuFieldGroupingModal();
|
|
}
|
|
|
|
function handleKikuKeydown(e: KeyboardEvent): boolean {
|
|
if (ctx.state.kikuModalStep === 'preview') {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
cancelKikuFieldGrouping();
|
|
return true;
|
|
}
|
|
if (e.key === 'Backspace') {
|
|
e.preventDefault();
|
|
goBackFromKikuPreview();
|
|
return true;
|
|
}
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
confirmKikuMerge();
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
cancelKikuFieldGrouping();
|
|
return true;
|
|
}
|
|
|
|
if (e.key === '1') {
|
|
e.preventDefault();
|
|
ctx.state.kikuSelectedCard = 1;
|
|
updateKikuCardSelection();
|
|
return true;
|
|
}
|
|
|
|
if (e.key === '2') {
|
|
e.preventDefault();
|
|
ctx.state.kikuSelectedCard = 2;
|
|
updateKikuCardSelection();
|
|
return true;
|
|
}
|
|
|
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
ctx.state.kikuSelectedCard = ctx.state.kikuSelectedCard === 1 ? 2 : 1;
|
|
updateKikuCardSelection();
|
|
return true;
|
|
}
|
|
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
void confirmKikuSelection();
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function wireDomEvents(): void {
|
|
ctx.dom.kikuCard1.addEventListener('click', () => {
|
|
ctx.state.kikuSelectedCard = 1;
|
|
updateKikuCardSelection();
|
|
});
|
|
ctx.dom.kikuCard1.addEventListener('dblclick', () => {
|
|
ctx.state.kikuSelectedCard = 1;
|
|
void confirmKikuSelection();
|
|
});
|
|
|
|
ctx.dom.kikuCard2.addEventListener('click', () => {
|
|
ctx.state.kikuSelectedCard = 2;
|
|
updateKikuCardSelection();
|
|
});
|
|
ctx.dom.kikuCard2.addEventListener('dblclick', () => {
|
|
ctx.state.kikuSelectedCard = 2;
|
|
void confirmKikuSelection();
|
|
});
|
|
|
|
ctx.dom.kikuConfirmButton.addEventListener('click', () => {
|
|
void confirmKikuSelection();
|
|
});
|
|
ctx.dom.kikuCancelButton.addEventListener('click', () => {
|
|
cancelKikuFieldGrouping();
|
|
});
|
|
ctx.dom.kikuBackButton.addEventListener('click', () => {
|
|
goBackFromKikuPreview();
|
|
});
|
|
ctx.dom.kikuFinalConfirmButton.addEventListener('click', () => {
|
|
confirmKikuMerge();
|
|
});
|
|
ctx.dom.kikuFinalCancelButton.addEventListener('click', () => {
|
|
cancelKikuFieldGrouping();
|
|
});
|
|
|
|
ctx.dom.kikuPreviewCompactButton.addEventListener('click', () => {
|
|
ctx.state.kikuPreviewMode = 'compact';
|
|
renderKikuPreview();
|
|
});
|
|
ctx.dom.kikuPreviewFullButton.addEventListener('click', () => {
|
|
ctx.state.kikuPreviewMode = 'full';
|
|
renderKikuPreview();
|
|
});
|
|
}
|
|
|
|
return {
|
|
cancelKikuFieldGrouping,
|
|
closeKikuFieldGroupingModal,
|
|
getControllerStep,
|
|
handleControllerAction,
|
|
handleKikuKeydown,
|
|
setControllerHints,
|
|
setControllerHintsVisible,
|
|
openKikuFieldGroupingModal,
|
|
wireDomEvents,
|
|
};
|
|
}
|