Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-05-12 04:43:25 +10:00
parent 655d16ea6b
commit b09a329d62
13 changed files with 3602 additions and 27 deletions

View 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;
}
}