增加拖拽快速提示词功能

This commit is contained in:
2026-05-09 10:09:52 +08:00
parent aa00c9ecc7
commit 706940bf93
4 changed files with 195 additions and 35 deletions
+160 -32
View File
@@ -13,6 +13,7 @@ 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);
@@ -449,6 +450,28 @@ 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;
@@ -469,23 +492,7 @@ function onPointerDown(index: number, e: PointerEvent) {
insertSide.value = null;
// 缓存所有 Token 的位置信息 (相对于 dragContainer)
const dragContainer = tokenMappingRef.value?.dragContainer;
if (dragContainer) {
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);
}
cacheTokenRects();
// 监听全局移动与抬起
window.addEventListener('pointermove', handlePointerMove);
@@ -513,7 +520,7 @@ function handlePointerMove(e: PointerEvent) {
if (rafId) return;
rafId = requestAnimationFrame(() => {
positionPreview(lastX.value, lastY.value);
updateOverIndexAndSideFast(lastX.value, lastY.value);
updateOverIndexAndSideFast(lastX.value, lastY.value, draggingIndex.value);
rafId = null;
});
}
@@ -587,7 +594,7 @@ function positionPreview(x: number, y: number) {
dragPreview.value.style.transform = `translate(${x - dragOffsetX.value}px, ${y - dragOffsetY.value}px)`;
}
function updateOverIndexAndSideFast(clientX: number, clientY: number) {
function updateOverIndexAndSideFast(clientX: number, clientY: number, activeDraggingIndex: number | null = null) {
const dragContainer = tokenMappingRef.value?.dragContainer;
if (!dragContainer) return;
@@ -596,21 +603,141 @@ function updateOverIndexAndSideFast(clientX: number, clientY: number) {
const relX = clientX - containerRect.left;
const relY = clientY - containerRect.top;
// 在缓存中查找命中的 Token
// 简单碰撞检测
const target = cachedTokenRects.value.find(item =>
relX >= item.left && relX <= item.left + item.width &&
relY >= item.top && relY <= item.top + item.height
);
if (!target || target.index === draggingIndex.value) {
const candidates = cachedTokenRects.value.filter(item => item.index !== activeDraggingIndex);
if (!candidates.length) {
overIndex.value = null;
insertSide.value = null;
return;
}
overIndex.value = target.index;
insertSide.value = relX < target.midX ? 'before' : 'after';
// 优先使用命中检测,鼠标落在 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) {
@@ -740,14 +867,15 @@ function isRemoveDisabled(token: string): boolean {
: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" />
@add-tag="handleAddTag" @drag-tag-start="handleQuickAddDragStart" @drag-tag-end="handleQuickAddDragEnd" />
<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" :priority-style="priorityStyle"
: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" @begin-edit="(i) => editingIndex = i" @commit-edit="commitEdit"
@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; }"
@@ -795,4 +923,4 @@ function isRemoveDisabled(token: string): boolean {
border-bottom: 1px solid var(--color-border);
}
}
</style>
</style>
+22 -1
View File
@@ -5,6 +5,8 @@ import type { PromptTag } from '../types';
const emit = defineEmits<{
(e: 'add-tag', tag: string): void
(e: 'drag-tag-start', tag: string): void
(e: 'drag-tag-end'): void
}>();
const store = usePromptStore();
@@ -17,6 +19,7 @@ const selectedLang = computed(() => store.selectedLang);
const PAGE_SIZE = 50;
const visibleCount = ref(PAGE_SIZE);
const tagsContainer = ref<HTMLElement | null>(null);
const draggedTagKey = ref<string | null>(null);
const visibleTags = computed(() => {
return filteredTags.value.slice(0, visibleCount.value);
@@ -49,9 +52,26 @@ function selectGroup(index: number) {
}
function onTagClick(tag: PromptTag) {
if (draggedTagKey.value === tag.key) return;
emit('add-tag', tag.key);
}
function onTagDragStart(tag: PromptTag, event: DragEvent) {
if (!event.dataTransfer) return;
draggedTagKey.value = tag.key;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/plain', tag.key);
event.dataTransfer.setData('application/x-prompt-tag', tag.key);
emit('drag-tag-start', tag.key);
}
function onTagDragEnd() {
emit('drag-tag-end');
window.setTimeout(() => {
draggedTagKey.value = null;
}, 0);
}
function displayTrans(tag: PromptTag) {
return tag.translation?.[selectedLang.value] ?? tag.key;
}
@@ -83,7 +103,8 @@ function displayTrans(tag: PromptTag) {
<!-- Tags -->
<div class="pqa-tags" ref="tagsContainer" @scroll="onScroll">
<button v-for="tag in visibleTags" :key="tag.key" class="pqa-tag" @click="onTagClick(tag)" @mousedown.prevent
<button v-for="tag in visibleTags" :key="tag.key" class="pqa-tag" draggable="true" @click="onTagClick(tag)"
@dragstart="onTagDragStart(tag, $event)" @dragend="onTagDragEnd"
:title="tag.key">
<span class="pqa-tag-text">{{ displayTrans(tag) }}</span>
<span class="pqa-tag-sub" v-if="displayTrans(tag) !== tag.key">{{ tag.key }}</span>
+7 -1
View File
@@ -22,6 +22,8 @@ const emit = defineEmits<{
'unify-priority': [];
'toggle-underscore': [];
'add-tag': [tag: string];
'drag-tag-start': [tag: string];
'drag-tag-end': [];
}>();
const inputEl = ref<HTMLTextAreaElement | null>(null);
@@ -230,7 +232,11 @@ defineExpose({
@click="applySuggestion(s)"
>{{ s }}</li>
</ul>
<PromptQuickAdd @add-tag="(tag) => emit('add-tag', tag)" />
<PromptQuickAdd
@add-tag="(tag) => emit('add-tag', tag)"
@drag-tag-start="(tag) => emit('drag-tag-start', tag)"
@drag-tag-end="() => emit('drag-tag-end')"
/>
</section>
</template>
+6 -1
View File
@@ -23,6 +23,9 @@ const props = defineProps<{
const emit = defineEmits<{
'update:viewMode': [value: 'compact' | 'detail'];
'pointer-down': [index: number, event: PointerEvent];
'panel-dragover': [event: DragEvent];
'panel-dragleave': [event: DragEvent];
'drop-tag': [event: DragEvent];
'begin-edit': [index: number];
'commit-edit': [value: string];
'cancel-edit': [];
@@ -194,7 +197,9 @@ defineExpose({
</div>
</div>
<div class="pe-drag-container" ref="dragContainer" :class="{ 'is-dragging': isDragging }">
<div class="pe-drag-container" ref="dragContainer" :class="{ 'is-dragging': isDragging }"
@dragover="emit('panel-dragover', $event)" @dragleave="emit('panel-dragleave', $event)"
@drop="emit('drop-tag', $event)">
<!-- 精简视图 -->
<div class="pe-tokens-compact" v-if="viewMode === 'compact'">
<div