mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
274 lines
8.4 KiB
JavaScript
274 lines
8.4 KiB
JavaScript
'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;
|
||
}
|
||
}
|