| |
| |
|
|
| |
| |
| |
| |
| |
| function escapeHtml(text) { |
| const map = { |
| '&': '&', |
| '<': '<', |
| '>': '>', |
| '"': '"', |
| "'": ''' |
| }; |
| return text.replace(/[&<>"']/g, (c) => map[c]); |
| } |
|
|
| |
| |
| |
| |
| |
| function sortSuggestions(suggestions) { |
| return [...suggestions].sort((a, b) => a.start - b.start); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function createSegments(text, suggestions) { |
| const sorted = sortSuggestions(suggestions); |
| const segments = []; |
| let currentPos = 0; |
|
|
| |
| const events = []; |
| sorted.forEach((suggestion, idx) => { |
| events.push({ |
| pos: suggestion.start, |
| type: 'start', |
| suggestionIdx: idx |
| }); |
| events.push({ |
| pos: suggestion.end, |
| type: 'end', |
| suggestionIdx: idx |
| }); |
| }); |
|
|
| |
| events.sort((a, b) => a.pos - b.pos || (a.type === 'end' ? 1 : -1)); |
|
|
| const activeSuggestions = []; |
|
|
| events.forEach((event) => { |
| const pos = event.pos; |
|
|
| |
| if (currentPos < pos) { |
| segments.push({ |
| type: 'text', |
| text: text.slice(currentPos, pos), |
| suggestions: [] |
| }); |
| } |
|
|
| |
| if (event.type === 'start') { |
| activeSuggestions.push(sorted[event.suggestionIdx]); |
| } else { |
| activeSuggestions.splice( |
| activeSuggestions.findIndex((s) => s === sorted[event.suggestionIdx]), |
| 1 |
| ); |
| } |
|
|
| currentPos = pos; |
| }); |
|
|
| |
| if (currentPos < text.length) { |
| segments.push({ |
| type: 'text', |
| text: text.slice(currentPos), |
| suggestions: [] |
| }); |
| } |
|
|
| |
| const finalSegments = []; |
| let segStart = 0; |
|
|
| sorted.forEach((suggestion, idx) => { |
| const { start, end } = suggestion; |
|
|
| |
| if (segStart < start) { |
| finalSegments.push({ |
| type: 'text', |
| text: text.slice(segStart, start), |
| suggestions: [] |
| }); |
| } |
|
|
| |
| finalSegments.push({ |
| type: 'suggestion', |
| text: text.slice(start, end), |
| suggestion: suggestion |
| }); |
|
|
| segStart = end; |
| }); |
|
|
| |
| if (segStart < text.length) { |
| finalSegments.push({ |
| type: 'text', |
| text: text.slice(segStart), |
| suggestions: [] |
| }); |
| } |
|
|
| return finalSegments; |
| } |
|
|
| |
| |
| |
| |
| |
| function getErrorClass(type) { |
| const classes = { |
| 'spelling': 'spelling-error', |
| 'grammar': 'grammar-error', |
| 'punctuation': 'punctuation-suggestion' |
| }; |
| return classes[type] || 'spelling-error'; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function renderHighlightedText(text, suggestions) { |
| if (!text || text.length === 0) { |
| return ''; |
| } |
|
|
| if (!suggestions || suggestions.length === 0) { |
| |
| return escapeHtml(text); |
| } |
|
|
| const segments = createSegments(text, suggestions); |
| let html = ''; |
| |
| let suggestionIdx = 0; |
|
|
| segments.forEach((segment) => { |
| if (segment.type === 'text') { |
| |
| html += escapeHtml(segment.text); |
| } else if (segment.type === 'suggestion') { |
| |
| const { suggestion } = segment; |
| const errorClass = getErrorClass(suggestion.type); |
| const escapedText = escapeHtml(segment.text); |
| |
| const sid = suggestion.id || suggestionIdx; |
|
|
| html += `<span class="${errorClass}" data-suggestion-id="${sid}" data-original="${escapeHtml( |
| suggestion.original |
| )}" data-correction="${escapeHtml( |
| suggestion.correction |
| )}" data-type="${suggestion.type}" title="${suggestion.type}: ${escapeHtml(suggestion.correction)}">${escapedText}</span>`; |
|
|
| suggestionIdx++; |
| } |
| }); |
|
|
| return html; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function render(input) { |
| const { text = '', suggestions = [] } = input; |
| return renderHighlightedText(text, suggestions); |
| } |
|
|
| |
| |
| |
| function walkTextNodes(root, callback) { |
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false); |
| let node; |
| while ((node = walker.nextNode())) { |
| callback(node); |
| } |
| } |
|
|
| |
| |
| |
| |
| function clearOverlays(editor) { |
| const errorSpans = editor.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion'); |
| errorSpans.forEach(span => { |
| |
| if (span.closest('.quran-applied')) return; |
| const parent = span.parentNode; |
| while (span.firstChild) { |
| parent.insertBefore(span.firstChild, span); |
| } |
| parent.removeChild(span); |
| }); |
| editor.normalize(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function overlaySuggestions(editor, suggestions) { |
| |
| clearOverlays(editor); |
|
|
| if (!suggestions || suggestions.length === 0) return; |
|
|
| |
| const textNodes = []; |
| let offset = 0; |
| walkTextNodes(editor, (node) => { |
| |
| if (node.parentElement && node.parentElement.closest('.quran-applied')) { |
| offset += node.length; |
| return; |
| } |
| textNodes.push({ node, start: offset, end: offset + node.length }); |
| offset += node.length; |
| }); |
|
|
| if (textNodes.length === 0) return; |
|
|
| |
| const sorted = [...suggestions].sort((a, b) => b.start - a.start); |
|
|
| sorted.forEach((suggestion, reverseIdx) => { |
| const { start, end } = suggestion; |
| const errorClass = getErrorClass(suggestion.type); |
|
|
| |
| const overlapping = textNodes.filter(tn => tn.start < end && tn.end > start); |
| if (overlapping.length === 0) return; |
|
|
| |
| const wrapper = document.createElement('span'); |
| wrapper.className = errorClass; |
| |
| wrapper.dataset.suggestionId = suggestion.id || String(reverseIdx); |
| wrapper.dataset.original = suggestion.original || ''; |
| wrapper.dataset.correction = suggestion.correction || ''; |
| wrapper.dataset.type = suggestion.type || 'spelling'; |
| wrapper.title = `${suggestion.type}: ${suggestion.correction}`; |
|
|
| if (overlapping.length === 1) { |
| |
| const tn = overlapping[0]; |
| const localStart = Math.max(0, start - tn.start); |
| const localEnd = Math.min(tn.node.length, end - tn.start); |
|
|
| |
| const textContent = tn.node.textContent; |
| const beforeText = textContent.slice(0, localStart); |
| const errorText = textContent.slice(localStart, localEnd); |
| const afterText = textContent.slice(localEnd); |
|
|
| const parent = tn.node.parentNode; |
| const errorTextNode = document.createTextNode(errorText); |
| wrapper.appendChild(errorTextNode); |
|
|
| |
| if (afterText) { |
| parent.insertBefore(document.createTextNode(afterText), tn.node.nextSibling); |
| } |
| parent.insertBefore(wrapper, tn.node.nextSibling || null); |
| if (beforeText) { |
| parent.insertBefore(document.createTextNode(beforeText), wrapper); |
| } |
| parent.removeChild(tn.node); |
|
|
| } else { |
| |
| |
| try { |
| const range = document.createRange(); |
|
|
| const firstTN = overlapping[0]; |
| const lastTN = overlapping[overlapping.length - 1]; |
| const rangeStart = Math.max(0, start - firstTN.start); |
| const rangeEnd = Math.min(lastTN.node.length, end - lastTN.start); |
|
|
| range.setStart(firstTN.node, rangeStart); |
| range.setEnd(lastTN.node, rangeEnd); |
|
|
| range.surroundContents(wrapper); |
| } catch (e) { |
| |
| |
| const tn = overlapping[0]; |
| const localStart = Math.max(0, start - tn.start); |
| const localEnd = Math.min(tn.node.length, end - tn.start); |
|
|
| if (localEnd > localStart) { |
| const textContent = tn.node.textContent; |
| const beforeText = textContent.slice(0, localStart); |
| const errorText = textContent.slice(localStart, localEnd); |
| const afterText = textContent.slice(localEnd); |
|
|
| const parent = tn.node.parentNode; |
| wrapper.appendChild(document.createTextNode(errorText)); |
| if (afterText) parent.insertBefore(document.createTextNode(afterText), tn.node.nextSibling); |
| parent.insertBefore(wrapper, tn.node.nextSibling || null); |
| if (beforeText) parent.insertBefore(document.createTextNode(beforeText), wrapper); |
| parent.removeChild(tn.node); |
| } |
| } |
| } |
|
|
| |
| textNodes.length = 0; |
| offset = 0; |
| walkTextNodes(editor, (node) => { |
| textNodes.push({ node, start: offset, end: offset + node.length }); |
| offset += node.length; |
| }); |
| }); |
| } |
|
|
| |
| if (typeof module !== 'undefined' && module.exports) { |
| module.exports = { |
| render, |
| renderHighlightedText, |
| escapeHtml, |
| createSegments, |
| sortSuggestions, |
| getErrorClass, |
| overlaySuggestions, |
| clearOverlays |
| }; |
| } |
|
|