Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 99 additions & 42 deletions examples/features/spell-check/typo-js/src/createTypoJsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,55 @@
import Typo from 'typo-js';
import affUrl from 'typo-js/dictionaries/en_US/en_US.aff?url';
import dicUrl from 'typo-js/dictionaries/en_US/en_US.dic?url';
import type { TypoWorkerCancelMessage, TypoWorkerIssue, TypoWorkerRequest, TypoWorkerResponse } from './typoWorkerMessages';

const WORD_RE = /[a-zA-Z'\u2019]+/g;
type PendingRequest = {
resolve: (value: { issues: TypoWorkerIssue[] }) => void;
reject: (error: unknown) => void;
cleanup: () => void;
};

function createAbortError(): DOMException | Error {
try {
return new DOMException('The operation was aborted.', 'AbortError');
} catch {
const error = new Error('The operation was aborted.');
error.name = 'AbortError';
return error;
}
}

export async function createTypoJsProvider() {
const [affData, dicData] = await Promise.all([
fetch(affUrl).then((r) => r.text()),
fetch(dicUrl).then((r) => r.text()),
]);
// Run Typo.js work inside a dedicated worker to avoid UI stalls.
const worker = new Worker(new URL('./typoWorker.ts', import.meta.url), { type: 'module' });
const pending = new Map<number, PendingRequest>();
let nextRequestId = 0;

const handleMessage = (event: MessageEvent<TypoWorkerResponse>) => {
const message = event.data;
const entry = pending.get(message.id);
if (!entry) return;

pending.delete(message.id);
entry.cleanup();

if (message.type === 'result') {
entry.resolve({ issues: message.issues });
} else {
entry.reject(new Error(message.error));
}
};

const dictionary = new Typo('en_US', affData, dicData);
const handleError = (event: ErrorEvent) => {
const error = new Error(event.message || 'Typo worker crashed');

for (const [, entry] of pending) {
entry.cleanup();
entry.reject(error);
}

pending.clear();
};

worker.addEventListener('message', handleMessage);
worker.addEventListener('error', handleError);

return {
id: 'typo-js',
Expand All @@ -26,42 +65,60 @@ export async function createTypoJsProvider() {
async check(request: {
segments: { id: string; text: string }[];
maxSuggestions?: number;
signal?: AbortSignal;
}) {
const issues: {
segmentId: string;
start: number;
end: number;
kind: 'spelling';
message: string;
replacements: string[];
}[] = [];

const maxSuggestions = request.maxSuggestions ?? 5;

for (const segment of request.segments) {
let match: RegExpExecArray | null;
WORD_RE.lastIndex = 0;

while ((match = WORD_RE.exec(segment.text)) !== null) {
const word = match[0];

// Skip very short words and apostrophe-only tokens
if (word.replace(/['\u2019]/g, '').length < 2) continue;

if (!dictionary.check(word)) {
issues.push({
segmentId: segment.id,
start: match.index,
end: match.index + word.length,
kind: 'spelling',
message: `Unknown word: "${word}"`,
replacements: dictionary.suggest(word).slice(0, maxSuggestions),
});
}
}
if (request.signal?.aborted) {
throw createAbortError();
}

return new Promise<{ issues: TypoWorkerIssue[] }>((resolve, reject) => {
const requestId = ++nextRequestId;
const maxSuggestions = request.maxSuggestions ?? 5;

const cleanup = () => {
request.signal?.removeEventListener('abort', onAbort);
};

const onAbort = () => {
pending.delete(requestId);
cleanup();
const cancel: TypoWorkerCancelMessage = { type: 'cancel', id: requestId };
worker.postMessage(cancel);
reject(createAbortError());
Comment thread
chittolinag marked this conversation as resolved.
};
Comment thread
chittolinag marked this conversation as resolved.

pending.set(requestId, {
resolve,
reject,
cleanup,
});

request.signal?.addEventListener('abort', onAbort);

const payload: TypoWorkerRequest = {
id: requestId,
type: 'check',
payload: {
segments: request.segments,
maxSuggestions,
},
};

worker.postMessage(payload);
});
},

dispose() {
worker.removeEventListener('message', handleMessage);
worker.removeEventListener('error', handleError);
worker.terminate();

for (const [, entry] of pending) {
entry.cleanup();
entry.reject(new Error('Typo.js provider disposed'));
}

return { issues };
pending.clear();
},
};
}
114 changes: 114 additions & 0 deletions examples/features/spell-check/typo-js/src/typoWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/// <reference lib="webworker" />

import Typo from 'typo-js';
import affUrl from 'typo-js/dictionaries/en_US/en_US.aff?url';
import dicUrl from 'typo-js/dictionaries/en_US/en_US.dic?url';
import type {
TypoWorkerIssue,
TypoWorkerRequest,
TypoWorkerResponse,
TypoWorkerIncomingMessage,
} from './typoWorkerMessages';

const ctx: DedicatedWorkerGlobalScope = self as unknown as DedicatedWorkerGlobalScope;
const WORD_PATTERN = /[a-zA-Z'\u2019]+/g;

/** Yields to the worker event loop so `cancel` messages can be processed mid-check. */
const YIELD_EVERY_WORDS = 25;

let dictionaryPromise: Promise<Typo> | null = null;

/** Cancelled request ids (added by `cancel` messages from the main thread). */
const cancelledIds = new Set<number>();

async function loadDictionary(): Promise<Typo> {
if (!dictionaryPromise) {
dictionaryPromise = Promise.all([
fetch(affUrl).then((r) => r.text()),
fetch(dicUrl).then((r) => r.text()),
]).then(([affData, dicData]) => new Typo('en_US', affData, dicData));
}

return dictionaryPromise;
}

/**
* Returns issues, or `null` if the request was cancelled (caller must not post a result).
* Yields periodically so abort can be observed while Typo runs synchronously per word.
*/
async function collectIssues(
payload: TypoWorkerRequest['payload'],
dictionary: Typo,
isAborted: () => boolean,
): Promise<TypoWorkerIssue[] | null> {
const issues: TypoWorkerIssue[] = [];
const maxSuggestions = payload.maxSuggestions ?? 5;
let wordCount = 0;

for (const segment of payload.segments) {
for (const match of segment.text.matchAll(WORD_PATTERN)) {
if (isAborted()) return null;

const word = match[0];
if (word.replace(/['\u2019]/g, '').length < 2) continue;

if (!dictionary.check(word)) {
issues.push({
segmentId: segment.id,
start: match.index,
end: match.index + word.length,
kind: 'spelling',
message: `Unknown word: "${word}"`,
replacements: maxSuggestions > 0 ? dictionary.suggest(word).slice(0, maxSuggestions) : [],
});
}

wordCount++;
if (wordCount % YIELD_EVERY_WORDS === 0) {
// Yield to the event loop so abort can be observed mid-check.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
if (isAborted()) return null;
}
}
}

return issues;
}

function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return 'Typo worker failed';
}

async function handleCheck(data: TypoWorkerRequest): Promise<void> {
const id = data.id;

try {
if (cancelledIds.has(id)) return;

const dictionary = await loadDictionary();
if (cancelledIds.has(id)) return;

const collected = await collectIssues(data.payload, dictionary, () => cancelledIds.has(id));
if (collected === null) return;

ctx.postMessage({ id, type: 'result', issues: collected } satisfies TypoWorkerResponse);
} catch (error) {
ctx.postMessage({ id, type: 'error', error: toErrorMessage(error) } satisfies TypoWorkerResponse);
} finally {
cancelledIds.delete(id);
}
}

ctx.addEventListener('message', (event: MessageEvent<TypoWorkerIncomingMessage>) => {
const { data } = event;

if (data.type === 'cancel') {
cancelledIds.add(data.id);
return;
}

if (data.type !== 'check') return;

handleCheck(data);
});
41 changes: 41 additions & 0 deletions examples/features/spell-check/typo-js/src/typoWorkerMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export type TypoWorkerIssue = {
segmentId: string;
start: number;
end: number;
kind: 'spelling';
message: string;
replacements: string[];
};

export type TypoWorkerPayload = {
segments: { id: string; text: string }[];
maxSuggestions: number;
};

export type TypoWorkerRequest = {
id: number;
type: 'check';
payload: TypoWorkerPayload;
};

/** Tells the worker to stop work for a timed-out or aborted check (id matches the check request). */
export type TypoWorkerCancelMessage = {
type: 'cancel';
id: number;
};

export type TypoWorkerIncomingMessage = TypoWorkerRequest | TypoWorkerCancelMessage;

type TypoWorkerResultMessage = {
id: number;
type: 'result';
issues: TypoWorkerIssue[];
};

type TypoWorkerErrorMessage = {
id: number;
type: 'error';
error: string;
};

export type TypoWorkerResponse = TypoWorkerResultMessage | TypoWorkerErrorMessage;
Loading