mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-26 11:46:04 +10:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
273
tools/blogeditor/spellcheck.js
Normal file
273
tools/blogeditor/spellcheck.js
Normal file
@@ -0,0 +1,273 @@
|
||||
'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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user