ui(showcase-specific): 💄 Enhance showcase UI components to improve featured content display and styling
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
cada297615
commit
fe26ec8e26
2 changed files with 778 additions and 0 deletions
431
journal/assistant/frontend/showcase/SpellcheckShowcasePage.tsx
Normal file
431
journal/assistant/frontend/showcase/SpellcheckShowcasePage.tsx
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
/** @jsxImportSource react */
|
||||
/* TRACKED: "placeholder" appears as HTML input attributes, not bypass pattern */
|
||||
|
||||
/**
|
||||
* Spellcheck Showcase Page
|
||||
*
|
||||
* Isolated demo of the SymSpell WASM spellcheck engine with Input + Textarea.
|
||||
* Independent of the chat pipeline — uses useSpellcheckStandalone hook.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
import { Input, Textarea } from '@lilith/ui-primitives';
|
||||
|
||||
import { SpellcheckOverlay } from '../chat/components/SpellcheckOverlay';
|
||||
import { useSpellcheckStandalone } from './useSpellcheckStandalone';
|
||||
|
||||
// --- Status badge pulse ---
|
||||
const pulse = keyframes`
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
`;
|
||||
|
||||
// --- Layout ---
|
||||
const PageContainer = styled.div`
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: ${({ theme }) => theme?.colors?.text?.primary ?? '#e0e0e0'};
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
const StatusBadge = styled.span<{ $status: 'loading' | 'ready' | 'error' }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
|
||||
${({ $status }) => {
|
||||
switch ($status) {
|
||||
case 'loading':
|
||||
return css`
|
||||
background: rgba(255, 170, 0, 0.12);
|
||||
border: 1px solid rgba(255, 170, 0, 0.4);
|
||||
color: #ffaa00;
|
||||
animation: ${pulse} 1.5s ease-in-out infinite;
|
||||
`;
|
||||
case 'ready':
|
||||
return css`
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.4);
|
||||
color: #22c55e;
|
||||
`;
|
||||
case 'error':
|
||||
return css`
|
||||
background: rgba(255, 51, 102, 0.12);
|
||||
border: 1px solid rgba(255, 51, 102, 0.4);
|
||||
color: #ff3366;
|
||||
`;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
const StatusDot = styled.span<{ $status: 'loading' | 'ready' | 'error' }>`
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $status }) => {
|
||||
switch ($status) {
|
||||
case 'loading': return '#ffaa00';
|
||||
case 'ready': return '#22c55e';
|
||||
case 'error': return '#ff3366';
|
||||
}
|
||||
}};
|
||||
box-shadow: 0 0 8px ${({ $status }) => {
|
||||
switch ($status) {
|
||||
case 'loading': return '#ffaa00';
|
||||
case 'ready': return '#22c55e';
|
||||
case 'error': return '#ff3366';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const DemoSection = styled.section`
|
||||
background: rgba(10, 10, 15, 0.6);
|
||||
border: 1px solid ${({ theme }) => theme?.colors?.border?.default ?? '#1a1a2e'};
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(8px);
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme?.colors?.primary?.main ?? '#00ff9f'};
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 16px;
|
||||
`;
|
||||
|
||||
const TextareaWrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const CorrectionChips = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
min-height: 28px;
|
||||
`;
|
||||
|
||||
const Chip = styled.button<{ $accepted: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid ${({ $accepted }) =>
|
||||
$accepted ? 'rgba(34, 197, 94, 0.4)' : 'rgba(0, 200, 255, 0.3)'};
|
||||
background: ${({ $accepted }) =>
|
||||
$accepted ? 'rgba(34, 197, 94, 0.12)' : 'rgba(0, 200, 255, 0.08)'};
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
background: ${({ $accepted }) =>
|
||||
$accepted ? 'rgba(34, 197, 94, 0.2)' : 'rgba(0, 200, 255, 0.18)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const ChipOriginal = styled.span<{ $struck?: boolean }>`
|
||||
color: ${({ $struck }) => ($struck ? '#94a3b8' : '#ff3366')};
|
||||
text-decoration: ${({ $struck }) => ($struck ? 'line-through' : 'none')};
|
||||
`;
|
||||
|
||||
const ChipArrow = styled.span`
|
||||
color: ${({ theme }) => theme?.colors?.text?.secondary ?? '#555'};
|
||||
font-size: 10px;
|
||||
`;
|
||||
|
||||
const ChipSuggestion = styled.span`
|
||||
color: #22c55e;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const ChipCheck = styled.span`
|
||||
font-size: 10px;
|
||||
color: #22c55e;
|
||||
`;
|
||||
|
||||
const ResultsPanel = styled.div`
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
border: 1px solid ${({ theme }) => theme?.colors?.border?.default ?? '#1a1a2e'};
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const ResultsTitle = styled.h2`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme?.colors?.text?.primary ?? '#e0e0e0'};
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
margin: 0 0 16px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const ResultOutput = styled.pre`
|
||||
font-size: 13px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
color: ${({ theme }) => theme?.colors?.text?.primary ?? '#e0e0e0'};
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 40px;
|
||||
margin: 0 0 16px;
|
||||
`;
|
||||
|
||||
const StatsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const StatCard = styled.div`
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const StatLabel = styled.span`
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: ${({ theme }) => theme?.colors?.text?.muted ?? '#555'};
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
`;
|
||||
|
||||
const StatValue = styled.span`
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: ${({ theme }) => theme?.colors?.primary?.main ?? '#00ff9f'};
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
`;
|
||||
|
||||
const EmptyHint = styled.span`
|
||||
font-size: 12px;
|
||||
color: ${({ theme }) => theme?.colors?.text?.muted ?? '#555'};
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
// --- Hint text constants ---
|
||||
const INPUT_HINT = 'teh quikc brwon fox';
|
||||
const TEXTAREA_HINT = 'Type here with errors: teh quikc brwon fox jumpd ovr teh layz dogg. Also try split words like every thing and some one.';
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export default function SpellcheckShowcasePage() {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [textareaValue, setTextareaValue] = useState('');
|
||||
const textareaWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const initTimeRef = useRef<number>(performance.now());
|
||||
const [initDuration, setInitDuration] = useState<number | null>(null);
|
||||
|
||||
const inputSpellcheck = useSpellcheckStandalone(300);
|
||||
const textareaSpellcheck = useSpellcheckStandalone(300);
|
||||
|
||||
// Track engine ready time
|
||||
useEffect(() => {
|
||||
if (inputSpellcheck.isReady && initDuration === null) {
|
||||
setInitDuration(Math.round(performance.now() - initTimeRef.current));
|
||||
}
|
||||
}, [inputSpellcheck.isReady, initDuration]);
|
||||
|
||||
const engineStatus: 'loading' | 'ready' | 'error' = inputSpellcheck.isReady
|
||||
? 'ready'
|
||||
: 'loading';
|
||||
|
||||
const statusLabel = engineStatus === 'ready' ? 'Engine Ready' : 'Loading WASM';
|
||||
|
||||
// --- Input handlers ---
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
inputSpellcheck.checkText(val);
|
||||
},
|
||||
[inputSpellcheck.checkText],
|
||||
);
|
||||
|
||||
const handleTextareaChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setTextareaValue(val);
|
||||
textareaSpellcheck.checkText(val);
|
||||
},
|
||||
[textareaSpellcheck.checkText],
|
||||
);
|
||||
|
||||
// --- Stats ---
|
||||
const allCorrections = [
|
||||
...inputSpellcheck.corrections,
|
||||
...textareaSpellcheck.corrections,
|
||||
];
|
||||
const totalWords =
|
||||
(inputValue ? inputValue.split(/\s+/).filter(Boolean).length : 0) +
|
||||
(textareaValue ? textareaValue.split(/\s+/).filter(Boolean).length : 0);
|
||||
const misspelled = allCorrections.length;
|
||||
const applied = allCorrections.filter((c) => c.status === 'accepted').length;
|
||||
|
||||
const hasPendingTextarea = textareaSpellcheck.corrections.some(
|
||||
(c) => c.status === 'pending' || c.status === 'accepted',
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Header>
|
||||
<Title>Spellcheck Showcase</Title>
|
||||
<StatusBadge $status={engineStatus}>
|
||||
<StatusDot $status={engineStatus} />
|
||||
{statusLabel}
|
||||
</StatusBadge>
|
||||
</Header>
|
||||
|
||||
{/* Input Demo */}
|
||||
<DemoSection>
|
||||
<SectionTitle>Input Demo</SectionTitle>
|
||||
<Input
|
||||
placeholder={INPUT_HINT}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
fullWidth
|
||||
/>
|
||||
<CorrectionChips>
|
||||
{inputSpellcheck.corrections.length === 0 && inputValue && !inputSpellcheck.isChecking && (
|
||||
<EmptyHint>No corrections found</EmptyHint>
|
||||
)}
|
||||
{inputSpellcheck.corrections.map((c) => {
|
||||
const isAccepted = c.status === 'accepted';
|
||||
return (
|
||||
<Chip
|
||||
key={c.id}
|
||||
$accepted={isAccepted}
|
||||
onClick={() =>
|
||||
isAccepted
|
||||
? inputSpellcheck.ignoreCorrection(c.id)
|
||||
: inputSpellcheck.acceptCorrection(c.id)
|
||||
}
|
||||
title={
|
||||
isAccepted
|
||||
? `Undo: revert "${c.suggestion}" back to "${c.original}"`
|
||||
: `Apply: replace "${c.original}" with "${c.suggestion}"`
|
||||
}
|
||||
>
|
||||
<ChipOriginal $struck={isAccepted}>{c.original}</ChipOriginal>
|
||||
<ChipArrow>{'→'}</ChipArrow>
|
||||
<ChipSuggestion>{c.suggestion}</ChipSuggestion>
|
||||
{isAccepted && <ChipCheck>{'✓'}</ChipCheck>}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</CorrectionChips>
|
||||
</DemoSection>
|
||||
|
||||
{/* Textarea Demo */}
|
||||
<DemoSection>
|
||||
<SectionTitle>Textarea Demo</SectionTitle>
|
||||
<TextareaWrapper ref={textareaWrapperRef}>
|
||||
{hasPendingTextarea && (
|
||||
<SpellcheckOverlay
|
||||
corrections={textareaSpellcheck.corrections}
|
||||
remainingTime={textareaSpellcheck.remainingTime}
|
||||
onAccept={textareaSpellcheck.acceptCorrection}
|
||||
onIgnore={textareaSpellcheck.ignoreCorrection}
|
||||
onAcceptAll={textareaSpellcheck.acceptAll}
|
||||
onIgnoreAll={textareaSpellcheck.ignoreAll}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
placeholder={TEXTAREA_HINT}
|
||||
value={textareaValue}
|
||||
onChange={handleTextareaChange}
|
||||
rows={5}
|
||||
fullWidth
|
||||
/>
|
||||
</TextareaWrapper>
|
||||
</DemoSection>
|
||||
|
||||
{/* Results Panel */}
|
||||
<ResultsPanel>
|
||||
<ResultsTitle>Results</ResultsTitle>
|
||||
|
||||
<StatLabel style={{ marginBottom: 6, display: 'block' }}>
|
||||
Input Corrected Output
|
||||
</StatLabel>
|
||||
<ResultOutput>
|
||||
{inputSpellcheck.correctedText || (
|
||||
<EmptyHint>Type in the input above...</EmptyHint>
|
||||
)}
|
||||
</ResultOutput>
|
||||
|
||||
<StatLabel style={{ marginBottom: 6, display: 'block' }}>
|
||||
Textarea Corrected Output
|
||||
</StatLabel>
|
||||
<ResultOutput>
|
||||
{textareaSpellcheck.correctedText || (
|
||||
<EmptyHint>Type in the textarea above...</EmptyHint>
|
||||
)}
|
||||
</ResultOutput>
|
||||
|
||||
<StatsGrid>
|
||||
<StatCard>
|
||||
<StatLabel>Total Words</StatLabel>
|
||||
<StatValue>{totalWords}</StatValue>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>Misspelled</StatLabel>
|
||||
<StatValue>{misspelled}</StatValue>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>Corrections</StatLabel>
|
||||
<StatValue>{applied}</StatValue>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>Init Time</StatLabel>
|
||||
<StatValue>
|
||||
{initDuration !== null ? `${initDuration}ms` : '...'}
|
||||
</StatValue>
|
||||
</StatCard>
|
||||
</StatsGrid>
|
||||
</ResultsPanel>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
347
journal/assistant/frontend/showcase/useSpellcheckStandalone.ts
Normal file
347
journal/assistant/frontend/showcase/useSpellcheckStandalone.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/**
|
||||
* Standalone Spellcheck Hook
|
||||
*
|
||||
* Stripped-down version of the chat useSpellcheck hook, decoupled from
|
||||
* React Query / WebSocket / conversation logic. Suitable for any text
|
||||
* input that needs spellcheck without the chat pipeline.
|
||||
*
|
||||
* Reuses the same WASM-backed Web Worker and spellcheck settings.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import type { SpellcheckCorrection } from '@life-platform/shared';
|
||||
import type { SpellCheckError } from '@lilith/text-processing-utils';
|
||||
|
||||
import { getSpellcheckSettings } from '../chat/services/spellcheckSettings';
|
||||
import SpellcheckWorker from '../chat/services/spellcheck.worker?worker';
|
||||
|
||||
const MAX_PENDING_CHECKS = 10;
|
||||
|
||||
export interface UseSpellcheckStandaloneReturn {
|
||||
corrections: SpellcheckCorrection[];
|
||||
isReady: boolean;
|
||||
isChecking: boolean;
|
||||
remainingTime: number;
|
||||
checkText: (text: string) => void;
|
||||
acceptCorrection: (id: string) => void;
|
||||
ignoreCorrection: (id: string) => void;
|
||||
acceptAll: () => void;
|
||||
ignoreAll: () => void;
|
||||
dismiss: () => void;
|
||||
correctedText: string;
|
||||
}
|
||||
|
||||
interface PendingCheck {
|
||||
resolve: (errors: SpellCheckError[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function useSpellcheckStandalone(debounceMs = 300): UseSpellcheckStandaloneReturn {
|
||||
const [corrections, setCorrections] = useState<SpellcheckCorrection[]>([]);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
const [originalText, setOriginalText] = useState('');
|
||||
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const workerReadyRef = useRef(false);
|
||||
const workerReadyPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const pendingChecksRef = useRef<Map<string, PendingCheck>>(new Map());
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// --- Worker message handler ---
|
||||
const handleWorkerMessage = useCallback((event: MessageEvent) => {
|
||||
const msg = event.data;
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
workerReadyRef.current = true;
|
||||
setIsReady(true);
|
||||
break;
|
||||
case 'initError':
|
||||
workerReadyRef.current = false;
|
||||
setIsReady(false);
|
||||
break;
|
||||
case 'result': {
|
||||
const pending = pendingChecksRef.current.get(msg.requestId);
|
||||
if (pending) {
|
||||
pendingChecksRef.current.delete(msg.requestId);
|
||||
pending.resolve(msg.errors);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
const pending = pendingChecksRef.current.get(msg.requestId);
|
||||
if (pending) {
|
||||
pendingChecksRef.current.delete(msg.requestId);
|
||||
pending.reject(new Error(msg.error));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- Worker init ---
|
||||
const initWorker = useCallback((): Promise<void> => {
|
||||
if (workerReadyPromiseRef.current) return workerReadyPromiseRef.current;
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
const worker = new SpellcheckWorker();
|
||||
workerRef.current = worker;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.data.type === 'ready') {
|
||||
workerReadyRef.current = true;
|
||||
setIsReady(true);
|
||||
worker.removeEventListener('message', onMessage);
|
||||
worker.addEventListener('message', handleWorkerMessage);
|
||||
resolve();
|
||||
} else if (event.data.type === 'initError') {
|
||||
worker.removeEventListener('message', onMessage);
|
||||
setIsReady(false);
|
||||
reject(new Error(event.data.error));
|
||||
}
|
||||
};
|
||||
|
||||
worker.addEventListener('message', onMessage);
|
||||
|
||||
const settings = getSpellcheckSettings();
|
||||
worker.postMessage({
|
||||
type: 'init',
|
||||
customWords: settings.customWords,
|
||||
minConfidence: settings.minConfidence,
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
workerReadyPromiseRef.current = promise;
|
||||
return promise;
|
||||
}, [handleWorkerMessage]);
|
||||
|
||||
// --- Send text to worker ---
|
||||
const checkTextInWorker = useCallback((text: string): Promise<SpellCheckError[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!workerRef.current || !workerReadyRef.current) {
|
||||
reject(new Error('Worker not ready'));
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Evict oldest pending check if map is at capacity
|
||||
const pending = pendingChecksRef.current;
|
||||
if (pending.size >= MAX_PENDING_CHECKS) {
|
||||
const oldestKey = pending.keys().next().value!;
|
||||
const oldest = pending.get(oldestKey)!;
|
||||
pending.delete(oldestKey);
|
||||
oldest.reject(new Error('Superseded by newer check'));
|
||||
}
|
||||
|
||||
pending.set(requestId, { resolve, reject });
|
||||
workerRef.current.postMessage({ type: 'check', text, requestId });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// --- Build corrected text from accepted corrections ---
|
||||
const buildFinalText = useCallback(
|
||||
(text: string, corrs: SpellcheckCorrection[]): string => {
|
||||
const accepted = corrs.filter((c) => c.status === 'accepted');
|
||||
if (accepted.length === 0) return text;
|
||||
|
||||
const sorted = [...accepted].sort((a, b) => b.position.start - a.position.start);
|
||||
let result = text;
|
||||
for (const correction of sorted) {
|
||||
result =
|
||||
result.slice(0, correction.position.start) +
|
||||
correction.suggestion +
|
||||
result.slice(correction.position.end);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// --- Clear all timers ---
|
||||
const clearTimers = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- Handle timeout ---
|
||||
const handleTimeout = useCallback(() => {
|
||||
const settings = getSpellcheckSettings();
|
||||
|
||||
setCorrections((prev) => {
|
||||
if (settings.timeoutMode === 'auto-approve') {
|
||||
return prev.map((c) =>
|
||||
c.status === 'pending' ? { ...c, status: 'accepted' as const } : c,
|
||||
);
|
||||
}
|
||||
return prev.map((c) =>
|
||||
c.status === 'pending' ? { ...c, status: 'ignored' as const } : c,
|
||||
);
|
||||
});
|
||||
|
||||
clearTimers();
|
||||
}, [clearTimers]);
|
||||
|
||||
// --- Start countdown ---
|
||||
const startTimer = useCallback(() => {
|
||||
const settings = getSpellcheckSettings();
|
||||
const timeout = settings.timeout;
|
||||
|
||||
setRemainingTime(timeout);
|
||||
|
||||
countdownRef.current = setInterval(() => {
|
||||
setRemainingTime((prev) => Math.max(0, prev - 100));
|
||||
}, 100);
|
||||
|
||||
timerRef.current = setTimeout(handleTimeout, timeout);
|
||||
}, [handleTimeout]);
|
||||
|
||||
// --- Core check function (debounced) ---
|
||||
const checkText = useCallback(
|
||||
(text: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
// Clear previous corrections if text is empty
|
||||
if (!text.trim()) {
|
||||
setCorrections([]);
|
||||
setOriginalText('');
|
||||
setRemainingTime(0);
|
||||
clearTimers();
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChecking(true);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await initWorker();
|
||||
|
||||
const errors = await checkTextInWorker(text);
|
||||
const settings = getSpellcheckSettings();
|
||||
|
||||
const relevantErrors = errors.filter(
|
||||
(e) => (e.confidence ?? 0) >= settings.minConfidence,
|
||||
);
|
||||
|
||||
if (relevantErrors.length === 0) {
|
||||
setCorrections([]);
|
||||
setOriginalText(text);
|
||||
setIsChecking(false);
|
||||
clearTimers();
|
||||
setRemainingTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const newCorrections: SpellcheckCorrection[] = relevantErrors.map((error, index) => ({
|
||||
id: `correction-${index}-${error.position.start}`,
|
||||
original: error.word,
|
||||
suggestion: error.suggestions[0] ?? error.word,
|
||||
position: error.position,
|
||||
confidence: error.confidence ?? 0,
|
||||
status:
|
||||
(error.confidence ?? 0) >= settings.autoApproveConfidence
|
||||
? ('accepted' as const)
|
||||
: ('pending' as const),
|
||||
}));
|
||||
|
||||
setCorrections(newCorrections);
|
||||
setOriginalText(text);
|
||||
setIsChecking(false);
|
||||
|
||||
clearTimers();
|
||||
startTimer();
|
||||
} catch {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, debounceMs);
|
||||
},
|
||||
[debounceMs, initWorker, checkTextInWorker, clearTimers, startTimer],
|
||||
);
|
||||
|
||||
// --- User actions ---
|
||||
const acceptCorrection = useCallback((id: string) => {
|
||||
setCorrections((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, status: 'accepted' as const } : c)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const ignoreCorrection = useCallback((id: string) => {
|
||||
setCorrections((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, status: 'ignored' as const } : c)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const acceptAll = useCallback(() => {
|
||||
setCorrections((prev) =>
|
||||
prev.map((c) => (c.status === 'pending' ? { ...c, status: 'accepted' as const } : c)),
|
||||
);
|
||||
clearTimers();
|
||||
setRemainingTime(0);
|
||||
}, [clearTimers]);
|
||||
|
||||
const ignoreAll = useCallback(() => {
|
||||
setCorrections((prev) =>
|
||||
prev.map((c) => (c.status === 'pending' ? { ...c, status: 'ignored' as const } : c)),
|
||||
);
|
||||
clearTimers();
|
||||
setRemainingTime(0);
|
||||
}, [clearTimers]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setCorrections([]);
|
||||
setOriginalText('');
|
||||
clearTimers();
|
||||
setRemainingTime(0);
|
||||
}, [clearTimers]);
|
||||
|
||||
// --- Computed corrected text ---
|
||||
const correctedText = useMemo(
|
||||
() => buildFinalText(originalText, corrections),
|
||||
[originalText, corrections, buildFinalText],
|
||||
);
|
||||
|
||||
// --- Mount/unmount lifecycle ---
|
||||
useEffect(() => {
|
||||
initWorker().catch(() => {
|
||||
// Worker initialization failure is reflected via isReady state
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimers();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
workerReadyRef.current = false;
|
||||
workerReadyPromiseRef.current = null;
|
||||
pendingChecksRef.current.clear();
|
||||
};
|
||||
}, [initWorker, clearTimers]);
|
||||
|
||||
return {
|
||||
corrections,
|
||||
isReady,
|
||||
isChecking,
|
||||
remainingTime,
|
||||
checkText,
|
||||
acceptCorrection,
|
||||
ignoreCorrection,
|
||||
acceptAll,
|
||||
ignoreAll,
|
||||
dismiss,
|
||||
correctedText,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue