import type { EditorState, EditorDebugInterface, EditorDebuggerInstance } from '../richTextTypes' import { reactive } from 'vue' import { EditorDebugger } from '../utils/debug' import { isStyleActive } from '../utils/selection' function preserveIframes(content: string): { html: string, iframes: HTMLIFrameElement[] } { const temp = document.createElement('div') temp.innerHTML = content const iframes: HTMLIFrameElement[] = [] const placeholders: string[] = [] // Find all iframes and replace them with placeholders temp.querySelectorAll('iframe').forEach((iframe, index) => { const placeholder = `` iframes.push(iframe.cloneNode(true) as HTMLIFrameElement) placeholders.push(placeholder) iframe.replaceWith(placeholder) }) return { html: temp.innerHTML, iframes } } function restoreIframes(doc: Document, content: string, iframes: HTMLIFrameElement[]) { // Find all iframe placeholders and restore them const placeholderPattern = //g doc.body.innerHTML = content.replace(placeholderPattern, (_, index) => { const iframe = iframes[Number(index)] return iframe ? iframe.outerHTML : '' }) } export function useEditor() { let cleanupListeners: (() => void) | null = null const state = reactive({ content: '', doc: undefined, selection: null, selectedStyles: new Set(), isFullscreen: false, isSplitView: false, isCodeView: false, hasInit: false, undoStack: [], redoStack: [], rangeCount: 0, range: null, debug: undefined }) // Centralized state update functions const updateState = { styles: () => { if (!state.doc) { return } const styles = new Set() const styleTypes = [ 'bold', 'italic', 'underline', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'orderedList', 'unorderedList', 'alignLeft', 'alignCenter', 'alignRight', 'alignJustify', 'textDirection', 'ltrDirection', 'rtlDirection' ] styleTypes.forEach((style) => { if (state.doc && isStyleActive(style, state.doc)) { styles.add(style) } }) state.selectedStyles = styles }, content: (source: 'html' | 'text') => { if (!state.doc) { return } // Only push to undo stack if content has changed const currentContent = state.doc.body.innerHTML if (currentContent !== state.content) { state.undoStack.push(state.content) state.redoStack = [] } // Store current selection const selection = state.doc.getSelection() const range = selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null if (source === 'html') { // Preserve iframes before setting content const preserved = preserveIframes(state.content) state.doc.body.innerHTML = preserved.html // Restore iframes after a short delay to ensure the document is ready setTimeout(() => { if (state.doc) { restoreIframes(state.doc, state.content, preserved.iframes) // Restore selection if it existed if (range && selection) { try { selection.removeAllRanges() selection.addRange(range) } catch { // Range restoration failed, ignore } } } }, 0) } else { state.doc.body.textContent = state.content } }, selection: () => { if (!state.doc) { return } const newSelection = state.doc.getSelection() if (!newSelection) { return } try { if (!state.doc.body.contains(newSelection.anchorNode)) { state.doc.body.focus() return } // Only update if selection has actually changed const hasSelectionChanged = !state.selection || state.selection !== newSelection || state.rangeCount !== newSelection.rangeCount || (newSelection.rangeCount > 0 && state.range && ( state.range.startContainer !== newSelection.getRangeAt(0).startContainer || state.range.startOffset !== newSelection.getRangeAt(0).startOffset || state.range.endContainer !== newSelection.getRangeAt(0).endContainer || state.range.endOffset !== newSelection.getRangeAt(0).endOffset )) if (hasSelectionChanged) { state.selection = newSelection state.rangeCount = newSelection.rangeCount if (newSelection.rangeCount > 0) { state.range = newSelection.getRangeAt(0).cloneRange() } // Update styles immediately for better responsiveness updateState.styles() } } catch { state.selection = null state.range = null state.rangeCount = 0 state.selectedStyles = new Set() } } } // History management const history = { undo: () => { if (state.undoStack.length === 0) { return } state.redoStack.push(state.content) const lastContent = state.undoStack.pop() if (lastContent !== undefined) { state.content = lastContent updateState.content('html') } }, redo: () => { if (state.redoStack.length === 0) { return } state.undoStack.push(state.content) const nextContent = state.redoStack.pop() if (nextContent !== undefined) { state.content = nextContent updateState.content('html') } } } // Content cleanup utilities const cleanup = { emptyTags: (doc: Document) => { const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, null) const nodesToRemove: Element[] = [] let node = walker.nextNode() as Element while (node) { if (['br', 'img', 'hr', 'input'].includes(node.tagName.toLowerCase())) { node = walker.nextNode() as Element continue } const textContent = node.textContent?.trim() || '' const innerHTML = node.innerHTML.trim() const hasOnlyBr = innerHTML === '
' || innerHTML === '
' const hasOnlyNbsp = innerHTML === ' ' || textContent === '\u00A0' const isEmpty = !textContent && !innerHTML const isDirectChildOfBody = node.parentElement === doc.body if (isEmpty || hasOnlyNbsp || (hasOnlyBr && !isDirectChildOfBody)) { nodesToRemove.push(node) } node = walker.nextNode() as Element } nodesToRemove.forEach((node) => { node.remove() }) }, normalizeContent: (doc: Document) => { // Only normalize direct text nodes to paragraphs, don't force any structure const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT) const textNodes: Text[] = [] let node: Node | null while ((node = walker.nextNode())) { if (node.parentElement === doc.body && node.textContent?.trim()) { textNodes.push(node as Text) } } textNodes.forEach((textNode) => { const p = doc.createElement('p') p.dir = doc.body.dir p.appendChild(textNode.cloneNode()) doc.body.replaceChild(p, textNode) }) } } function setupEventListeners(doc: Document) { // Clean up existing listeners if they exist if (cleanupListeners) { cleanupListeners() cleanupListeners = null } let isUpdating = false const events = { input: () => { if (isUpdating) { return } isUpdating = true // Immediately update content const newContent = doc.body.innerHTML if (newContent !== state.content) { state.content = newContent } isUpdating = false }, selectionchange: () => { if (!isUpdating) { updateState.selection() } }, mouseup: () => { if (!isUpdating) { updateState.selection() } }, keyup: (e: KeyboardEvent) => { if (!isUpdating) { if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { updateState.selection() } } } } // Only add listeners if they haven't been added yet Object.entries(events).forEach(([event, handler]) => { doc.addEventListener(event, handler as EventListener) }) // Store cleanup function cleanupListeners = () => { Object.entries(events).forEach(([event, handler]) => { doc.removeEventListener(event, handler as EventListener) }) } return cleanupListeners } function init(doc: Document) { if (state.hasInit) { if (cleanupListeners) { cleanupListeners() cleanupListeners = null } } state.doc = doc state.hasInit = true as const // Store state reference in document for table operations (doc as any).editorState = state // Initial setup without triggering updates - only set content if it exists and is not just empty paragraphs if (state.content && state.content.trim() && !state.content.match(/^]*)?>(?:)?\s*<\/p>$/i)) { const preserved = preserveIframes(state.content) doc.body.innerHTML = preserved.html setTimeout(() => { if (state.doc) { restoreIframes(doc, state.content, preserved.iframes) } }, 0) } cleanup.normalizeContent(doc) // Set initial selection only if there's content if (doc.body.firstElementChild) { const range = doc.createRange() const selection = doc.getSelection() if (selection) { range.selectNodeContents(doc.body) range.collapse(false) selection.removeAllRanges() selection.addRange(range) state.range = range.cloneRange() state.selection = selection state.rangeCount = selection.rangeCount } } else { // For empty editor, set cursor at the beginning of body const selection = doc.getSelection() if (selection) { const range = doc.createRange() range.setStart(doc.body, 0) range.setEnd(doc.body, 0) selection.removeAllRanges() selection.addRange(range) state.range = range.cloneRange() state.selection = selection state.rangeCount = selection.rangeCount } } // Setup event listeners immediately cleanupListeners = setupEventListeners(doc) } function initDebugger() { if (!state.debug) { const debugInstance: EditorDebuggerInstance = new EditorDebugger() const debug: EditorDebugInterface = { debugger: debugInstance, logCommand: (command: string, value?: string) => { debugInstance.logCommand(command, value, state) }, getSession: () => debugInstance.getSession(), clearSession: () => { debugInstance.clearSession() }, downloadSession: () => { debugInstance.downloadSession() }, exportDebugWithPrompt: (message?: string) => debugInstance.exportSessionWithPrompt(message) } state.debug = debug } } // Add cleanup on component unmount if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { if (cleanupListeners) { cleanupListeners() } }) } return { state, init, updateState, history, initDebugger, cleanup: () => { if (cleanupListeners) { cleanupListeners() cleanupListeners = null } } } }