936 lines
31 KiB
Vue
936 lines
31 KiB
Vue
<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>
|