Files
prompt/src/components/PromptEditor.vue
T
2026-06-23 10:08:39 +08:00

936 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed, nextTick, watch } from 'vue';
import { usePromptStore, splitTokens, normalizeSymbols, parseDetailedToken, constructToken, toNumericForm, toBracketForm, formatWeight } from '../stores/promptStore';
import type { LangCode, PresetFolder } from '../types';
import NotificationToast from './NotificationToast.vue';
import TranslationPopup from './TranslationPopup.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<number | null>(null);
const overIndex = ref<number | null>(null);
const dragPreview = ref<HTMLElement | null>(null);
const isDragging = ref(false);
const externalDraggingTag = ref<string | null>(null);
// 指针拖拽新增状态
const insertSide = ref<'before' | 'after' | null>(null);
const pointerId = ref<number | null>(null);
const startX = ref(0);
const startY = ref(0);
const lastX = ref(0);
const lastY = ref(0);
const dragOffsetX = ref(0);
const dragOffsetY = ref(0);
const dragStarted = ref(false);
const cachedTokenRects = ref<{
index: number;
left: number;
top: number;
width: number;
height: number;
midX: number;
}[]>([]);
let rafId: number | null = null;
const DRAG_THRESHOLD = 3; // 像素阈值,避免误触
const editingIndex = ref<number | null>(null);
const presetName = ref('');
// 跨页面切换保留所选文件夹(刷新后重置为默认)
const selectedFolderId = computed({
get: () => store.editorSelectedFolderId,
set: (v: string) => { store.editorSelectedFolderId = v; },
});
// 保存预设时是否同时标记为收藏
const saveAsFavorite = ref(false);
const viewMode = ref<'compact' | 'detail'>('compact');
// 左侧光标所在的 token 序号,用于在右侧映射中定位高亮
const activeTokenIndex = ref<number | null>(null);
const showPresetDropdown = ref(false);
const showTranslationPopup = ref(false);
const translationTargetToken = ref<string | null>(null);
const translationTokens = computed(() => {
if (translationTargetToken.value) {
return [translationTargetToken.value];
}
return unmappedTokens.value;
});
const notification = ref<{ message: string; type: 'success' | 'error' | 'info'; show: boolean }>({
message: '',
type: 'info',
show: false
});
function showNotification(message: string, type: 'success' | 'error' | 'info' = 'info') {
notification.value = { message, type, show: true };
setTimeout(() => {
notification.value.show = false;
}, 3000);
}
// 点击外部关闭下拉菜单
function handleClickOutside(event: Event) {
const target = event.target as HTMLElement;
// 检查点击是否在预设下拉区域内,包括重命名输入框和按钮
if (!target.closest('.pe-presets') && !target.closest('.pd-dropdown')) {
showPresetDropdown.value = false;
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside);
// 仅首次进入时初始化为默认文件夹;后续页面切换保留用户上次的选择
if (!store.editorFolderInitialized) {
const defaultFolder = store.presetManagement?.settings?.defaultFolder;
if (defaultFolder) {
store.editorSelectedFolderId = defaultFolder;
}
store.editorFolderInitialized = true;
}
store.searchQuery = ''; // Reset search query to ensure all tags are shown
});
// 清理事件监听器
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
const selectedLang = computed({
get: () => store.selectedLang,
set: (v: LangCode) => store.setLanguage(v),
});
const tokens = computed(() => store.tokens);
const folderTree = computed(() => {
const folders = store.presetFolders || [];
const rootFolders = folders.filter(f => !f.parentId);
function buildTree(parentFolders: PresetFolder[]): any[] {
return parentFolders.map(folder => ({
...folder,
children: buildTree(folders.filter(f => f.parentId === folder.id)),
presetCount: (store.extendedPresets || []).filter(p => p.folderId === folder.id).length
}));
}
return buildTree(rootFolders);
});
const flattenedFolders = computed(() => {
type FlatItem = { id: string; name: string; label: string; level: number; presetCount: number; hasChildren: boolean };
const res: FlatItem[] = [];
function walk(nodes: any[], level: number, parentPath: string) {
nodes.forEach((node: any) => {
const label = parentPath ? `${parentPath} / ${node.name}` : node.name;
res.push({
id: node.id,
name: node.name,
label,
level,
presetCount: node.presetCount,
hasChildren: !!(node.children && node.children.length)
});
if (node.children && node.children.length) {
walk(node.children, level + 1, label);
}
});
}
walk(folderTree.value, 0, '');
return res;
});
const suggestions = ref<string[]>([]);
const editSuggestions = ref<string[]>([]);
const editorInputRef = ref<InstanceType<typeof EditorInput> | null>(null);
const tokenMappingRef = ref<InstanceType<typeof TokenMappingPanel> | null>(null);
// 优先级模式开关:false = 括号嵌套,true = 数字权重后缀
const numericMode = ref(false);
// 括号嵌套时使用的括号样式(默认圆括号)
const bracketStyle = ref<'()' | '{}' | '[]' | '<>'>('()');
const priorityStep = ref(0.1);
function splitTokensLocal(txt: string): string[] {
return splitTokens(txt);
}
function normalizeToken(t: string): string { return t.trim().replace(/\s+/g, ' '); }
function normalizePromptLocal(txt: string): string {
return splitTokens(txt).map(t => t.replace(/\s+/g, ' ')).join(', ');
}
function applyFullPrompt(newText: string) {
const el = editorInputRef.value?.inputEl;
if (!el) { text.value = newText; return; }
el.focus();
applyTextReplacement(el, 0, text.value.length, newText);
}
function getTokenWeight(token: string): number {
const { core } = store.parseTokenWrappers(token);
const idx = core.lastIndexOf(':');
if (idx > -1) {
const w = parseFloat(core.slice(idx + 1).trim());
return isNaN(w) ? 1 : w;
}
return 1;
}
function hasWeightSuffix(token: string): boolean {
const { core } = store.parseTokenWrappers(token);
return /:\s*\d+(?:\.\d+)?$/.test(core);
}
function roundToDecimals(v: number, decimals: number): number {
const m = Math.pow(10, decimals);
return Math.round(v * m) / m;
}
function stepDecimals(): number {
const s = String(priorityStep.value);
return s.includes('.') ? (s.split('.')[1]?.length ?? 0) : 0;
}
// 数字权重模式:确保 () 容器,按步进调整显式权重(默认 1,即无后缀)
function adjustNumericWeight(token: string, deltaSteps: number): string {
const { core, weight, prefix, suffix } = parseDetailedToken(token);
if (prefix || suffix) return token;
const base = weight ?? 1;
let next = roundToDecimals(base + deltaSteps * priorityStep.value, stepDecimals());
if (next < 0) next = 0;
if (next === 1) return `(${core})`;
return `(${core}:${formatWeight(next)})`;
}
// 括号嵌套模式:在最外层再套一层选定括号
function addBracketLayer(token: string): string {
const { core, weight, wrappers, prefix, suffix } = parseDetailedToken(token);
const newWrappers = [bracketStyle.value, ...wrappers];
return constructToken(core, weight, newWrappers, prefix, suffix);
}
// 括号嵌套模式:去掉最外层括号;若移除后无 () 承载裸权重则一并清除
function removeBracketLayer(token: string): string {
const { core, weight, wrappers, prefix, suffix } = parseDetailedToken(token);
if (prefix || suffix) return token;
if (wrappers.length === 0) return core;
const newWrappers = wrappers.slice(1);
const hasParen = newWrappers.includes('()');
const keepWeight = weight !== undefined && weight !== 1 && hasParen ? weight : undefined;
return constructToken(core, keepWeight, newWrappers, prefix, suffix);
}
const text = ref('');
watch(text, (val) => {
store.setPromptTextRaw(val);
updateSuggestionsFromText();
});
// 当 store.promptText 发生变化(例如点击右侧预设加载)时,主动同步到左侧输入
watch(() => store.promptText, (v) => {
if (text.value !== v) text.value = v;
}, { immediate: true });
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);
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;
const segment = txt.slice(left < 0 ? 0 : left + 1, right).trim();
const { core } = parseDetailedToken(segment);
const cleanCore = core.replace(/^[\(\[\{<]+/, '').replace(/[\)\]\}>]+$/, '');
suggestions.value = store.getSuggestions(cleanCore, 8);
}
function updateEditSuggestionsFromValue(val: string) {
const { core } = parseDetailedToken(val);
const cleanCore = core.replace(/^[\(\[\{<]+/, '').replace(/[\)\]\}>]+$/, '');
editSuggestions.value = store.getSuggestions(cleanCore, 8);
}
// 统一的文本替换方法:优先使用原生插入以保留撤回栈,失败时回退
function applyTextReplacement(
el: HTMLTextAreaElement | HTMLInputElement,
start: number,
end: number,
text: string,
) {
try {
if (typeof el.setSelectionRange === 'function') {
el.setSelectionRange(start, end);
}
const ok = (document as any).execCommand && (document as any).execCommand('insertText', false, text);
if (ok) return;
} catch { }
try {
el.setRangeText(text, start, end, 'end');
try {
const ie = new (window as any).InputEvent('input', { bubbles: true, data: text, inputType: 'insertReplacementText' });
el.dispatchEvent(ie);
} catch {
el.dispatchEvent(new Event('input', { bubbles: true }));
}
} catch {
const value = (el as any).value as string;
(el as any).value = value.slice(0, start) + text + value.slice(end);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
}
function handleAddTag(tag: string) {
const el = editorInputRef.value?.inputEl;
if (!el) {
store.setPromptTextRaw(store.promptText ? store.promptText + ', ' + tag : tag);
return;
}
el.focus();
let start = el.selectionStart ?? store.promptText.length;
const textVal = el.value;
const len = textVal.length;
let tokenStart = start;
while (tokenStart > 0 && !/[,]/.test(textVal[tokenStart - 1] || '')) {
tokenStart--;
}
let tokenEnd = start;
while (tokenEnd < len && !/[,]/.test(textVal[tokenEnd] || '')) {
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;
}
}
let prefix = '';
let suffix = '';
if (start > 0) {
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)) {
const trimmedPrev = prevText.trimEnd();
if (trimmedPrev.length > 0 && !/[,]$/.test(trimmedPrev)) {
if (!/[,]\s*$/.test(prevText)) {
prefix = ', ';
}
}
}
}
if (start < len) {
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);
nextTick(() => {
el.focus();
});
}
async function copyLeft() {
try {
await navigator.clipboard.writeText(store.promptText);
showNotification('提示词已复制到剪贴板', 'success');
} catch (error) {
showNotification('复制失败,请手动复制', 'error');
}
}
function replaceCnComma() { applyFullPrompt(normalizeSymbols(text.value)); }
function formatPrompt() { applyFullPrompt(normalizePromptLocal(text.value)); }
function unifyPriorityStyle() {
const list = splitTokens(text.value).map(token =>
numericMode.value ? toNumericForm(token) : toBracketForm(token)
);
applyFullPrompt(list.join(', '));
showNotification(numericMode.value ? '已统一为数字权重' : '已统一为括号样式', 'success');
}
// 新增功能方法
function toggleUnderscoreSpace() {
const tokens = splitTokens(text.value);
// 1. 统计全局倾向
let spaceCount = 0;
let underscoreCount = 0;
// 预解析所有 Token
const parsedList = tokens.map(t => parseDetailedToken(t));
parsedList.forEach(({ core }) => {
for (const char of core) {
if (char === ' ') spaceCount++;
if (char === '_') underscoreCount++;
}
});
// 2. 确定目标格式
// 逻辑:统一成“非优势”的一方。
// 如果下划线更多(或相等),则统一变成空格(通常是为了可读性)。
// 如果空格更多,则统一变成下划线(通常是为了作为 Tag 使用)。
const targetIsUnderscore = spaceCount > underscoreCount;
const newTokens = parsedList.map(({ core, weight, wrappers }) => {
let newCore = core;
if (targetIsUnderscore) {
newCore = newCore.replace(/ /g, '_');
} else {
newCore = newCore.replace(/_/g, ' ');
}
// 重构 Token (保持权重和包裹层)
let result = newCore;
let currentWrappers = [...wrappers];
if (weight !== undefined && weight !== 1) {
const lastWrapper = currentWrappers[currentWrappers.length - 1];
if (lastWrapper === '()') {
currentWrappers.pop();
}
const wStr = Number.isInteger(weight) ? weight.toString() : weight.toFixed(2).replace(/\.?0+$/, '');
result = `(${result}:${wStr})`;
}
return store.wrapToken(result, currentWrappers);
});
applyFullPrompt(newTokens.join(', '));
showNotification(targetIsUnderscore ? '已统一为下划线格式' : '已统一为空格格式', 'success');
}
function addWrapperToToken(index: number) {
const tokens = splitTokensLocal(text.value);
if (index < 0 || index >= tokens.length) return;
const token = tokens[index]!;
tokens[index] = numericMode.value ? adjustNumericWeight(token, +1) : addBracketLayer(token);
applyFullPrompt(tokens.join(', '));
showNotification(numericMode.value ? '已提升权重' : '已添加优先级', 'success');
}
function removeWrapperFromToken(index: number) {
const tokens = splitTokensLocal(text.value);
if (index < 0 || index >= tokens.length) return;
const token = tokens[index]!;
tokens[index] = numericMode.value ? adjustNumericWeight(token, -1) : removeBracketLayer(token);
applyFullPrompt(tokens.join(', '));
showNotification(numericMode.value ? '已降低权重' : '已移除优先级', 'success');
}
function getTokenWrapperInfo(token: string) {
return store.getTokenWrapperInfo(token);
}
function cacheTokenRects() {
const dragContainer = tokenMappingRef.value?.dragContainer;
if (!dragContainer) {
cachedTokenRects.value = [];
return;
}
const selector = viewMode.value === 'compact' ? '.pe-token-compact' : '.pe-token-detail';
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);
return {
index: idx,
left: htmlEl.offsetLeft,
top: htmlEl.offsetTop,
width: htmlEl.offsetWidth,
height: htmlEl.offsetHeight,
midX: htmlEl.offsetLeft + htmlEl.offsetWidth / 2
};
}).filter(item => item.index !== -1);
}
// 指针事件版拖拽:更高性能且可自定义插入指示
function onPointerDown(index: number, e: PointerEvent) {
if (editingIndex.value === index) return;
draggingIndex.value = index;
pointerId.value = e.pointerId;
startX.value = e.clientX;
startY.value = e.clientY;
lastX.value = e.clientX;
lastY.value = e.clientY;
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
dragOffsetX.value = e.clientX - rect.left;
dragOffsetY.value = e.clientY - rect.top;
dragStarted.value = false;
isDragging.value = false;
insertSide.value = null;
// 缓存所有 Token 的位置信息 (相对于 dragContainer)
cacheTokenRects();
// 监听全局移动与抬起
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp, { once: true });
}
function handlePointerMove(e: PointerEvent) {
lastX.value = e.clientX;
lastY.value = e.clientY;
const dx = e.clientX - startX.value;
const dy = e.clientY - startY.value;
if (!dragStarted.value) {
if (Math.hypot(dx, dy) > DRAG_THRESHOLD) {
dragStarted.value = true;
isDragging.value = true;
if (draggingIndex.value != null) createPointerPreview(draggingIndex.value);
}
return;
}
if (!isDragging.value) return;
// 使用 requestAnimationFrame 节流渲染
if (rafId) return;
rafId = requestAnimationFrame(() => {
positionPreview(lastX.value, lastY.value);
updateOverIndexAndSideFast(lastX.value, lastY.value, draggingIndex.value);
rafId = null;
});
}
function handlePointerUp(e: PointerEvent) {
window.removeEventListener('pointermove', handlePointerMove);
if (!dragStarted.value || draggingIndex.value == null) {
cleanupDrag();
return;
}
const from = draggingIndex.value!;
const j = overIndex.value;
const side = insertSide.value;
if (j != null && side) {
let to = j;
if (side === 'before') {
to = j - (from < j ? 1 : 0);
} else {
to = j + (from > j ? 1 : 0);
}
const list = splitTokensLocal(text.value);
if (to < 0) to = 0;
if (to >= list.length) to = list.length - 1;
const [item] = list.splice(from, 1);
if (item != null) list.splice(to, 0, item);
applyFullPrompt(list.join(', '));
showNotification('已重新排序', 'success');
}
cleanupDrag();
}
function cleanupDrag() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
cachedTokenRects.value = [];
draggingIndex.value = null;
overIndex.value = null;
isDragging.value = false;
insertSide.value = null;
pointerId.value = null;
if (dragPreview.value) {
document.body.removeChild(dragPreview.value);
dragPreview.value = null;
}
}
function createPointerPreview(index: number) {
const token = tokens.value[index] || '';
const translation = displayTrans(token);
const preview = document.createElement('div');
preview.className = 'drag-preview';
preview.innerHTML = `
<div class="drag-preview-content">
<span class="drag-preview-key">${token}</span>
<span class="drag-preview-arrow">→</span>
<span class="drag-preview-trans">${translation}</span>
</div>
`;
preview.style.position = 'fixed';
preview.style.top = '0';
preview.style.left = '0';
preview.style.zIndex = '1000';
preview.style.pointerEvents = 'none';
; (preview.style as any).contain = 'layout style paint';
preview.style.willChange = 'transform, opacity';
document.body.appendChild(preview);
dragPreview.value = preview;
}
function positionPreview(x: number, y: number) {
if (!dragPreview.value) return;
dragPreview.value.style.transform = `translate(${x - dragOffsetX.value}px, ${y - dragOffsetY.value}px)`;
}
function updateOverIndexAndSideFast(clientX: number, clientY: number, activeDraggingIndex: number | null = null) {
const dragContainer = tokenMappingRef.value?.dragContainer;
if (!dragContainer) return;
// 计算鼠标在容器内的相对坐标
const containerRect = dragContainer.getBoundingClientRect();
const relX = clientX - containerRect.left;
const relY = clientY - containerRect.top;
const candidates = cachedTokenRects.value.filter(item => item.index !== activeDraggingIndex);
if (!candidates.length) {
overIndex.value = null;
insertSide.value = null;
return;
}
// 优先使用命中检测,鼠标落在 token 上时更准确
const target = candidates.find(item =>
relX >= item.left && relX <= item.left + item.width &&
relY >= item.top && relY <= item.top + item.height
);
if (target) {
overIndex.value = target.index;
insertSide.value = relX < target.midX ? 'before' : 'after';
return;
}
// 命中空隙时,选择同一行或最近的一项,支持任意位置插入
let nearest: typeof candidates[number] | null = null;
let bestScore = Number.POSITIVE_INFINITY;
for (const item of candidates) {
const dx = relX - item.midX;
const dy =
relY < item.top ? item.top - relY :
relY > item.top + item.height ? relY - (item.top + item.height) :
0;
const score = Math.abs(dx) + dy * 4;
if (score < bestScore) {
bestScore = score;
nearest = item;
}
}
if (!nearest) {
overIndex.value = null;
insertSide.value = null;
return;
}
overIndex.value = nearest.index;
insertSide.value = relX < nearest.midX ? 'before' : 'after';
}
function isQuickAddDragEvent(event: DragEvent): boolean {
const types = Array.from(event.dataTransfer?.types ?? []);
return types.includes('application/x-prompt-tag') || types.includes('text/plain');
}
function cleanupExternalDrag() {
externalDraggingTag.value = null;
cachedTokenRects.value = [];
overIndex.value = null;
insertSide.value = null;
if (draggingIndex.value == null) {
isDragging.value = false;
}
}
function handleQuickAddDragStart(tag: string) {
externalDraggingTag.value = tag;
isDragging.value = true;
overIndex.value = null;
insertSide.value = null;
cacheTokenRects();
}
function handleQuickAddDragEnd() {
cleanupExternalDrag();
}
function handlePanelDragOver(event: DragEvent) {
if (!isQuickAddDragEvent(event)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
if (!externalDraggingTag.value) {
externalDraggingTag.value =
event.dataTransfer?.getData('application/x-prompt-tag') ||
event.dataTransfer?.getData('text/plain') ||
null;
}
isDragging.value = true;
if (!cachedTokenRects.value.length && tokens.value.length) {
cacheTokenRects();
}
updateOverIndexAndSideFast(event.clientX, event.clientY, null);
}
function handlePanelDragLeave(event: DragEvent) {
if (!externalDraggingTag.value) return;
const dragContainer = tokenMappingRef.value?.dragContainer;
if (!dragContainer) return;
const related = event.relatedTarget as Node | null;
if (related && dragContainer.contains(related)) return;
const rect = dragContainer.getBoundingClientRect();
const inside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (!inside) {
overIndex.value = null;
insertSide.value = null;
}
}
function insertTagIntoTokens(tag: string, targetIndex: number | null, side: 'before' | 'after' | null) {
const list = splitTokensLocal(text.value);
let insertAt = list.length;
if (targetIndex != null && side) {
insertAt = targetIndex + (side === 'after' ? 1 : 0);
}
insertAt = Math.max(0, Math.min(insertAt, list.length));
list.splice(insertAt, 0, normalizeToken(tag));
applyFullPrompt(list.join(', '));
showNotification('已插入提示词', 'success');
}
function handlePanelDropTag(event: DragEvent) {
if (!isQuickAddDragEvent(event)) return;
event.preventDefault();
const tag =
externalDraggingTag.value ||
event.dataTransfer?.getData('application/x-prompt-tag') ||
event.dataTransfer?.getData('text/plain') ||
'';
if (!tag) {
cleanupExternalDrag();
return;
}
insertTagIntoTokens(tag, overIndex.value, insertSide.value);
cleanupExternalDrag();
}
function commitEdit(value: string) {
const i = tokenMappingRef.value?.editingIndex;
if (i == null) return;
const tokens = splitTokensLocal(text.value);
if (i >= 0 && i < tokens.length) {
tokens[i] = normalizeToken(value);
applyFullPrompt(tokens.join(', '));
}
}
function showAddMap(i: number) {
const token = tokens.value[i];
if (!token) return;
translationTargetToken.value = token;
showTranslationPopup.value = true;
}
function removeToken(i: number) {
const tokens = splitTokensLocal(text.value);
if (i < 0 || i >= tokens.length) return;
tokens.splice(i, 1);
applyFullPrompt(tokens.join(', '));
}
function addTokenAfter(i: number) {
const tokens = splitTokensLocal(text.value);
tokens.splice(i + 1, 0, normalizeToken('new_token'));
applyFullPrompt(tokens.join(', '));
}
function savePreset() {
if (!presetName.value.trim()) {
showNotification('请输入预设名称', 'error');
return;
}
const name = presetName.value.trim();
// 只保存到新的扩展预设系统
const folderId = selectedFolderId.value || store.presetManagement?.settings?.defaultFolder;
store.createExtendedPreset({
name: name,
type: 'positive',
content: store.promptText,
description: '从编辑器快速保存',
folderId: folderId,
isFavorite: saveAsFavorite.value
});
showNotification(
saveAsFavorite.value
? `预设「${name}」已保存并收藏`
: `预设「${name}」已保存到预设管理`,
'success'
);
presetName.value = '';
}
// 预设下拉组件的事件处理
function handlePresetLoad(name: string) {
// 优先从扩展预设中查找
const extendedPreset = store.extendedPresets.find(p => p.name === name);
if (extendedPreset) {
store.setPromptTextRaw(extendedPreset.content);
text.value = extendedPreset.content;
} else {
// 回退到旧预设系统
store.loadPreset(name);
text.value = store.promptText;
}
showNotification(`已加载预设「${name}」`, 'success');
}
function handlePresetSave(name: string) {
store.savePreset(name);
showNotification(`预设「${name}」已保存`, 'success');
}
function handlePresetDelete(name: string) {
store.deletePreset(name);
showNotification(`预设「${name}」已删除`, 'info');
}
function handlePresetRename(oldName: string, newName: string) {
store.renamePreset(oldName, newName);
showNotification(`预设已重命名为「${newName}」`, 'success');
}
const unmappedTokens = computed(() => {
return tokens.value.filter(k => isUnmapped(k));
});
function isUnmapped(key: string): boolean {
void store.mappingVersion; // 追踪映射变更以触发响应式刷新
const { core } = parseDetailedToken(key);
const tag = store.getTagByKey(core);
return !tag || !tag.translation?.[selectedLang.value];
}
function handleApplyTranslation(results: { key: string; trans: string }[]) {
results.forEach(({ key, trans }) => {
store.addMapping(key, selectedLang.value, trans);
});
showNotification(`已添加 ${results.length} 条映射`, 'success');
}
function displayTrans(key: string): string {
void store.mappingVersion; // 追踪映射变更以触发响应式刷新
const { core, weight, wrappers, prefix, suffix } = parseDetailedToken(key);
const tag = store.getTagByKey(core);
const translatedCore = tag?.translation?.[selectedLang.value] ?? tag?.key ?? core;
return constructToken(translatedCore, weight, wrappers, prefix, suffix);
}
function isRemoveDisabled(token: string): boolean {
const info = getTokenWrapperInfo(token);
return info.wrapperCount === 0 && !hasWeightSuffix(token);
}
</script>
<template>
<div class="pe-root">
<EditorToolbar :languages="store.languages as LangCode[]" v-model:selected-lang="selectedLang"
v-model:preset-name="presetName" v-model:selected-folder-id="selectedFolderId"
v-model:save-as-favorite="saveAsFavorite"
v-model:show-preset-dropdown="showPresetDropdown" :folder-tree="folderTree" :flattened-folders="flattenedFolders"
@copy="copyLeft" @save-preset="savePreset" @preset-load="handlePresetLoad" @preset-save="handlePresetSave"
@preset-delete="handlePresetDelete" @preset-rename="handlePresetRename" />
<div class="pe-main">
<EditorInput ref="editorInputRef" v-model:text="text" v-model:numeric-mode="numericMode"
v-model:bracket-style="bracketStyle" v-model:priority-step="priorityStep" :suggestions="suggestions"
:get-suggestions="(prefix, limit) => store.getSuggestions(prefix, limit)"
@update-suggestions="updateSuggestionsFromText" @copy="copyLeft" @replace-cn-comma="replaceCnComma"
@format-prompt="formatPrompt" @unify-priority="unifyPriorityStyle" @toggle-underscore="toggleUnderscoreSpace"
@add-tag="handleAddTag" @drag-tag-start="handleQuickAddDragStart" @drag-tag-end="handleQuickAddDragEnd"
@locate-token="(i) => activeTokenIndex = i >= 0 ? i : null" />
<TokenMappingPanel ref="tokenMappingRef" :tokens="tokens" :selected-lang="selectedLang"
v-model:view-mode="viewMode" :dragging-index="draggingIndex" :over-index="overIndex" :insert-side="insertSide"
:is-dragging="isDragging" :edit-suggestions="editSuggestions" :numeric-mode="numericMode"
:active-index="activeTokenIndex"
:display-trans="displayTrans" :is-unmapped="isUnmapped" :get-token-wrapper-info="getTokenWrapperInfo"
:has-weight-suffix="hasWeightSuffix" :get-suggestions="(prefix, limit) => store.getSuggestions(prefix, limit)"
@pointer-down="onPointerDown" @panel-dragover="handlePanelDragOver" @panel-dragleave="handlePanelDragLeave"
@drop-tag="handlePanelDropTag" @begin-edit="(i) => editingIndex = i" @commit-edit="commitEdit"
@cancel-edit="() => editingIndex = null" @show-add-map="showAddMap" @add-wrapper="addWrapperToToken"
@remove-wrapper="removeWrapperFromToken" @remove-token="removeToken" @add-token-after="addTokenAfter"
@show-translation-popup="() => { translationTargetToken = null; showTranslationPopup = true; }"
@update-edit-value="updateEditSuggestionsFromValue" />
</div>
<!-- 翻译弹窗 -->
<TranslationPopup :visible="showTranslationPopup" :tokens="translationTokens" :target-lang="selectedLang"
@close="() => { showTranslationPopup = false; translationTargetToken = null; }" @apply="handleApplyTranslation" />
<!-- 通知组件 -->
<NotificationToast :message="notification.message" :type="notification.type" :show="notification.show" />
</div>
</template>
<style scoped>
.pe-root {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--color-bg-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
}
.pe-main {
display: grid;
grid-template-columns: 1fr 1fr;
flex: 1;
min-height: 0;
gap: 1px;
background-color: var(--color-border);
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.pe-main {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.pe-left-pane {
border-bottom: 1px solid var(--color-border);
}
}
</style>