import { getRelativePosition } from './custom.util.js' const EDITOR_CLASS = 'jsmind-editor' // jsmind class name const SUGGESTION_BOX_CLASS = 'jsmind-suggestions' const SUGGESTION_ITEM_CLASS = 'suggestion-item' /** * jsMind 搜尋管理 * jsMind Search Manager */ export class JsmindSearch { /** * 建構搜尋 * Constructor for search * @param {Object} jm - jsMind 實例 (jsMind instance) * @param {Function} searchAPI - 遠程搜尋 API 函式 (Remote search API function) * @param {string} tableUID */ constructor(jm, searchAPI, tableUID) { this.jm = jm this.searchAPI = searchAPI this.container = document.getElementById(jm.options.container) this.suggestionBox = null this.tableUID = tableUID // 新增記錄節點與事件 handler this.currentNode = null this._keydownHandler = null this._inputHandler = null this.init() } /** * 初始化搜尋事件 * Initialize search events */ init() { // 確保不會重複綁定 dblclick 事件 // Ensure double-click event is not bound multiple times this.container.removeEventListener('dblclick', this.onDoubleClick) this.container.addEventListener('dblclick', this.onDoubleClick.bind(this)) } /** * 處理雙擊事件以觸發搜尋 * Handle double-click event to trigger search * @param {Event} e - 事件對象 (Event object) */ onDoubleClick(e) { // 非可編輯狀態不執行 // Ignore if not editable if (!this.jm.options.editable) return const node = this.jm.get_selected_node() if (!node) return // 避免影響原生編輯功能,稍後執行 // Prevent interfering with native edit mode setTimeout(() => this.handleSearch(node), 100) } /** * 開始處理搜尋 * Start handling search * @param {Object} node - 當前選中節點 (Selected node) */ handleSearch(node) { const inputField = document.querySelector(`.${EDITOR_CLASS}`) if (!inputField) return // 記住目前的 node this.currentNode = node // 清除之前的 handler if (this._keydownHandler) inputField.removeEventListener('keydown', this._keydownHandler) if (this._inputHandler) inputField.removeEventListener('input', this._inputHandler) // 新綁定 handler this._keydownHandler = this.onKeyDown.bind(this) this._inputHandler = this.onInput.bind(this) inputField.addEventListener('keydown', this._keydownHandler) inputField.addEventListener('input', this._inputHandler) } /** * 處理 Enter 鍵完成輸入 * Handle Enter key to finalize input * @param {Object} node - 當前節點 * @param {KeyboardEvent} e - 鍵盤事件 */ onKeyDown(e) { if (e.key === 'Enter') { e.preventDefault() const input = e.target.value.trim() const node = this.currentNode if (input && node) { node.data.text = input this.jm.end_edit() this.jm.update_node(node.id, input) if (this.suggestionBox) { this.suggestionBox.style.display = 'none' } this.currentNode = null // 清除參考 } } } /** * 處理使用者輸入 * Handle user input * @param {Object} node - 當前選中節點 (Selected node) * @param {Event} e - 輸入事件 (Input event) */ async onInput(e) { const query = e.target.value.trim() if (!query) return await new Promise((resolve) => setTimeout(resolve, 500)) try { const results = await this.searchAPI(query, this.tableUID) this.showSuggestion(this.currentNode, e.target, results) } catch (error) { console.error('搜尋 API 錯誤:', error) } } /** * 顯示搜尋建議框 * Show search suggestion box * @param {Object} node - 當前選中節點 (Selected node) * @param {HTMLElement} inputElement - 輸入框 (Input field) * @param {Array} results - 搜尋結果 (Search results) */ showSuggestion(node, inputElement, results) { const container = this.container const nodeElement = inputElement.parentNode if (!nodeElement) return const { left, top, height } = getRelativePosition(nodeElement, container) this.suggestionBox = this.suggestionBox || this.createSuggestionBox() // 更新建議框內容 // Update suggestion box content this.suggestionBox.innerHTML = results .map(item => { const fieldHtml = item.fields.map(f => { const txt = f.url ? `${f.text}` : f.text; return `
${f.title}: ${txt}
`; }).join(""); return `
${fieldHtml}
`; }) .join('') this.suggestionBox.style.left = `${left}px` this.suggestionBox.style.top = `${top + height}px` this.suggestionBox.style.display = 'block' // 綁定建議點擊事件 // Bind suggestion click events document.querySelectorAll(`.${SUGGESTION_ITEM_CLASS}`).forEach((item) => { item.removeEventListener('mousedown', this.onSuggestionClick) item.addEventListener('mousedown', this.onSuggestionClick.bind(this, node)) }) } /** * 建立搜尋建議框 * Create search suggestion box * @returns {HTMLElement} - 建議框 DOM (Suggestion box DOM) */ createSuggestionBox() { let suggestionBox = document.getElementById(SUGGESTION_BOX_CLASS) if (!suggestionBox) { suggestionBox = document.createElement('div') suggestionBox.classList.add(SUGGESTION_BOX_CLASS) this.container.appendChild(suggestionBox) } return suggestionBox } /** * 處理點擊建議 * Handle suggestion click * @param {Object} node - 當前選中節點 (Selected node) * @param {Event} e - 點擊事件 (Click event) */ // onSuggestionClick(node, e) { // e.preventDefault() // const text = e.target.getAttribute('data-text') // const link = e.target.getAttribute('data-link') // node.data.text = text // node.data.link = link // this.jm.end_edit() // this.jm.update_node(node.id, text) // // 選擇後隱藏建議框 // // Hide suggestions after selection // this.suggestionBox.style.display = 'none' // } onSuggestionClick(node, e) { e.preventDefault() const item = e.currentTarget // 確保抓到整個 .suggestion-item DIV const html = item.innerHTML // 取得完整 HTML 當作 topic node.data.text = html node.data.link = item.getAttribute('data-link') this.jm.end_edit() this.jm.update_node(node.id, html) this.suggestionBox.style.display = 'none' } } // ✅ 新增播放語音事件委派,支援動態插入的 voice-player let audio; document.addEventListener('click', function(e) { const target = e.target.closest('.voice-player'); if (!target) return; e.preventDefault(); let status = target.getAttribute('status'); if (audio) { audio.pause(); audio.currentTime = 0; } if (status === 'playing') { target.setAttribute('status', ''); const icon = target.querySelector('i'); icon?.classList.remove('fa-pause'); icon?.classList.add('fa-play'); } else { let mp3_url = target.getAttribute('data-content'); audio = new Audio(mp3_url); audio.play(); target.setAttribute('status', 'playing'); const icon = target.querySelector('i'); icon?.classList.remove('fa-play'); icon?.classList.add('fa-pause'); audio.onended = function() { target.setAttribute('status', ''); icon?.classList.remove('fa-pause'); icon?.classList.add('fa-play'); }; } });