@jager-ai/holy-editor
Version:
Rich text editor with Bible verse slash commands and PWA keyboard tracking, extracted from Holy Habit project
450 lines (376 loc) • 12.5 kB
text/typescript
/**
* Text Formatter
*
* Text formatting engine for rich text editing
* Extracted from Holy Habit holy-editor-pro.js
*/
import { FormatAction, FormatState, EditorSelection } from '../types/Editor';
export class TextFormatter {
private editorId: string;
constructor(editorId: string = 'holyEditor') {
this.editorId = editorId;
}
/**
* Get current selection information
*/
public getCurrentSelection(): EditorSelection | null {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
const range = selection.getRangeAt(0);
return {
range,
selectedText: range.toString(),
hasSelection: !range.collapsed
};
}
/**
* Toggle bold formatting
*/
public toggleBold(): void {
const selectionInfo = this.getCurrentSelection();
if (!selectionInfo) return;
const { range, selectedText, hasSelection } = selectionInfo;
if (hasSelection) {
const isBold = this.isCurrentlyBold(range);
if (isBold) {
this.removeBoldFromSelection(range);
} else {
this.applyBoldToSelection(range, selectedText);
}
} else {
this.setNextInputStyle('bold');
}
}
/**
* Toggle underline formatting
*/
public toggleUnderline(): void {
const selectionInfo = this.getCurrentSelection();
if (!selectionInfo) return;
const { range, selectedText, hasSelection } = selectionInfo;
if (hasSelection) {
const isUnderlined = this.isCurrentlyUnderlined(range);
if (isUnderlined) {
this.removeUnderlineFromSelection(range);
} else {
this.applyUnderlineToSelection(range, selectedText);
}
} else {
this.setNextInputStyle('underline');
}
}
/**
* Toggle heading1 formatting
*/
public toggleHeading1(): void {
const selectionInfo = this.getCurrentSelection();
if (!selectionInfo) return;
const { range, selectedText, hasSelection } = selectionInfo;
if (hasSelection) {
const isHeading = this.isCurrentlyHeading(range);
if (isHeading) {
this.removeHeadingFromSelection(range);
} else {
this.applyHeading1ToSelection(range, selectedText);
}
} else {
this.setNextInputStyle('heading1');
}
}
/**
* Toggle quote formatting
*/
public toggleQuote(): void {
const selectionInfo = this.getCurrentSelection();
if (!selectionInfo) return;
const { range, selectedText, hasSelection } = selectionInfo;
if (hasSelection) {
const isQuote = this.isCurrentlyQuote(range);
if (isQuote) {
this.removeQuoteFromSelection(range);
} else {
this.applyQuoteToSelection(range, selectedText);
}
} else {
this.createEmptyInlineQuote();
}
}
/**
* Apply text color
*/
public applyTextColor(color: string): void {
const selectionInfo = this.getCurrentSelection();
if (!selectionInfo) return;
const { range, selectedText, hasSelection } = selectionInfo;
if (hasSelection) {
this.applyColorToSelection(range, selectedText, color);
} else {
this.setNextInputStyle('color', color);
}
}
/**
* Toggle any formatting style
*/
public toggleStyle(action: FormatAction): void {
switch (action) {
case 'bold':
this.toggleBold();
break;
case 'underline':
this.toggleUnderline();
break;
case 'heading1':
this.toggleHeading1();
break;
case 'quote':
this.toggleQuote();
break;
default:
console.warn('❌ Unsupported format action:', action);
}
}
/**
* Check current format state
*/
public getFormatState(): FormatState {
const selectionInfo = this.getCurrentSelection();
if (!selectionInfo) {
return {
bold: false,
underline: false,
heading1: false,
quote: false,
textcolor: false
};
}
const { range } = selectionInfo;
return {
bold: this.isCurrentlyBold(range),
underline: this.isCurrentlyUnderlined(range),
heading1: this.isCurrentlyHeading(range),
quote: this.isCurrentlyQuote(range),
textcolor: this.isCurrentlyTextColor(range)
};
}
// Private methods for format detection
private isCurrentlyBold(range: Range): boolean {
return this.hasFormatTag(range, (node) =>
node.tagName === 'STRONG' || node.tagName === 'B'
);
}
private isCurrentlyUnderlined(range: Range): boolean {
return this.hasFormatTag(range, (node) =>
node.tagName === 'U'
);
}
private isCurrentlyHeading(range: Range): boolean {
return this.hasFormatTag(range, (node) =>
node.tagName === 'SPAN' &&
node.style.fontSize === '20px' &&
node.style.fontWeight === 'bold'
);
}
private isCurrentlyQuote(range: Range): boolean {
return this.hasFormatTag(range, (node) =>
node.tagName === 'BLOCKQUOTE' &&
node.classList &&
node.classList.contains('inline-quote')
);
}
private isCurrentlyTextColor(range: Range): boolean {
return this.hasFormatTag(range, (node) =>
node.style &&
node.style.color &&
node.style.color !== '' &&
node.style.color !== 'inherit'
);
}
private hasFormatTag(range: Range, checkFunction: (node: HTMLElement) => boolean): boolean {
const container = range.commonAncestorContainer;
let parent = container.nodeType === Node.TEXT_NODE ? container.parentNode : container;
while (parent && parent !== document.getElementById(this.editorId)) {
if (parent instanceof HTMLElement && checkFunction(parent)) {
return true;
}
parent = parent.parentNode;
}
return false;
}
// Private methods for applying formats
private applyBoldToSelection(range: Range, selectedText: string): void {
const strong = document.createElement('strong');
strong.textContent = selectedText;
range.deleteContents();
range.insertNode(strong);
this.selectNode(range, strong);
console.log('💪 Bold formatting applied');
}
private applyUnderlineToSelection(range: Range, selectedText: string): void {
const u = document.createElement('u');
u.textContent = selectedText;
range.deleteContents();
range.insertNode(u);
this.selectNode(range, u);
console.log('✏️ Underline formatting applied');
}
private applyHeading1ToSelection(range: Range, selectedText: string): void {
const span = document.createElement('span');
span.style.fontSize = '20px';
span.style.fontWeight = 'bold';
span.textContent = selectedText;
range.deleteContents();
range.insertNode(span);
this.selectNode(range, span);
console.log('📝 Heading1 formatting applied');
}
private applyQuoteToSelection(range: Range, selectedText: string): void {
const quote = document.createElement('blockquote');
quote.className = 'inline-quote';
quote.textContent = selectedText;
range.deleteContents();
range.insertNode(quote);
// Move cursor after quote
range.setStartAfter(quote);
range.collapse(true);
this.updateSelection(range);
console.log('💬 Quote formatting applied');
}
private applyColorToSelection(range: Range, selectedText: string, color: string): void {
const colorSpan = document.createElement('span');
colorSpan.style.color = color;
try {
range.surroundContents(colorSpan);
} catch (e) {
// Fallback for complex selections
const contents = range.extractContents();
colorSpan.appendChild(contents);
range.insertNode(colorSpan);
}
this.selectNode(range, colorSpan);
console.log('🎨 Color formatting applied:', color);
}
// Private methods for removing formats
private removeBoldFromSelection(range: Range): void {
this.removeFormatFromSelection(range, (node) =>
node.tagName === 'STRONG' || node.tagName === 'B'
);
console.log('💪 Bold formatting removed');
}
private removeUnderlineFromSelection(range: Range): void {
this.removeFormatFromSelection(range, (node) =>
node.tagName === 'U'
);
console.log('✏️ Underline formatting removed');
}
private removeHeadingFromSelection(range: Range): void {
this.removeFormatFromSelection(range, (node) =>
node.tagName === 'SPAN' &&
node.style.fontSize === '20px' &&
node.style.fontWeight === 'bold'
);
console.log('📝 Heading formatting removed');
}
private removeQuoteFromSelection(range: Range): void {
this.removeFormatFromSelection(range, (node) =>
node.tagName === 'BLOCKQUOTE' &&
node.classList &&
node.classList.contains('inline-quote')
);
console.log('💬 Quote formatting removed');
}
private removeFormatFromSelection(range: Range, checkFunction: (node: HTMLElement) => boolean): void {
const container = range.commonAncestorContainer;
let parent = container.nodeType === Node.TEXT_NODE ? container.parentNode : container;
while (parent && parent !== document.getElementById(this.editorId)) {
if (parent instanceof HTMLElement && checkFunction(parent)) {
const fragment = this.extractNodeContents(parent);
parent.parentNode?.insertBefore(fragment, parent);
parent.parentNode?.removeChild(parent);
this.restoreSelectionFromFragment(range, fragment, parent.parentNode);
break;
}
parent = parent.parentNode;
}
}
// Utility methods
private selectNode(range: Range, node: Node): void {
range.selectNodeContents(node);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
private updateSelection(range: Range): void {
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
private extractNodeContents(node: Node): DocumentFragment {
const fragment = document.createDocumentFragment();
while (node.firstChild) {
fragment.appendChild(node.firstChild);
}
return fragment;
}
private restoreSelectionFromFragment(range: Range, fragment: DocumentFragment, parentNode: Node | null): void {
const newRange = document.createRange();
const startNode = fragment.firstChild || parentNode;
const endNode = fragment.lastChild || parentNode;
if (startNode && endNode && parentNode) {
const endOffset = endNode.textContent ? endNode.textContent.length : 0;
newRange.setStart(startNode, 0);
newRange.setEnd(endNode, endOffset);
this.updateSelection(newRange);
}
}
private createEmptyInlineQuote(): void {
const selectionInfo = this.getCurrentSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
const quote = document.createElement('blockquote');
quote.className = 'inline-quote';
quote.innerHTML = '​'; // Zero-width space
range.insertNode(quote);
// Move cursor inside quote
range.selectNodeContents(quote);
range.collapse(false);
this.updateSelection(range);
console.log('💬 Empty quote block created');
}
private setNextInputStyle(style: string, value?: string): void {
const selectionInfo = this.getCurrentSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
let marker: HTMLElement;
switch (style) {
case 'bold':
marker = document.createElement('strong');
break;
case 'underline':
marker = document.createElement('u');
break;
case 'heading1':
marker = document.createElement('span');
marker.style.fontSize = '20px';
marker.style.fontWeight = 'bold';
break;
case 'color':
marker = document.createElement('span');
if (value) {
marker.style.color = value;
}
break;
default:
return;
}
marker.innerHTML = '​'; // Zero-width space
range.insertNode(marker);
range.setStartAfter(marker);
range.collapse(true);
this.updateSelection(range);
// Move cursor inside marker
range.selectNodeContents(marker);
range.collapse(false);
this.updateSelection(range);
console.log('🎯 Next input style set:', style);
}
}