diff --git a/src/components/PromptEditor.vue b/src/components/PromptEditor.vue index a40cb68..a270543 100644 --- a/src/components/PromptEditor.vue +++ b/src/components/PromptEditor.vue @@ -3,10 +3,10 @@ import { onMounted, onUnmounted, ref, computed, nextTick, watch } from 'vue'; import { usePromptStore, splitTokens, normalizeSymbols, parseDetailedToken, constructToken } from '../stores/promptStore'; import type { LangCode, PresetFolder } from '../types'; import NotificationToast from './NotificationToast.vue'; -import PresetDropdown from './PresetDropdown.vue'; import TranslationPopup from './TranslationPopup.vue'; -import FolderSelector from './preset/FolderSelector.vue'; -import PromptQuickAdd from './PromptQuickAdd.vue'; +import EditorToolbar from './editor/EditorToolbar.vue'; +import EditorInput from './editor/EditorInput.vue'; +import TokenMappingPanel from './editor/TokenMappingPanel.vue'; const store = usePromptStore(); const draggingIndex = ref(null); @@ -23,7 +23,6 @@ const lastY = ref(0); const dragOffsetX = ref(0); const dragOffsetY = ref(0); const dragStarted = ref(false); -const dragContainer = ref(null); const cachedTokenRects = ref<{ index: number; left: number; @@ -36,9 +35,6 @@ let rafId: number | null = null; const DRAG_THRESHOLD = 3; // 像素阈值,避免误触 const editingIndex = ref(null); -const editingValue = ref(''); -const addingMapIndex = ref(null); -const addingMapValue = ref(''); const presetName = ref(''); const selectedFolderId = ref(''); const viewMode = ref<'compact' | 'detail'>('compact'); @@ -135,15 +131,8 @@ const flattenedFolders = computed(() => { const suggestions = ref([]); const editSuggestions = ref([]); -const inputEl = ref(null); -// 注意:ref 在 v-for 中可能成为数组,这里做统一归一化处理 -const editEl = ref(null); - -function currentEditEl(): HTMLInputElement | null { - const raw = editEl.value as any; - if (!raw) return null; - return Array.isArray(raw) ? (raw[0] ?? null) : raw; -} +const editorInputRef = ref | null>(null); +const tokenMappingRef = ref | null>(null); const priorityStyle = ref<'{}' | '()' | '[]' | '<>' | 'suffix'>('{}'); const priorityStep = ref(0.1); function splitTokensLocal(txt: string): string[] { @@ -154,7 +143,7 @@ function normalizePromptLocal(txt: string): string { return splitTokens(txt).map(t => t.replace(/\s+/g, ' ')).join(', '); } function applyFullPrompt(newText: string) { - const el = inputEl.value; + const el = editorInputRef.value?.inputEl; if (!el) { text.value = newText; return; } el.focus(); applyTextReplacement(el, 0, text.value.length, newText); @@ -200,7 +189,7 @@ const text = ref(''); watch(text, (val) => { store.setPromptTextRaw(val); - updateSuggestions(); + updateSuggestionsFromText(); }); // 当 store.promptText 发生变化(例如点击右侧预设加载)时,主动同步到左侧输入 @@ -208,14 +197,13 @@ watch(() => store.promptText, (v) => { if (text.value !== v) text.value = v; }, { immediate: true }); -function updateSuggestions() { - const el = inputEl.value; +function updateSuggestionsFromText() { + const el = editorInputRef.value?.inputEl; const txt = store.promptText; let pos = txt.length; if (el && typeof el.selectionStart === 'number') { pos = el.selectionStart ?? txt.length; } - // 计算当前光标所在片段:左右最近的逗号之间 const leftCommaEn = txt.lastIndexOf(',', pos - 1); const leftCommaCn = txt.lastIndexOf(',', pos - 1); const left = Math.max(leftCommaEn, leftCommaCn); @@ -225,43 +213,16 @@ function updateSuggestions() { const right = rightCandidates.length ? Math.min(...rightCandidates) : txt.length; const segment = txt.slice(left < 0 ? 0 : left + 1, right).trim(); const { core } = parseDetailedToken(segment); - // 去除 core 前后可能残留的符号(针对未闭合情况,如 "(aa" -> "aa") const cleanCore = core.replace(/^[\(\[\{<]+/, '').replace(/[\)\]\}>]+$/, ''); suggestions.value = store.getSuggestions(cleanCore, 8); } -function updateEditSuggestions() { - const el = currentEditEl(); - const val = editingValue.value || ''; - let pos = val.length; - if (el && typeof el.selectionStart === 'number') { - pos = el.selectionStart ?? val.length; - } - // 对编辑输入,使用光标左侧内容作为前缀 - const before = val.slice(0, pos); - const match = before.match(/[^,,]*$/); - const prefix = (match ? match[0] : before).trim(); - const { core } = parseDetailedToken(prefix); +function updateEditSuggestionsFromValue(val: string) { + const { core } = parseDetailedToken(val); const cleanCore = core.replace(/^[\(\[\{<]+/, '').replace(/[\)\]\}>]+$/, ''); editSuggestions.value = store.getSuggestions(cleanCore, 8); } -// 计算左侧输入(textarea)基于光标位置的片段替换范围(修剪前后空格) -function getTextSegmentBounds(txt: string, pos: number) { - const leftCommaEn = txt.lastIndexOf(',', pos - 1); - const leftCommaCn = txt.lastIndexOf(',', pos - 1); - const left = Math.max(leftCommaEn, leftCommaCn); - const rightCommaEn = txt.indexOf(',', pos); - const rightCommaCn = txt.indexOf(',', pos); - const rightCandidates = [rightCommaEn, rightCommaCn].filter(i => i !== -1); - const right = rightCandidates.length ? Math.min(...rightCandidates) : txt.length; - let start = left < 0 ? 0 : left + 1; - let end = right; - while (start < end && txt[start] && /\s/.test(txt[start]!)) start++; - while (end > start && txt[end - 1] && /\s/.test(txt[end - 1]!)) end--; - return { start, end }; -} - // 统一的文本替换方法:优先使用原生插入以保留撤回栈,失败时回退 function applyTextReplacement( el: HTMLTextAreaElement | HTMLInputElement, @@ -293,162 +254,77 @@ function applyTextReplacement( function handleAddTag(tag: string) { - const el = inputEl.value; + const el = editorInputRef.value?.inputEl; if (!el) { store.setPromptTextRaw(store.promptText ? store.promptText + ', ' + tag : tag); return; } - el.focus(); // Ensure focus for undo/redo support + el.focus(); let start = el.selectionStart ?? store.promptText.length; - const text = el.value; - const len = text.length; + const textVal = el.value; + const len = textVal.length; - // 智能定位:如果在词条中间,判断是前半还是后半 - // 1. 找到当前光标所在的词条范围 (current token boundaries) - // 向左找逗号或开头 let tokenStart = start; - while (tokenStart > 0 && !/[,,]/.test(text[tokenStart - 1] || '')) { + while (tokenStart > 0 && !/[,,]/.test(textVal[tokenStart - 1] || '')) { tokenStart--; } - // 向右找逗号或结尾 let tokenEnd = start; - while (tokenEnd < len && !/[,,]/.test(text[tokenEnd] || '')) { + while (tokenEnd < len && !/[,,]/.test(textVal[tokenEnd] || '')) { tokenEnd++; } - // 当前光标所在的词条内容(去除前后空格,但保留原始位置信息) - const rawToken = text.slice(tokenStart, tokenEnd); + const rawToken = textVal.slice(tokenStart, tokenEnd); const trimmedToken = rawToken.trim(); - // 如果是在词条内部(非空词条) if (trimmedToken.length > 0) { const tokenCenter = tokenStart + rawToken.length / 2; if (start < tokenCenter) { - // 在前半部分 -> 插入到该词条前面 start = tokenStart; } else { - // 在后半部分 -> 插入到该词条后面 start = tokenEnd; } - } else { - // 空词条或光标在逗号旁,保持原位,但在逗号后可能需要调整 } - // 插入逻辑:根据 start 位置前后字符决定逗号和空格 let prefix = ''; let suffix = ''; - // 检查前文 if (start > 0) { - const prevChar = text[start - 1]; - // 如果前一个字符不是逗号也不是空格(即紧贴着字),或者虽然是逗号但没空格,根据情况加 - // 简单策略: - // 1. 如果前面是非空字符且不是逗号 -> 加 ", " - // 2. 如果前面是逗号 -> 加 " " - // 3. 如果前面是空格 -> 检查空格前面是不是逗号,是则不动,不是则加 ", " - - // 获取前面的有效内容(忽略紧挨着的空格) - const prevText = text.slice(0, start); + const prevText = textVal.slice(0, start); if (/[^,,\s]$/.test(prevText)) { prefix = ', '; } else if (/[,,]$/.test(prevText)) { prefix = ' '; } else if (/[,,]\s+$/.test(prevText)) { - // 已经是 ", " 格式,无需前缀 prefix = ''; } else if (/\s+$/.test(prevText)) { - // 只是空格,前面没有逗号 -> 加 ", " - // 注意:如果整段都是空格(start之前),那其实就是开头,不需要逗号 - if (prevText.trim().length > 0) { - // 将空格替换为 ", " ? 或者直接追加 - // 这里为了不破坏原有空格结构,简单处理: - // 如果前面是 "abc " -> 变成 "abc, tag" - // 回退光标吃掉空格?不,直接补逗号和空格 - // 修正:如果前面是空格,我们应该判断这个空格是不是分隔符的一部分 - // 简单起见,如果前导是空格,且再前面不是逗号,那说明是 "word _" -> "word, tag" - // 此时 prefix 应该是 ", ",但要考虑是否保留原来的空格。 - // 通常习惯:光标贴着前词 -> ", ";光标隔着空格 -> ", " - // 只有当光标紧贴逗号 ",|" -> " " - } - // 重新更严谨的判断 const trimmedPrev = prevText.trimEnd(); if (trimmedPrev.length > 0 && !/[,,]$/.test(trimmedPrev)) { - // 前面有词且没逗号 -> 补逗号 - // 但是 start 前面可能有空格,我们最好是把逗号插在空格前?或者无所谓 - // 这里直接在当前位置插入 ", " 比较安全 - if (!/[,,]\s*$/.test(prevText)) { // 再次确认 + if (!/[,,]\s*$/.test(prevText)) { prefix = ', '; } } } } - // 检查后文 if (start < len) { - const nextText = text.slice(start); - // 如果后面紧跟着非逗号非空字符 -> 需要 ", " 分隔 ? - // 或者是插入在词条前,需要加逗号分隔后面的词 - + const nextText = textVal.slice(start); if (/^[^,,\s]/.test(nextText)) { suffix = ', '; } else if (/^\s+[^,,]/.test(nextText)) { - // 后面是空格接词 -> 补逗号 suffix = ', '; } - // 如果后面已经是逗号,通常不需要补后缀 } const toInsert = prefix + tag + suffix; - applyTextReplacement(el, start, start, toInsert); // end=start means insert + applyTextReplacement(el, start, start, toInsert); nextTick(() => { el.focus(); - // 光标位置调整到插入词的后面 - // applyTextReplacement 通常会把光标放在插入文本之后,所以可能不需要额外操作 }); } -async function onKeyDown(e: KeyboardEvent) { - if (e.key === 'Tab') { - // 在光标位置进行补全,不影响撤回 - const el = inputEl.value; - if (!el) return; - const pos = el.selectionStart ?? store.promptText.length; - const { start, end } = getTextSegmentBounds(store.promptText, pos); - const segment = store.promptText.slice(start, end); - const { core } = parseDetailedToken(segment); - const cleanCore = core.replace(/^[\(\[\{<]+/, '').replace(/[\)\]\}>]+$/, ''); - - const list = store.getSuggestions(cleanCore, 8); - if (list.length > 0) { - e.preventDefault(); - const s = list[0]; - if (!s) return; - - // 智能替换:保留包裹层和权重 - const { weight, wrappers } = parseDetailedToken(segment); - // 即使 parseDetailedToken 没解析出 wrapper (如未闭合情况),我们也尝试保留非 core 部分? - // 目前策略:如果 parseDetailedToken 能解析出结构,则完美重构。 - // 如果是未闭合如 "(aa",wrappers 为空,core 为 "(aa",cleanCore 为 "aa"。 - // 此时如果直接用 constructToken("aaa", undefined, []) -> "aaa",会丢失 "("。 - // 针对未闭合情况的特殊处理: - let newToken = ''; - if (wrappers.length === 0 && weight === undefined && segment !== cleanCore) { - // 简单替换核心部分 - newToken = segment.replace(cleanCore, s); - } else { - newToken = constructToken(s, weight, wrappers); - } - - applyTextReplacement(el, start, end, newToken); - await nextTick(); - updateSuggestions(); - } - } -} - async function copyLeft() { try { await navigator.clipboard.writeText(store.promptText); @@ -593,9 +469,10 @@ function onPointerDown(index: number, e: PointerEvent) { insertSide.value = null; // 缓存所有 Token 的位置信息 (相对于 dragContainer) - if (dragContainer.value) { + const dragContainer = tokenMappingRef.value?.dragContainer; + if (dragContainer) { const selector = viewMode.value === 'compact' ? '.pe-token-compact' : '.pe-token-detail'; - const elements = dragContainer.value.querySelectorAll(selector); + const elements = dragContainer.querySelectorAll(selector); cachedTokenRects.value = Array.from(elements).map(el => { const htmlEl = el as HTMLElement; const idx = parseInt(htmlEl.getAttribute('data-index') || '-1', 10); @@ -711,10 +588,11 @@ function positionPreview(x: number, y: number) { } function updateOverIndexAndSideFast(clientX: number, clientY: number) { - if (!dragContainer.value) return; + const dragContainer = tokenMappingRef.value?.dragContainer; + if (!dragContainer) return; // 计算鼠标在容器内的相对坐标 - const containerRect = dragContainer.value.getBoundingClientRect(); + const containerRect = dragContainer.getBoundingClientRect(); const relX = clientX - containerRect.left; const relY = clientY - containerRect.top; @@ -735,29 +613,15 @@ function updateOverIndexAndSideFast(clientX: number, clientY: number) { insertSide.value = relX < target.midX ? 'before' : 'after'; } -function beginEdit(i: number) { - editingIndex.value = i; - editingValue.value = tokens.value[i] ?? ''; - addingMapIndex.value = null; - nextTick(() => { - const el = currentEditEl(); - if (el) { - el.focus(); - try { el.setSelectionRange(0, editingValue.value.length); } catch {} - } - }); -} -function commitEdit() { - if (editingIndex.value == null) return; +function commitEdit(value: string) { + const i = tokenMappingRef.value?.editingIndex; + if (i == null) return; const tokens = splitTokensLocal(text.value); - const i = editingIndex.value!; if (i >= 0 && i < tokens.length) { - tokens[i] = normalizeToken(editingValue.value); + tokens[i] = normalizeToken(value); applyFullPrompt(tokens.join(', ')); } - editingIndex.value = null; } -function cancelEdit() { editingIndex.value = null; } function showAddMap(i: number) { const token = tokens.value[i]; @@ -765,13 +629,6 @@ function showAddMap(i: number) { translationTargetToken.value = token; showTranslationPopup.value = true; } -function commitAddMap() { - if (addingMapIndex.value == null) return; - const key = tokens.value[addingMapIndex.value]; - if (!key) return; - store.addMapping(key, selectedLang.value, addingMapValue.value.trim()); - addingMapIndex.value = null; addingMapValue.value = ''; -} function removeToken(i: number) { const tokens = splitTokensLocal(text.value); @@ -837,82 +694,6 @@ function handlePresetRename(oldName: string, newName: string) { showNotification(`预设已重命名为「${newName}」`, 'success'); } -async function applySuggestion(s: string) { - const el = inputEl.value; - if (!el) return; - // 确保输入框获得焦点,避免点击建议导致焦点丢失 - el.focus(); - const pos = el.selectionStart ?? store.promptText.length; - const { start, end } = getTextSegmentBounds(store.promptText, pos); - - // 智能替换逻辑 - const segment = store.promptText.slice(start, end); - const { core, weight, wrappers } = parseDetailedToken(segment); - const cleanCore = core.replace(/^[\(\[\{<]+/, '').replace(/[\)\]\}>]+$/, ''); - - let newToken = ''; - if (wrappers.length === 0 && weight === undefined && segment !== cleanCore) { - // 简单替换核心部分 (针对未闭合情况) - newToken = segment.replace(cleanCore, s); - } else { - newToken = constructToken(s, weight, wrappers); - } - - applyTextReplacement(el, start, end, newToken); - await nextTick(); - updateSuggestions(); -} - -function onEditKeyDown(e: KeyboardEvent) { - if (e.key !== 'Tab') return; - const el = currentEditEl(); - if (!el) return; - const val = editingValue.value || ''; - const pos = el.selectionStart ?? val.length; - const before = val.slice(0, pos); - const match = before.match(/[^,,]*$/); - const prefix = (match ? match[0] : '').trim(); - const list = store.getSuggestions(prefix, 8); - if (list.length > 0) { - e.preventDefault(); - const s = list[0]; - if (s) applyTextReplacement(el, 0, val.length, s); - updateEditSuggestions(); - } -} - -function applyEditSuggestion(s: string) { - const el = currentEditEl(); - if (!el) return; - // 保持焦点在编辑输入上 - el.focus(); - const val = editingValue.value || ''; - const pos = el.selectionStart ?? val.length; - - // 智能替换逻辑 (Compact Mode) - const { core, weight, wrappers } = parseDetailedToken(val); - const cleanCore = core.replace(/^[\(\[\{<]+/, '').replace(/[\)\]\}>]+$/, ''); - - let newVal = ''; - if (wrappers.length === 0 && weight === undefined && val !== cleanCore) { - // 简单替换核心部分 (针对未闭合情况) - newVal = val.replace(cleanCore, s); - } else { - newVal = constructToken(s, weight, wrappers); - } - - editingValue.value = newVal; - nextTick(() => { - // 光标移动到插入词后 - const newCoreIndex = newVal.indexOf(s); - if (newCoreIndex !== -1) { - el.setSelectionRange(newCoreIndex + s.length, newCoreIndex + s.length); - } else { - el.setSelectionRange(newVal.length, newVal.length); - } - updateEditSuggestions(); - }); -} const unmappedTokens = computed(() => { return tokens.value.filter(k => isUnmapped(k)); @@ -931,36 +712,6 @@ function handleApplyTranslation(results: { key: string; trans: string }[]) { showNotification(`已添加 ${results.length} 条映射`, 'success'); } -async function autoTranslateSingle() { - if (addingMapIndex.value == null) return; - const key = tokens.value[addingMapIndex.value]; - if (!key) return; - - try { - let target = selectedLang.value as string; - if (target === 'zh_CN') target = 'zh'; - - // 移除包裹层和下划线,提取核心词 - const { core } = parseDetailedToken(key); - // 再次清理可能残留的括号(针对复杂嵌套或未闭合情况) - const cleanCore = core.replace(/^[\(\[\{<]+/, '').replace(/[\)\]\}>]+$/, ''); - const cleanText = cleanCore.replace(/_/g, ' '); - - const url = `https://sywb.top/api/translate2?text=${encodeURIComponent(cleanText)}&sourceLang=auto&targetLang=${target}`; - - const res = await fetch(url); - const data = await res.json(); - - if (data.success && data.translation) { - addingMapValue.value = data.translation; - } else { - showNotification('翻译失败', 'error'); - } - } catch { - showNotification('翻译请求失败', 'error'); - } -} - function displayTrans(key: string): string { const { core, weight, wrappers, prefix, suffix } = parseDetailedToken(key); const tag = store.getTagByKey(core); @@ -977,357 +728,67 @@ function isRemoveDisabled(token: string): boolean {