diff --git a/apps/admin-web/src/components/kol/KolPromptEditArea.vue b/apps/admin-web/src/components/kol/KolPromptEditArea.vue index 75a1df6..c13fac6 100644 --- a/apps/admin-web/src/components/kol/KolPromptEditArea.vue +++ b/apps/admin-web/src/components/kol/KolPromptEditArea.vue @@ -8,7 +8,6 @@ import { Modal } from "ant-design-vue"; import { buildKolPlaceholderToken, createKolVariableDefinition, - findKolPlaceholderKeyAtPosition, } from "@/lib/kol-placeholders"; const props = defineProps<{ @@ -29,6 +28,16 @@ const { t } = useI18n(); const aiDescription = ref(""); const textareaRef = ref(null); const highlightRef = ref(null); +let syncingScroll = false; + +type PointerSelectionState = { + key: string | null; + pointerId: number; + startX: number; + startY: number; +}; + +let pointerSelectionState: PointerSelectionState | null = null; const editorPlaceholder = "在这里输入提示词内容,可以使用 {{变量名}} 引用左侧定义的变量"; const highlightTokenRE = /\{\{\s*([^{}]+?)\s*\}\}/g; @@ -45,9 +54,20 @@ const highlightedContent = computed(() => { for (const match of text.matchAll(highlightTokenRE)) { const matchIndex = match.index ?? 0; const token = match[0] ?? ""; + const key = match[1] ?? ""; + const keyOffset = token.indexOf(key); + const prefix = keyOffset >= 0 ? token.slice(0, keyOffset) : token; + const suffix = keyOffset >= 0 ? token.slice(keyOffset + key.length) : ""; + const keyStart = matchIndex + Math.max(keyOffset, 0); html += escapeHtml(text.slice(lastIndex, matchIndex)); - html += `${escapeHtml(token)}`; + html += [ + ``, + escapeHtml(prefix), + `${escapeHtml(key)}`, + escapeHtml(suffix), + ``, + ].join(""); lastIndex = matchIndex + token.length; } @@ -107,7 +127,7 @@ function insertToken(token: string) { setTimeout(() => { el.focus(); el.setSelectionRange(start + token.length, start + token.length); - syncScroll(); + syncScrollFromTextarea(); }, 0); } @@ -118,34 +138,280 @@ function handleAiGenerate() { function handleContentInput(event: Event) { emit("update:content", (event.target as HTMLTextAreaElement).value); - syncScroll(); + syncScrollFromTextarea(); } -function handleCaretInteraction() { - if (!textareaRef.value) { +function handleHighlightPointerDown(event: PointerEvent) { + pointerSelectionState = { + key: getPlaceholderKeyFromTarget(event.target), + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + }; +} + +function handleHighlightPointerUp(event: PointerEvent) { + const textarea = textareaRef.value; + const highlight = highlightRef.value; + const pointerState = pointerSelectionState; + pointerSelectionState = null; + + if (!textarea || !highlight || !pointerState || pointerState.pointerId !== event.pointerId) { return; } - requestAnimationFrame(() => { - const textarea = textareaRef.value; - if (!textarea) { - return; + if (hasOverlaySelection(highlight)) { + return; + } + + const movedDistance = + Math.abs(event.clientX - pointerState.startX) + Math.abs(event.clientY - pointerState.startY); + if (movedDistance > 6) { + return; + } + + event.preventDefault(); + const cursorPos = getCaretIndexFromPoint(highlight, event); + const scrollTop = textarea.scrollTop; + const scrollLeft = textarea.scrollLeft; + + textarea.setSelectionRange(cursorPos, cursorPos); + focusTextareaWithoutScroll(textarea); + restoreTextareaScroll(textarea, scrollTop, scrollLeft); + + if (pointerState.key) { + emit("focus-variable", pointerState.key); + } +} + +function handleHighlightPointerCancel() { + pointerSelectionState = null; +} + +function getPlaceholderKeyFromTarget(target: EventTarget | null): string | null { + const targetElement = + target instanceof HTMLElement + ? target + : target instanceof Text + ? target.parentElement + : null; + + return targetElement?.closest("[data-kol-key]")?.dataset.kolKey?.trim() || null; +} + +function hasOverlaySelection(container: HTMLElement): boolean { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) { + return false; + } + + const anchorNode = selection.anchorNode; + const focusNode = selection.focusNode; + return !!anchorNode && !!focusNode && container.contains(anchorNode) && container.contains(focusNode); +} + +function getCaretIndexFromPoint(container: HTMLElement, event: PointerEvent): number { + const browserIndex = getBrowserCaretIndex(container, event); + if (browserIndex !== null) { + return clampOffset(browserIndex, props.content.length); + } + + const approximateIndex = getApproximateCaretIndex(container, event.clientX, event.clientY); + return clampOffset(approximateIndex, props.content.length); +} + +function getBrowserCaretIndex( + container: HTMLElement, + event: PointerEvent, +): number | null { + if ("caretPositionFromPoint" in document) { + const position = document.caretPositionFromPoint(event.clientX, event.clientY); + const index = resolveCaretIndex(container, position?.offsetNode ?? null, position?.offset); + if (index !== null) { + return index; + } + } + + if ("caretRangeFromPoint" in document) { + const range = document.caretRangeFromPoint(event.clientX, event.clientY); + const index = resolveCaretIndex(container, range?.startContainer ?? null, range?.startOffset); + if (index !== null) { + return index; + } + } + + return null; +} + +function resolveCaretIndex( + container: HTMLElement, + node: Node | null, + offset?: number, +): number | null { + if (!node || (node !== container && !container.contains(node))) { + return null; + } + + const range = document.createRange(); + range.setStart(container, 0); + range.setEnd(node, offset ?? 0); + return range.toString().length; +} + +function getApproximateCaretIndex( + container: HTMLElement, + clientX: number, + clientY: number, +): number { + const textNodes = getTextNodes(container); + if (textNodes.length === 0) { + return 0; + } + + const range = document.createRange(); + let indexOffset = 0; + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + let sawLineMatch = false; + + for (const textNode of textNodes) { + const textLength = textNode.textContent?.length ?? 0; + + for (let index = 0; index < textLength; index += 1) { + range.setStart(textNode, index); + range.setEnd(textNode, index + 1); + const rect = range.getBoundingClientRect(); + + if (!rect.width && !rect.height) { + continue; + } + + const isSameLine = clientY >= rect.top && clientY <= rect.bottom; + const midpoint = rect.left + rect.width / 2; + + if (isSameLine) { + sawLineMatch = true; + if (clientX <= midpoint) { + return indexOffset + index; + } + bestIndex = indexOffset + index + 1; + continue; + } + + const deltaY = + clientY < rect.top + ? rect.top - clientY + : clientY > rect.bottom + ? clientY - rect.bottom + : 0; + const deltaX = + clientX < rect.left + ? rect.left - clientX + : clientX > rect.right + ? clientX - rect.right + : 0; + const distance = deltaY * deltaY + deltaX * deltaX; + + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = clientX <= midpoint ? indexOffset + index : indexOffset + index + 1; + } } - const key = findKolPlaceholderKeyAtPosition(props.content, textarea.selectionStart ?? -1); - if (key) { - emit("focus-variable", key); + indexOffset += textLength; + } + + if (sawLineMatch) { + return bestIndex; + } + + return bestIndex; +} + +function getTextNodes(container: HTMLElement): Text[] { + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!(node instanceof Text)) { + return NodeFilter.FILTER_REJECT; + } + return node.textContent?.length ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + }, + }); + const nodes: Text[] = []; + + while (walker.nextNode()) { + const currentNode = walker.currentNode; + if (currentNode instanceof Text) { + nodes.push(currentNode); } + } + + return nodes; +} + +function clampOffset(offset: number, textLength: number): number { + if (!Number.isFinite(offset)) { + return 0; + } + return Math.min(Math.max(offset, 0), textLength); +} + +function focusTextareaWithoutScroll(textarea: HTMLTextAreaElement) { + try { + textarea.focus({ preventScroll: true }); + } catch { + textarea.focus(); + } +} + +function restoreTextareaScroll( + textarea: HTMLTextAreaElement, + scrollTop: number, + scrollLeft: number, +) { + textarea.scrollTop = scrollTop; + textarea.scrollLeft = scrollLeft; + syncScrollFromTextarea(); + + requestAnimationFrame(() => { + textarea.scrollTop = scrollTop; + textarea.scrollLeft = scrollLeft; + syncScrollFromTextarea(); }); } -function syncScroll() { +function syncScrollFromTextarea() { if (!textareaRef.value || !highlightRef.value) { return; } + if (syncingScroll) { + return; + } + + syncingScroll = true; highlightRef.value.scrollTop = textareaRef.value.scrollTop; highlightRef.value.scrollLeft = textareaRef.value.scrollLeft; + requestAnimationFrame(() => { + syncingScroll = false; + }); +} + +function syncScrollFromHighlight() { + if (!textareaRef.value || !highlightRef.value) { + return; + } + + if (syncingScroll) { + return; + } + + syncingScroll = true; + textareaRef.value.scrollTop = highlightRef.value.scrollTop; + textareaRef.value.scrollLeft = highlightRef.value.scrollLeft; + requestAnimationFrame(() => { + syncingScroll = false; + }); } function escapeHtml(value: string): string { @@ -154,6 +420,10 @@ function escapeHtml(value: string): string { .replaceAll("<", "<") .replaceAll(">", ">"); } + +function escapeAttribute(value: string): string { + return escapeHtml(value).replaceAll('"', """); +} @@ -467,4 +510,9 @@ function promptStatusLabel(status: string) { color: #fa8c16; background: #fff7e6; } + +.action-delete.ant-btn:hover:not(:disabled) { + color: #ff4d4f; + background: #fff1f0; +} diff --git a/apps/admin-web/src/views/TemplatesView.vue b/apps/admin-web/src/views/TemplatesView.vue index 9f7f98b..5d506e7 100644 --- a/apps/admin-web/src/views/TemplatesView.vue +++ b/apps/admin-web/src/views/TemplatesView.vue @@ -1,17 +1,25 @@