bayan-api / src /js /format.js
youssefreda9's picture
feat: Complete ALL plan items — gradient tokens, nav scroll glow, focus rings, dropdown keyboard nav, color reset, network delay indicator
97ed8d3
Raw
History Blame Contribute Delete
12.3 kB
// 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'); }
/* ── 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));
}
});
}
/**
* 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');
}
/* ── 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';
});
});
}