Files
lit.ruv.wtf/tools/blogeditor/spellcheck.js

274 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
import nspell from 'nspell';
import affData from 'dictionary-en-us/index.aff';
import dicData from 'dictionary-en-us/index.dic';
const spell = nspell(affData, dicData);
const SPELLCHECK_HIGHLIGHT = 'blog-editor-spellcheck';
const WORD_PATTERN = /[A-Za-z][A-Za-z'-]{1,}/g;
const SKIP_TAGS = new Set(['CODE', 'PRE', 'A', 'KBD', 'SAMP']);
const PERSONAL_DICTIONARY_KEY = 'blog-editor-personal-dictionary-v1';
/**
* @param {string} value
* @returns {boolean}
*/
function isWordChar(value) {
return /[A-Za-z'-]/.test(value);
}
/**
* Lightweight editor spellcheck controller using CSS Highlights.
*/
export class BlogEditorSpellcheck {
/**
* @param {HTMLElement} root
* @param {(message: string, isError?: boolean) => void} updateStatus
*/
constructor(root, updateStatus) {
this.root = root;
this.updateStatus = updateStatus;
this.enabled = true;
this.pendingTimer = 0;
this.highlightName = SPELLCHECK_HIGHLIGHT;
this.personalWords = new Set();
this.supported = typeof CSS !== 'undefined' && typeof CSS.highlights !== 'undefined' && typeof Highlight !== 'undefined';
this.loadPersonalDictionary();
}
/**
* @param {boolean} enabled
*/
setEnabled(enabled) {
this.enabled = enabled;
if (!this.supported) {
this.updateStatus(enabled ? 'spellcheck unavailable in this renderer' : 'spellcheck off', !enabled ? false : true);
return;
}
if (!enabled) {
this.clear();
this.updateStatus('spellcheck off');
return;
}
this.schedule();
}
schedule() {
if (!this.supported || !this.enabled) return;
window.clearTimeout(this.pendingTimer);
this.pendingTimer = window.setTimeout(() => this.refresh(), 180);
}
refresh() {
if (!this.supported || !this.enabled) return;
const highlight = new Highlight();
let count = 0;
for (const node of this.iterTextNodes()) {
const text = node.textContent ?? '';
WORD_PATTERN.lastIndex = 0;
let match;
while ((match = WORD_PATTERN.exec(text)) !== null) {
const word = match[0];
if (!this.isMisspelledWord(word)) continue;
const range = new Range();
range.setStart(node, match.index);
range.setEnd(node, match.index + word.length);
highlight.add(range);
count += 1;
}
}
CSS.highlights.set(this.highlightName, highlight);
this.updateStatus(count > 0 ? `spellcheck on · ${count} issue${count === 1 ? '' : 's'}` : 'spellcheck on · clear');
}
clear() {
if (!this.supported) return;
window.clearTimeout(this.pendingTimer);
CSS.highlights.delete(this.highlightName);
}
destroy() {
this.clear();
}
/**
* Loads the personal dictionary from browser storage.
*/
loadPersonalDictionary() {
if (typeof localStorage === 'undefined') return;
try {
const raw = localStorage.getItem(PERSONAL_DICTIONARY_KEY);
if (!raw) return;
const words = JSON.parse(raw);
if (!Array.isArray(words)) return;
for (const entry of words) {
if (typeof entry !== 'string' || !entry.trim()) continue;
const word = entry.trim();
this.personalWords.add(word);
spell.add(word);
}
} catch {
this.personalWords.clear();
}
}
/**
* Persists the personal dictionary to browser storage.
*/
savePersonalDictionary() {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(PERSONAL_DICTIONARY_KEY, JSON.stringify([...this.personalWords].sort((a, b) => a.localeCompare(b))));
} catch {
// Ignore storage quota / privacy errors.
}
}
/**
* Adds a word to the personal dictionary and refreshes highlights.
* @param {string} word
*/
addToDictionary(word) {
const normalized = typeof word === 'string' ? word.trim() : '';
if (!normalized) return;
if (this.personalWords.has(normalized)) return;
this.personalWords.add(normalized);
spell.add(normalized);
this.savePersonalDictionary();
this.refresh();
this.updateStatus(`added "${normalized}" to dictionary`);
}
/**
* @param {string} word
* @returns {boolean}
*/
isMisspelledWord(word) {
if (!this.shouldCheckWord(word)) return false;
return !spell.correct(word);
}
/**
* @param {string} word
* @param {number} [limit]
* @returns {string[]}
*/
getSuggestions(word, limit = 6) {
if (!word || !this.isMisspelledWord(word)) return [];
return spell.suggest(word).slice(0, Math.max(0, limit));
}
*iterTextNodes() {
const walker = document.createTreeWalker(this.root, NodeFilter.SHOW_TEXT, {
acceptNode: node => {
if (!(node.parentElement instanceof HTMLElement)) return NodeFilter.FILTER_REJECT;
if (!node.textContent || !WORD_PATTERN.test(node.textContent)) return NodeFilter.FILTER_REJECT;
if (node.parentElement.closest('[data-type="image-block"]')) return NodeFilter.FILTER_REJECT;
if (this.shouldSkip(node.parentElement)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
let current;
while ((current = walker.nextNode())) {
yield current;
}
}
/**
* @param {HTMLElement} element
* @returns {boolean}
*/
shouldSkip(element) {
let current = element;
while (current && current !== this.root) {
if (SKIP_TAGS.has(current.tagName)) return true;
if (current.getAttribute('contenteditable') === 'false') return true;
current = current.parentElement;
}
return false;
}
/**
* Returns the misspelled word under a viewport point, if any.
* @param {number} clientX
* @param {number} clientY
* @returns {string | null}
*/
getMisspelledWordAtPoint(clientX, clientY) {
const hit = this.getMisspelledAtPoint(clientX, clientY);
return hit ? hit.word : null;
}
/**
* Returns misspelled word details under a viewport point, if any.
* @param {number} clientX
* @param {number} clientY
* @returns {{ word: string, range: Range } | null}
*/
getMisspelledAtPoint(clientX, clientY) {
if (!this.supported) return null;
let node = null;
let offset = 0;
if (typeof document.caretPositionFromPoint === 'function') {
const pos = document.caretPositionFromPoint(clientX, clientY);
if (pos) {
node = pos.offsetNode;
offset = pos.offset;
}
} else if (typeof document.caretRangeFromPoint === 'function') {
const range = document.caretRangeFromPoint(clientX, clientY);
if (range) {
node = range.startContainer;
offset = range.startOffset;
}
}
if (!(node instanceof Text)) return null;
if (!(node.parentElement instanceof HTMLElement)) return null;
if (this.shouldSkip(node.parentElement)) return null;
const text = node.textContent ?? '';
if (!text) return null;
let start = Math.min(Math.max(offset, 0), text.length);
let end = start;
if (start > 0 && !isWordChar(text[start]) && isWordChar(text[start - 1])) {
start -= 1;
end = start;
}
while (start > 0 && isWordChar(text[start - 1])) start -= 1;
while (end < text.length && isWordChar(text[end])) end += 1;
if (end <= start) return null;
const word = text.slice(start, end);
if (!this.isMisspelledWord(word)) return null;
const range = new Range();
range.setStart(node, start);
range.setEnd(node, end);
return { word, range };
}
/**
* @param {string} word
* @returns {boolean}
*/
shouldCheckWord(word) {
if (word.length < 3) return false;
if (/^[A-Z0-9_-]+$/.test(word)) return false;
if (/\d/.test(word)) return false;
return true;
}
}