Implement all 19 remaining UI features: toolbar tools, find/replace, focus mode, error tabs, donut chart, version history, Arabic tools, tab animations, etc.
804eb2f | // src/js/format.js | |
| // Rich text formatting commands for the editor | |
| /** | |
| * Execute a formatting command on the current selection | |
| * @param {string} command - execCommand name | |
| * @param {string} [value] - optional value | |
| * @param {boolean} [keepSelection] - if true, don't collapse selection | |
| */ | |
| function execFormat(command, value, keepSelection) { | |
| pushUndoState(); // Save state before formatting | |
| document.execCommand(command, false, value !== undefined ? value : null); | |
| const editor = getEditorElement(); | |
| if (editor) editor.focus(); | |
| // Collapse selection after formatting so text doesn't stay highlighted | |
| if (!keepSelection) { | |
| const sel = window.getSelection(); | |
| if (sel && sel.rangeCount > 0 && !sel.isCollapsed) { | |
| sel.collapseToEnd(); | |
| } | |
| } | |
| updateFormatState(); | |
| } | |
| /* ── Text style ── */ | |
| function formatBold() { execFormat('bold'); } | |
| function formatItalic() { execFormat('italic'); } | |
| function formatUnderline() { execFormat('underline'); } | |
| function formatStrikethrough() { execFormat('strikethrough'); } | |
| /* ── Undo / Redo (uses custom stack — same as Ctrl+Z/Y) ── */ | |
| function formatUndo() { editorUndo(); } | |
| function formatRedo() { editorRedo(); } | |
| /* ── Alignment (applies to paragraph containing selection/cursor) ── */ | |
| function formatAlignRight() { execFormat('justifyRight'); } | |
| function formatAlignCenter() { execFormat('justifyCenter'); } | |
| function formatAlignLeft() { execFormat('justifyLeft'); } | |
| /* ── Text Direction (RTL/LTR) ── */ | |
| function setDirection(dir) { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| const sel = window.getSelection(); | |
| if (sel && sel.rangeCount > 0) { | |
| const range = sel.getRangeAt(0); | |
| let block = range.startContainer; | |
| if (block.nodeType === 3) block = block.parentNode; | |
| while (block && block !== editor && !['DIV','P','H1','H2','H3','H4','H5','H6','LI','BLOCKQUOTE'].includes(block.tagName)) { | |
| block = block.parentNode; | |
| } | |
| if (block && block !== editor) { | |
| block.setAttribute('dir', dir); | |
| block.style.direction = dir; | |
| block.style.textAlign = dir === 'rtl' ? 'right' : 'left'; | |
| } else { | |
| editor.setAttribute('dir', dir); | |
| editor.style.direction = dir; | |
| } | |
| } | |
| updateFormatState(); | |
| } | |
| /* ── Insert Link ── */ | |
| function insertLink() { | |
| const sel = window.getSelection(); | |
| if (!sel || !sel.rangeCount) return; | |
| const selectedText = sel.toString(); | |
| const url = prompt('أدخل الرابط (URL):', 'https://'); | |
| if (!url || url === 'https://') return; | |
| if (selectedText) { | |
| execFormat('createLink', url); | |
| } else { | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.textContent = url; | |
| link.target = '_blank'; | |
| const range = sel.getRangeAt(0); | |
| range.insertNode(link); | |
| range.setStartAfter(link); | |
| range.collapse(true); | |
| sel.removeAllRanges(); | |
| sel.addRange(range); | |
| } | |
| } | |
| /* ── Line Height Cycling ── */ | |
| var _currentLineHeight = 1.8; | |
| function cycleLineHeight() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| const heights = [1.5, 2.0, 2.5, 1.8]; | |
| const idx = heights.indexOf(_currentLineHeight); | |
| _currentLineHeight = heights[(idx + 1) % heights.length]; | |
| editor.style.lineHeight = _currentLineHeight; | |
| const btn = document.getElementById('fmt-line-height'); | |
| if (btn) { | |
| btn.setAttribute('data-tooltip', 'ارتفاع السطر: ' + _currentLineHeight); | |
| } | |
| } | |
| /* ── Blockquote ── */ | |
| function formatBlockquote() { | |
| execFormat('formatBlock', 'blockquote'); | |
| } | |
| /* ── Paragraph Spacing ── */ | |
| var _paragraphSpacing = 'normal'; | |
| function cycleParagraphSpacing() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| const modes = { tight: '0.3em', normal: '0.8em', wide: '1.5em' }; | |
| const order = ['tight', 'normal', 'wide']; | |
| const idx = order.indexOf(_paragraphSpacing); | |
| _paragraphSpacing = order[(idx + 1) % order.length]; | |
| const val = modes[_paragraphSpacing]; | |
| editor.style.setProperty('--paragraph-spacing', val); | |
| editor.querySelectorAll('div,p').forEach(el => { | |
| el.style.marginBottom = val; | |
| }); | |
| } | |
| /* ── Find & Replace ── */ | |
| var _findMatches = []; | |
| var _findIdx = -1; | |
| function openFindReplace() { | |
| const bar = document.getElementById('find-replace-bar'); | |
| if (bar) { | |
| bar.style.display = 'block'; | |
| document.getElementById('find-input')?.focus(); | |
| } | |
| } | |
| function closeFindReplace() { | |
| const bar = document.getElementById('find-replace-bar'); | |
| if (bar) bar.style.display = 'none'; | |
| clearHighlights(); | |
| _findMatches = []; | |
| _findIdx = -1; | |
| const countEl = document.getElementById('find-count'); | |
| if (countEl) countEl.textContent = '0/0'; | |
| } | |
| function toggleReplace() { | |
| const row = document.getElementById('replace-row'); | |
| if (row) row.style.display = row.style.display === 'none' ? 'flex' : 'none'; | |
| } | |
| function doFind() { | |
| const query = document.getElementById('find-input')?.value; | |
| if (!query) { clearHighlights(); return; } | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| clearHighlights(); | |
| const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); | |
| _findMatches = []; | |
| let node; | |
| while (node = walker.nextNode()) { | |
| let idx = node.textContent.indexOf(query); | |
| while (idx !== -1) { | |
| _findMatches.push({ node, idx, len: query.length }); | |
| idx = node.textContent.indexOf(query, idx + 1); | |
| } | |
| } | |
| // Highlight all matches | |
| for (let i = _findMatches.length - 1; i >= 0; i--) { | |
| const m = _findMatches[i]; | |
| const range = document.createRange(); | |
| range.setStart(m.node, m.idx); | |
| range.setEnd(m.node, m.idx + m.len); | |
| const span = document.createElement('span'); | |
| span.className = 'find-highlight'; | |
| range.surroundContents(span); | |
| } | |
| // Re-collect highlighted spans | |
| _findMatches = Array.from(editor.querySelectorAll('.find-highlight')); | |
| _findIdx = _findMatches.length > 0 ? 0 : -1; | |
| updateFindCount(); | |
| if (_findIdx >= 0) highlightCurrent(); | |
| } | |
| function findNext() { | |
| if (_findMatches.length === 0) { doFind(); return; } | |
| _findIdx = (_findIdx + 1) % _findMatches.length; | |
| updateFindCount(); | |
| highlightCurrent(); | |
| } | |
| function findPrev() { | |
| if (_findMatches.length === 0) { doFind(); return; } | |
| _findIdx = (_findIdx - 1 + _findMatches.length) % _findMatches.length; | |
| updateFindCount(); | |
| highlightCurrent(); | |
| } | |
| function highlightCurrent() { | |
| _findMatches.forEach((el, i) => { | |
| el.classList.toggle('find-highlight--active', i === _findIdx); | |
| }); | |
| if (_findMatches[_findIdx]) { | |
| _findMatches[_findIdx].scrollIntoView({ block: 'center', behavior: 'smooth' }); | |
| } | |
| } | |
| function updateFindCount() { | |
| const el = document.getElementById('find-count'); | |
| if (el) el.textContent = _findMatches.length > 0 ? `${_findIdx + 1}/${_findMatches.length}` : '0/0'; | |
| } | |
| function replaceCurrent() { | |
| if (_findIdx < 0 || !_findMatches[_findIdx]) return; | |
| const replaceVal = document.getElementById('replace-input')?.value || ''; | |
| _findMatches[_findIdx].textContent = replaceVal; | |
| _findMatches[_findIdx].className = ''; | |
| _findMatches.splice(_findIdx, 1); | |
| if (_findIdx >= _findMatches.length) _findIdx = 0; | |
| updateFindCount(); | |
| if (_findMatches.length > 0) highlightCurrent(); | |
| } | |
| function replaceAll() { | |
| const replaceVal = document.getElementById('replace-input')?.value || ''; | |
| _findMatches.forEach(el => { | |
| el.textContent = replaceVal; | |
| el.className = ''; | |
| }); | |
| _findMatches = []; | |
| _findIdx = -1; | |
| updateFindCount(); | |
| } | |
| function clearHighlights() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| editor.querySelectorAll('.find-highlight').forEach(el => { | |
| const parent = el.parentNode; | |
| parent.replaceChild(document.createTextNode(el.textContent), el); | |
| parent.normalize(); | |
| }); | |
| } | |
| /* ── Focus Mode ── */ | |
| var _focusMode = false; | |
| function toggleFocusMode() { | |
| _focusMode = !_focusMode; | |
| document.body.classList.toggle('focus-mode', _focusMode); | |
| const btn = document.getElementById('focus-mode-btn'); | |
| if (btn) btn.classList.toggle('fmt-active', _focusMode); | |
| } | |
| /* ── Typewriter Mode ── */ | |
| var _typewriterMode = false; | |
| function toggleTypewriterMode() { | |
| _typewriterMode = !_typewriterMode; | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| editor.classList.toggle('typewriter-mode', _typewriterMode); | |
| if (_typewriterMode) { | |
| editor.addEventListener('input', typewriterScroll); | |
| editor.addEventListener('click', typewriterScroll); | |
| } else { | |
| editor.removeEventListener('input', typewriterScroll); | |
| editor.removeEventListener('click', typewriterScroll); | |
| } | |
| } | |
| function typewriterScroll() { | |
| const sel = window.getSelection(); | |
| if (!sel.rangeCount) return; | |
| const range = sel.getRangeAt(0); | |
| const rect = range.getBoundingClientRect(); | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| const editorRect = editor.getBoundingClientRect(); | |
| const middle = editorRect.height / 2; | |
| const offset = rect.top - editorRect.top - middle; | |
| editor.scrollTop += offset; | |
| } | |
| /* ── Version History ── */ | |
| function saveVersion() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| const versions = JSON.parse(localStorage.getItem('bayan_versions') || '[]'); | |
| versions.push({ | |
| timestamp: Date.now(), | |
| content: editor.innerHTML, | |
| preview: editor.textContent.substring(0, 50) | |
| }); | |
| // Keep last 20 versions | |
| if (versions.length > 20) versions.shift(); | |
| localStorage.setItem('bayan_versions', JSON.stringify(versions)); | |
| } | |
| function showVersionHistory() { | |
| const versions = JSON.parse(localStorage.getItem('bayan_versions') || '[]'); | |
| if (!versions.length) { alert('لا توجد نسخ سابقة'); return; } | |
| let msg = 'اختر نسخة للاستعادة:\n\n'; | |
| versions.forEach((v, i) => { | |
| const date = new Date(v.timestamp); | |
| msg += `${i + 1}. ${date.toLocaleString('ar-EG')} — "${v.preview}..."\n`; | |
| }); | |
| const choice = prompt(msg + '\nأدخل رقم النسخة:'); | |
| if (!choice) return; | |
| const idx = parseInt(choice) - 1; | |
| if (idx >= 0 && idx < versions.length) { | |
| const editor = getEditorElement(); | |
| if (editor) { | |
| pushUndoState(); | |
| editor.innerHTML = versions[idx].content; | |
| } | |
| } | |
| } | |
| /* ── Collaboration Hints (last edit timestamp) ── */ | |
| var _lastEditTime = null; | |
| function updateLastEditTime() { | |
| _lastEditTime = Date.now(); | |
| updateCollabHint(); | |
| } | |
| function updateCollabHint() { | |
| const el = document.getElementById('last-edit-hint'); | |
| if (!el || !_lastEditTime) return; | |
| const diff = Math.floor((Date.now() - _lastEditTime) / 1000); | |
| if (diff < 5) el.textContent = 'الآن'; | |
| else if (diff < 60) el.textContent = `منذ ${diff} ثانية`; | |
| else if (diff < 3600) el.textContent = `منذ ${Math.floor(diff/60)} دقيقة`; | |
| else el.textContent = `منذ ${Math.floor(diff/3600)} ساعة`; | |
| } | |
| /* ── Tashkeel (Diacritics) ── */ | |
| function addTashkeel() { | |
| // This adds basic tashkeel placeholder — real implementation needs NLP | |
| alert('ميزة التشكيل التلقائي قيد التطوير. سيتم إضافتها قريباً.'); | |
| } | |
| function removeTashkeel() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| pushUndoState(); | |
| const diacritics = /[\u064B-\u065F\u0670\u06D6-\u06ED]/g; | |
| // Walk through text nodes only | |
| const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); | |
| let node; | |
| while (node = walker.nextNode()) { | |
| const cleaned = node.textContent.replace(diacritics, ''); | |
| if (cleaned !== node.textContent) { | |
| node.textContent = cleaned; | |
| } | |
| } | |
| } | |
| /* ── Number Conversion (Arabic ↔ Hindi) ── */ | |
| function convertToHindiNumerals() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| pushUndoState(); | |
| const map = {'0':'٠','1':'١','2':'٢','3':'٣','4':'٤','5':'٥','6':'٦','7':'٧','8':'٨','9':'٩'}; | |
| walkTextNodes(editor, text => text.replace(/[0-9]/g, d => map[d])); | |
| } | |
| function convertToArabicNumerals() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| pushUndoState(); | |
| const map = {'٠':'0','١':'1','٢':'2','٣':'3','٤':'4','٥':'5','٦':'6','٧':'7','٨':'8','٩':'9'}; | |
| walkTextNodes(editor, text => text.replace(/[٠-٩]/g, d => map[d])); | |
| } | |
| function walkTextNodes(root, fn) { | |
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false); | |
| let node; | |
| while (node = walker.nextNode()) { | |
| const result = fn(node.textContent); | |
| if (result !== node.textContent) node.textContent = result; | |
| } | |
| } | |
| /* ── Text Cleanup ── */ | |
| function cleanupText() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| pushUndoState(); | |
| walkTextNodes(editor, text => { | |
| return text | |
| .replace(/[\u064B-\u065F\u0670\u06D6-\u06ED]/g, '') // Remove diacritics | |
| .replace(/\u200C/g, '') // Remove ZWNJ | |
| .replace(/\u200D/g, '') // Remove ZWJ | |
| .replace(/\u00A0/g, ' ') // NBSP → space | |
| .replace(/ {2,}/g, ' ') // Multiple spaces → one | |
| .replace(/\n{3,}/g, '\n\n'); // Multiple newlines → two | |
| }); | |
| } | |
| /* ── Error Badge Update ── */ | |
| function updateErrorBadge() { | |
| const badge = document.getElementById('error-badge'); | |
| if (!badge) return; | |
| const s = parseInt(document.getElementById('spelling-count')?.textContent) || 0; | |
| const g = parseInt(document.getElementById('grammar-count')?.textContent) || 0; | |
| const p = parseInt(document.getElementById('punctuation-count')?.textContent) || 0; | |
| const total = s + g + p; | |
| if (total > 0) { | |
| badge.textContent = total; | |
| badge.style.display = 'inline-flex'; | |
| } else { | |
| badge.style.display = 'none'; | |
| } | |
| } | |
| /* ── Paragraph Count ── */ | |
| function updateParagraphCount() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| const text = editor.textContent || ''; | |
| const paras = text.split(/\n\s*\n/).filter(p => p.trim().length > 0); | |
| const count = text.trim().length > 0 ? Math.max(1, paras.length) : 0; | |
| const el = document.getElementById('paragraph-count'); | |
| if (el) el.textContent = count.toLocaleString('ar-EG'); | |
| } | |
| /* ── Tab Keyboard Shortcuts ── */ | |
| function initTabShortcuts() { | |
| document.addEventListener('keydown', (e) => { | |
| if (e.altKey && !e.ctrlKey) { | |
| if (e.key === '1') { e.preventDefault(); switchTab('write'); } | |
| else if (e.key === '2') { e.preventDefault(); switchTab('summarize'); } | |
| else if (e.key === '3') { e.preventDefault(); switchTab('dialect'); } | |
| else if (e.key === '4') { e.preventDefault(); switchTab('quran'); } | |
| } | |
| // Ctrl+H for Find & Replace | |
| if (e.ctrlKey && e.key === 'h') { | |
| e.preventDefault(); | |
| openFindReplace(); | |
| } | |
| // Ctrl+Shift+F for Focus Mode | |
| if (e.ctrlKey && e.shiftKey && e.key === 'F') { | |
| e.preventDefault(); | |
| toggleFocusMode(); | |
| } | |
| // Ctrl+K for link | |
| if (e.ctrlKey && e.key === 'k') { | |
| e.preventDefault(); | |
| insertLink(); | |
| } | |
| }); | |
| } | |
| /* ── Font Hover Preview ── */ | |
| function initFontHoverPreview() { | |
| document.querySelectorAll('#fmt-font-menu .fmt-dropdown__item').forEach(item => { | |
| item.addEventListener('mouseenter', () => { | |
| const label = document.getElementById('fmt-font-label'); | |
| if (label) label.style.fontFamily = item.dataset.font; | |
| }); | |
| item.addEventListener('mouseleave', () => { | |
| const label = document.getElementById('fmt-font-label'); | |
| if (label) label.style.fontFamily = ''; | |
| }); | |
| }); | |
| } | |
| /* ── Font family ── */ | |
| function formatFont(fontName) { | |
| execFormat('fontName', fontName); | |
| // Update the dropdown label | |
| const label = document.getElementById('fmt-font-label'); | |
| if (label) label.textContent = fontName; | |
| closeAllFmtDropdowns(); | |
| } | |
| /* ── Font size ── */ | |
| function formatFontSize(size) { | |
| const sel = window.getSelection(); | |
| if (!sel.rangeCount) return; | |
| const range = sel.getRangeAt(0); | |
| if (range.collapsed) { | |
| // No selection — size will apply to next typed text | |
| // Use a zero-width space trick | |
| const span = document.createElement('span'); | |
| span.style.fontSize = size; | |
| span.textContent = '\u200B'; | |
| range.insertNode(span); | |
| // Place cursor after the span | |
| const newRange = document.createRange(); | |
| newRange.setStartAfter(span); | |
| newRange.collapse(true); | |
| sel.removeAllRanges(); | |
| sel.addRange(newRange); | |
| } else { | |
| // Wrap selected text | |
| const span = document.createElement('span'); | |
| span.style.fontSize = size; | |
| try { | |
| range.surroundContents(span); | |
| } catch (e) { | |
| // Fallback: use execCommand | |
| execFormat('fontSize', '4'); | |
| const editor = getEditorElement(); | |
| if (editor) { | |
| editor.querySelectorAll('font[size="4"]').forEach(f => { | |
| const s = document.createElement('span'); | |
| s.style.fontSize = size; | |
| s.innerHTML = f.innerHTML; | |
| f.replaceWith(s); | |
| }); | |
| } | |
| } | |
| } | |
| // Update label | |
| const label = document.getElementById('fmt-size-label'); | |
| if (label) label.textContent = parseInt(size); | |
| // Update active item | |
| document.querySelectorAll('#fmt-size-menu .fmt-dropdown__item').forEach(item => { | |
| item.classList.toggle('fmt-dropdown__item--active', item.dataset.size === size); | |
| }); | |
| closeAllFmtDropdowns(); | |
| const editor = getEditorElement(); | |
| if (editor) editor.focus(); | |
| updateFormatState(); | |
| } | |
| /** | |
| * Update toolbar button active states based on current selection | |
| */ | |
| function updateFormatState() { | |
| const btnMap = { | |
| 'fmt-bold': 'bold', | |
| 'fmt-italic': 'italic', | |
| 'fmt-underline': 'underline', | |
| 'fmt-strikethrough': 'strikeThrough', | |
| }; | |
| Object.entries(btnMap).forEach(([id, command]) => { | |
| const btn = document.getElementById(id); | |
| if (btn) { | |
| btn.classList.toggle('fmt-active', document.queryCommandState(command)); | |
| } | |
| }); | |
| // Alignment — mutually exclusive | |
| const alignMap = { | |
| 'fmt-align-right': 'justifyRight', | |
| 'fmt-align-center': 'justifyCenter', | |
| 'fmt-align-left': 'justifyLeft', | |
| }; | |
| Object.entries(alignMap).forEach(([id, command]) => { | |
| const btn = document.getElementById(id); | |
| if (btn) { | |
| btn.classList.toggle('fmt-active', document.queryCommandState(command)); | |
| } | |
| }); | |
| // Lists | |
| const listMap = { | |
| 'fmt-ul': 'insertUnorderedList', | |
| 'fmt-ol': 'insertOrderedList', | |
| }; | |
| Object.entries(listMap).forEach(([id, command]) => { | |
| const btn = document.getElementById(id); | |
| if (btn) { | |
| btn.classList.toggle('fmt-active', document.queryCommandState(command)); | |
| } | |
| }); | |
| } | |
| /** | |
| * Close all formatting dropdowns | |
| */ | |
| function closeAllFmtDropdowns() { | |
| document.querySelectorAll('.fmt-dropdown').forEach(d => d.classList.remove('open')); | |
| } | |
| /** | |
| * Toggle a specific dropdown | |
| */ | |
| function toggleFmtDropdown(wrapperId) { | |
| const wrap = document.getElementById(wrapperId); | |
| if (!wrap) return; | |
| const isOpen = wrap.classList.contains('open'); | |
| closeAllFmtDropdowns(); | |
| if (!isOpen) wrap.classList.add('open'); | |
| } | |
| /** | |
| * Initialize formatting toolbar events | |
| */ | |
| function initFormatToolbar() { | |
| const editor = getEditorElement(); | |
| if (!editor) return; | |
| // Update button states on selection change | |
| document.addEventListener('selectionchange', () => { | |
| if (editor.contains(document.activeElement) || editor === document.activeElement) { | |
| updateFormatState(); | |
| } | |
| }); | |
| // Font dropdown trigger | |
| const fontTrigger = document.getElementById('fmt-font-trigger'); | |
| if (fontTrigger) { | |
| fontTrigger.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| toggleFmtDropdown('fmt-font-wrap'); | |
| }); | |
| } | |
| // Font items | |
| document.querySelectorAll('#fmt-font-menu .fmt-dropdown__item').forEach(item => { | |
| item.addEventListener('click', () => { | |
| formatFont(item.dataset.font); | |
| }); | |
| }); | |
| // Size dropdown trigger | |
| const sizeTrigger = document.getElementById('fmt-size-trigger'); | |
| if (sizeTrigger) { | |
| sizeTrigger.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| toggleFmtDropdown('fmt-size-wrap'); | |
| }); | |
| } | |
| // Size items | |
| document.querySelectorAll('#fmt-size-menu .fmt-dropdown__item').forEach(item => { | |
| item.addEventListener('click', () => { | |
| formatFontSize(item.dataset.size); | |
| }); | |
| }); | |
| // Close dropdowns when clicking outside | |
| document.addEventListener('click', (e) => { | |
| if (!e.target.closest('.fmt-dropdown')) { | |
| closeAllFmtDropdowns(); | |
| } | |
| }); | |
| // Close dropdowns on Escape + keyboard navigation | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape') closeAllFmtDropdowns(); | |
| // ArrowDown/ArrowUp navigation inside open dropdowns | |
| if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | |
| const openDropdown = document.querySelector('.fmt-dropdown.open .fmt-dropdown__menu'); | |
| if (!openDropdown) return; | |
| e.preventDefault(); | |
| const items = Array.from(openDropdown.querySelectorAll('.fmt-dropdown__item')); | |
| if (!items.length) return; | |
| const focused = document.activeElement; | |
| const idx = items.indexOf(focused); | |
| let next; | |
| if (e.key === 'ArrowDown') { | |
| next = idx < items.length - 1 ? idx + 1 : 0; | |
| } else { | |
| next = idx > 0 ? idx - 1 : items.length - 1; | |
| } | |
| items[next].focus(); | |
| } | |
| }); | |
| // Item 8: Color pickers | |
| initColorPicker('fmt-textcolor', 'foreColor', 'fmt-textcolor-bar'); | |
| initColorPicker('fmt-highlight', 'hiliteColor', 'fmt-highlight-bar'); | |
| // Tab keyboard shortcuts (Alt+1/2/3/4, Ctrl+H, Ctrl+Shift+F, Ctrl+K) | |
| initTabShortcuts(); | |
| // Font hover preview | |
| initFontHoverPreview(); | |
| // Find input listener | |
| const findInput = document.getElementById('find-input'); | |
| if (findInput) { | |
| findInput.addEventListener('input', doFind); | |
| findInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { e.shiftKey ? findPrev() : findNext(); } | |
| if (e.key === 'Escape') closeFindReplace(); | |
| }); | |
| } | |
| // Auto-save version every 2 minutes | |
| setInterval(() => { | |
| const ed = getEditorElement(); | |
| if (ed && ed.textContent.trim().length > 10) saveVersion(); | |
| }, 120000); | |
| // Collaboration hint updater every 30s | |
| setInterval(updateCollabHint, 30000); | |
| // Hook editor input for error badge + paragraph count + last edit time | |
| if (editor) { | |
| const origInputHandler = editor.oninput; | |
| editor.addEventListener('input', () => { | |
| updateParagraphCount(); | |
| updateLastEditTime(); | |
| // Delay badge update to let analysis finish | |
| setTimeout(updateErrorBadge, 2000); | |
| }); | |
| } | |
| } | |
| /* ── Item 8: Color Picker ── */ | |
| const COLOR_PALETTE = [ | |
| '#ECEEF2', '#E88A8A', '#E4B35A', '#6BC98A', '#6BA3E0', '#A594E8', | |
| '#F5F5F5', '#FF6B6B', '#FFD93D', '#51CF66', '#339AF0', '#845EF7', | |
| '#ADB5BD', '#C92A2A', '#F08C00', '#2B8A3E', '#1864AB', '#5F3DC4', | |
| '#495057', '#862E2E', '#B7791F', '#1B5E20', '#0D47A1', '#311B92', | |
| '#212529', '#000000', '#5D4037', '#004D40', '#1A237E', '#4A148C', | |
| ]; | |
| function initColorPicker(prefix, command, barId) { | |
| const trigger = document.getElementById(prefix + '-trigger'); | |
| const wrap = document.getElementById(prefix + '-wrap'); | |
| const grid = document.getElementById(prefix + '-grid'); | |
| if (!trigger || !wrap || !grid) return; | |
| // Build swatches — add reset button first | |
| const resetSwatch = document.createElement('button'); | |
| resetSwatch.type = 'button'; | |
| resetSwatch.className = 'fmt-color-swatch fmt-color-swatch--reset'; | |
| resetSwatch.title = '\u0625\u0639\u0627\u062f\u0629 \u0627\u0644\u0627\u0641\u062a\u0631\u0627\u0636\u064a'; | |
| resetSwatch.textContent = '\u00d7'; | |
| resetSwatch.addEventListener('click', () => { | |
| document.execCommand('removeFormat', false, null); | |
| const bar = document.getElementById(barId); | |
| if (bar) bar.style.background = command === 'foreColor' ? '#ECEEF2' : 'transparent'; | |
| closeAllFmtDropdowns(); | |
| const editor = getEditorElement(); | |
| if (editor) editor.focus(); | |
| }); | |
| grid.appendChild(resetSwatch); | |
| COLOR_PALETTE.forEach(color => { | |
| const swatch = document.createElement('button'); | |
| swatch.type = 'button'; | |
| swatch.className = 'fmt-color-swatch'; | |
| swatch.style.background = color; | |
| swatch.title = color; | |
| swatch.addEventListener('click', () => { | |
| document.execCommand(command, false, color); | |
| const bar = document.getElementById(barId); | |
| if (bar) bar.style.background = color; | |
| closeAllFmtDropdowns(); | |
| const editor = getEditorElement(); | |
| if (editor) editor.focus(); | |
| }); | |
| grid.appendChild(swatch); | |
| }); | |
| // Toggle | |
| trigger.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| toggleFmtDropdown(prefix + '-wrap'); | |
| }); | |
| } | |
| /* ── Item 4: Enhanced Stats ── */ | |
| function updateEnhancedStats() { | |
| const text = getEditorText(); | |
| const charCount = text.length; | |
| // Count sentences: split on Arabic/Latin sentence endings + newlines | |
| const words = text.trim().split(/\s+/).filter(w => w.length > 0).length; | |
| let sentences = 0; | |
| if (text.trim().length > 0) { | |
| // Split by: . ! ? ؟ ، ؛ and newlines | |
| sentences = text.split(/[.!?؟\n]+/).filter(s => s.trim().length > 2).length; | |
| if (sentences === 0) sentences = 1; // at least 1 if there's text | |
| } | |
| // Reading time: ~180 words/min for Arabic, show actual minutes | |
| const readingTimeMinutes = words === 0 ? 0 : Math.max(1, Math.round(words / 180)); | |
| const charEl = document.getElementById('char-count'); | |
| const sentEl = document.getElementById('sentence-count'); | |
| const readEl = document.getElementById('reading-time'); | |
| if (charEl) charEl.textContent = charCount.toLocaleString('ar-EG'); | |
| if (sentEl) sentEl.textContent = sentences.toLocaleString('ar-EG'); | |
| if (readEl) readEl.textContent = readingTimeMinutes.toLocaleString('ar-EG'); | |
| } | |
| /* ── Item 6: Summary Stats ── */ | |
| function updateSummaryStats(summaryText) { | |
| const originalText = getEditorText(); | |
| const summaryWords = summaryText.trim().split(/\s+/).filter(w => w.length > 0).length; | |
| const originalWords = originalText.trim().split(/\s+/).filter(w => w.length > 0).length; | |
| const compression = originalWords > 0 ? Math.round((1 - summaryWords / originalWords) * 100) : 0; | |
| const statsEl = document.getElementById('summary-stats'); | |
| const wordCountEl = document.getElementById('summary-word-count'); | |
| const compressionEl = document.getElementById('summary-compression'); | |
| if (statsEl) statsEl.style.display = 'flex'; | |
| if (wordCountEl) wordCountEl.textContent = summaryWords; | |
| if (compressionEl) compressionEl.textContent = compression + '%'; | |
| } | |
| /* ── Item 11: Summary Mode ── */ | |
| window._summaryMode = 'paragraph'; | |
| function setSummaryMode(mode) { | |
| window._summaryMode = mode; | |
| document.querySelectorAll('.summary-mode-btn').forEach(btn => { | |
| btn.classList.toggle('active', btn.id === 'summary-mode-' + mode); | |
| }); | |
| } | |
| /* ── Item 3: Empty States ── */ | |
| function renderEmptyState(container, icon, title, desc) { | |
| if (!container) return; | |
| container.innerHTML = ` | |
| <div class="empty-state"> | |
| <div class="empty-state__icon">${icon}</div> | |
| <div class="empty-state__title">${title}</div> | |
| <div class="empty-state__desc">${desc}</div> | |
| </div> | |
| `; | |
| } | |
| /* ── Item 7: Document Search ── */ | |
| function initDocSearch() { | |
| const searchInput = document.getElementById('docs-search-input'); | |
| if (!searchInput) return; | |
| searchInput.addEventListener('input', () => { | |
| const query = searchInput.value.trim().toLowerCase(); | |
| const items = document.querySelectorAll('.doc-list-item'); | |
| items.forEach(item => { | |
| const title = (item.querySelector('.doc-list-item__title')?.textContent || '').toLowerCase(); | |
| item.style.display = title.includes(query) || !query ? '' : 'none'; | |
| }); | |
| }); | |
| } | |
| /* ── Error Filtering (Sidebar tabs) ── */ | |
| var _currentErrorFilter = 'all'; | |
| function filterErrors(type) { | |
| _currentErrorFilter = type; | |
| // Update tab active state | |
| document.querySelectorAll('.error-tab').forEach(tab => { | |
| tab.classList.toggle('active', tab.dataset.filter === type); | |
| }); | |
| // Filter suggestion items | |
| document.querySelectorAll('#suggestions-list .suggestion-item, #suggestions-list .sugg-card').forEach(item => { | |
| if (type === 'all') { | |
| item.style.display = ''; | |
| } else { | |
| const itemType = item.dataset.type || item.getAttribute('data-error-type') || ''; | |
| item.style.display = itemType.includes(type) ? '' : 'none'; | |
| } | |
| }); | |
| // Show/hide dismiss button | |
| const dismissBtn = document.getElementById('dismiss-filtered-btn'); | |
| if (dismissBtn) dismissBtn.classList.toggle('is-hidden', type === 'all'); | |
| } | |
| function dismissAllFiltered() { | |
| const type = _currentErrorFilter; | |
| if (type === 'all') return; | |
| document.querySelectorAll('#suggestions-list .suggestion-item, #suggestions-list .sugg-card').forEach(item => { | |
| const itemType = item.dataset.type || item.getAttribute('data-error-type') || ''; | |
| if (itemType.includes(type)) { | |
| item.remove(); | |
| } | |
| }); | |
| } | |
| /* ── Error Breakdown Chart (SVG Donut) ── */ | |
| function renderErrorChart() { | |
| const container = document.getElementById('error-chart'); | |
| if (!container) return; | |
| const s = parseInt(document.getElementById('spelling-count')?.textContent) || 0; | |
| const g = parseInt(document.getElementById('grammar-count')?.textContent) || 0; | |
| const p = parseInt(document.getElementById('punctuation-count')?.textContent) || 0; | |
| const total = s + g + p; | |
| if (total === 0) { | |
| container.innerHTML = ''; | |
| return; | |
| } | |
| const r = 30, cx = 40, cy = 40, c = 2 * Math.PI * r; | |
| const segments = [ | |
| { val: s, color: '#ef4444', label: 'إملائي' }, | |
| { val: g, color: '#f59e0b', label: 'نحوي' }, | |
| { val: p, color: '#22c55e', label: 'ترقيم' }, | |
| ].filter(seg => seg.val > 0); | |
| let offset = 0; | |
| let paths = ''; | |
| segments.forEach(seg => { | |
| const pct = seg.val / total; | |
| const dash = c * pct; | |
| const gap = c - dash; | |
| paths += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${seg.color}" stroke-width="8" stroke-dasharray="${dash} ${gap}" stroke-dashoffset="${-offset}" opacity="0.85"/>`; | |
| offset += dash; | |
| }); | |
| let legend = segments.map(seg => `<span class="chart-legend-item"><span class="chart-dot" style="background:${seg.color}"></span>${seg.label}: ${seg.val}</span>`).join(''); | |
| container.innerHTML = `<svg width="80" height="80" viewBox="0 0 80 80" class="error-donut">${paths}<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" fill="var(--color-text-primary)" font-size="14" font-weight="700">${total}</text></svg><div class="chart-legend">${legend}</div>`; | |
| } | |
| /* ── Score History (Sparkline) ── */ | |
| var _scoreHistory = []; | |
| function trackScore(score) { | |
| _scoreHistory.push(score); | |
| if (_scoreHistory.length > 20) _scoreHistory.shift(); | |
| renderSparkline(); | |
| } | |
| function renderSparkline() { | |
| const container = document.getElementById('score-sparkline'); | |
| if (!container || _scoreHistory.length < 2) return; | |
| const w = 120, h = 30; | |
| const max = Math.max(..._scoreHistory, 100); | |
| const min = Math.min(..._scoreHistory, 0); | |
| const range = max - min || 1; | |
| const points = _scoreHistory.map((v, i) => { | |
| const x = (i / (_scoreHistory.length - 1)) * w; | |
| const y = h - ((v - min) / range) * h; | |
| return `${x},${y}`; | |
| }).join(' '); | |
| container.innerHTML = `<svg width="${w}" height="${h}" class="sparkline"><polyline points="${points}" fill="none" stroke="var(--color-primary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`; | |
| } | |