Files
geo/apps/admin-web/src/components/kol/KolPromptEditArea.vue
T
root 2846968cf7 feat(kol): add selection-based AI optimize for prompt editor
Trigger an AI optimize panel from a text selection in the KOL prompt
editor, accept a user instruction, and stream a preview before replacing
the selected fragment. Extend the shared editor AI assist panel with
boundary-aware placement so it can be hosted inside scoped surfaces, and
update the backend optimize prompt to honor the user instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:41:40 +08:00

1383 lines
37 KiB
Vue

<script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ExperimentOutlined, ThunderboltOutlined } from "@ant-design/icons-vue";
import type { KolVariableDefinition, KolVariableType } from "@geo/shared-types";
import { Modal } from "ant-design-vue";
import {
buildKolPlaceholderToken,
createKolVariableDefinition,
} from "@/lib/kol-placeholders";
const props = defineProps<{
content: string;
variables: KolVariableDefinition[];
aiBusy: boolean;
selectionAiOpen?: boolean;
}>();
const emit = defineEmits<{
(e: "update:content", value: string): void;
(e: "update:variables", value: KolVariableDefinition[]): void;
(e: "ai-generate", description: string): void;
(e: "ai-optimize-selection", value: PromptSelectionAiSnapshot): void;
(e: "focus-variable", key: string): void;
}>();
const { t } = useI18n();
const aiDescription = ref("");
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const highlightRef = ref<HTMLDivElement | null>(null);
const dropCaret = ref({
visible: false,
left: 0,
top: 0,
height: 24,
});
const selectionAiAction = ref({
visible: false,
buttonX: 0,
buttonY: 0,
start: 0,
end: 0,
selectedText: "",
x: 0,
y: 0,
width: 0,
placement: "bottom" as EditorAiAssistPlacement,
boundary: createViewportBoundary(),
});
const dragInsertIndex = ref<number | null>(null);
let syncingScroll = false;
type PointerSelectionState = {
key: string | null;
pointerId: number;
startX: number;
startY: number;
};
type OverlaySelectionRange = {
start: number;
end: number;
direction: "forward" | "backward";
};
type ClientPoint = {
clientX: number;
clientY: number;
};
type CaretVisualRect = {
left: number;
top: number;
height: number;
};
type EditorScrollState = {
top: number;
left: number;
};
type EditorAiAssistBoundary = {
left: number;
top: number;
right: number;
bottom: number;
};
type EditorAiAssistPlacement = "bottom" | "top";
type PromptSelectionAiSnapshot = {
start: number;
end: number;
selectedText: string;
x: number;
y: number;
width: number;
placement: EditorAiAssistPlacement;
boundary: EditorAiAssistBoundary;
};
let pointerSelectionState: PointerSelectionState | null = null;
let dragScrollLock: EditorScrollState | null = null;
const editorPlaceholder = "在这里输入提示词内容,可以使用 {{变量名}} 引用左侧定义的变量";
const highlightTokenRE = /\{\{\s*([^{}]+?)\s*\}\}/g;
const AI_SELECTION_ACTION_SIZE = 44;
const AI_SELECTION_ACTION_GAP = 10;
const AI_SELECTION_PANEL_GAP = 18;
const AI_SELECTION_PANEL_PREFERRED_HEIGHT = 320;
const AI_SELECTION_HIGHLIGHT_NAME = "kol-prompt-ai-optimize-selection";
const highlightedContent = computed(() => {
const text = props.content ?? "";
if (!text) {
return `<span class="editor-placeholder">${escapeHtml(editorPlaceholder)}</span>`;
}
let html = "";
let lastIndex = 0;
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 += [
`<span class="token-highlight">`,
escapeHtml(prefix),
`<span class="token-highlight__content" data-kol-key="${escapeAttribute(key)}" data-kol-start="${keyStart}">${escapeHtml(key)}</span>`,
escapeHtml(suffix),
`</span>`,
].join("");
lastIndex = matchIndex + token.length;
}
html += escapeHtml(text.slice(lastIndex));
return html;
});
const hasPromptContent = computed(() => Boolean(props.content?.trim()));
onMounted(() => {
window.addEventListener("dragend", hideDropCaret);
window.addEventListener("drop", hideDropCaret);
window.addEventListener("pointerdown", handleWindowPointerDown);
window.addEventListener("resize", handleViewportChange);
});
onBeforeUnmount(() => {
window.removeEventListener("dragend", hideDropCaret);
window.removeEventListener("drop", hideDropCaret);
window.removeEventListener("pointerdown", handleWindowPointerDown);
window.removeEventListener("resize", handleViewportChange);
clearSelectionAiHighlight();
});
watch(
() => props.selectionAiOpen,
(open) => {
if (!open) {
clearSelectionAiHighlight();
} else if (selectionAiAction.value.selectedText) {
syncSelectionAiHighlight(selectionAiAction.value.start, selectionAiAction.value.end);
}
},
);
watch(
() => props.content,
() => {
if (!props.selectionAiOpen) {
hideSelectionAiAction(true);
}
},
);
function onDragEnter(event: DragEvent) {
if (!isKolVariableDrag(event)) {
return;
}
event.preventDefault();
lockEditorScrollForDrag();
restoreDragScrollLock();
updateDragInsertionPoint(event);
restoreDragScrollLock();
}
function onDragOver(event: DragEvent) {
if (!isKolVariableDrag(event)) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
lockEditorScrollForDrag();
restoreDragScrollLock();
updateDragInsertionPoint(event);
restoreDragScrollLock();
}
function onDragLeave(event: DragEvent) {
const nextTarget = event.relatedTarget;
const currentTarget = event.currentTarget;
if (
nextTarget instanceof Node &&
currentTarget instanceof HTMLElement &&
currentTarget.contains(nextTarget)
) {
return;
}
if (currentTarget instanceof HTMLElement && isPointInsideElement(event, currentTarget)) {
return;
}
hideDropCaret();
}
function onDrop(event: DragEvent) {
event.preventDefault();
const type = event.dataTransfer?.getData("application/kol-variable-type") as KolVariableType;
if (!type) {
hideDropCaret();
return;
}
const insertionIndex =
dragInsertIndex.value ??
(highlightRef.value ? getCaretIndexFromPoint(highlightRef.value, event) : props.content.length);
const lockedScroll = dragScrollLock ?? readEditorScrollState();
hideDropCaret();
restoreEditorScroll(lockedScroll);
const labelValue = ref("");
Modal.confirm({
title: t("kol.manage.variable.label"),
content: () => {
return h("div", { style: { marginTop: "16px" } }, [
h("input", {
class: "ant-input",
placeholder: t("common.inputPlease"),
value: labelValue.value,
onInput: (e: Event) => {
labelValue.value = (e.target as HTMLInputElement).value;
},
}),
]);
},
onOk: () => {
const key = labelValue.value.trim() || t(`kol.manage.variable.${type}`);
const existingVariable = props.variables.find((variable) => variable.key === key);
if (!existingVariable) {
const newVar: KolVariableDefinition = createKolVariableDefinition(key, {
type,
});
emit("update:variables", [...props.variables, newVar]);
}
insertToken(buildKolPlaceholderToken(key), insertionIndex, lockedScroll);
},
});
}
function isKolVariableDrag(event: DragEvent): boolean {
const types = Array.from(event.dataTransfer?.types ?? []);
return types.includes("application/kol-variable-type");
}
function updateDragInsertionPoint(event: DragEvent) {
const highlight = highlightRef.value;
if (!highlight) {
return;
}
const insertIndex = getCaretIndexFromPoint(highlight, event);
dragInsertIndex.value = insertIndex;
updateDropCaretPosition(insertIndex);
}
function lockEditorScrollForDrag() {
if (!dragScrollLock) {
dragScrollLock = readEditorScrollState();
}
}
function restoreDragScrollLock() {
if (dragScrollLock) {
restoreEditorScroll(dragScrollLock);
}
}
function updateDropCaretPosition(insertIndex: number) {
const highlight = highlightRef.value;
if (!highlight) {
return;
}
const caretRect = getCaretVisualRectAtIndex(highlight, insertIndex);
const highlightRect = highlight.getBoundingClientRect();
const height = Math.max(caretRect.height, getEditorLineHeight(highlight) * 0.9);
dropCaret.value = {
visible: true,
left: clampNumber(caretRect.left - highlightRect.left, 0, highlight.clientWidth),
top: clampNumber(
caretRect.top - highlightRect.top,
0,
Math.max(highlight.clientHeight - height, 0),
),
height,
};
}
function hideDropCaret() {
dropCaret.value = {
...dropCaret.value,
visible: false,
};
dragInsertIndex.value = null;
dragScrollLock = null;
}
function insertToken(token: string, insertionIndex?: number, scrollState = readEditorScrollState()) {
if (!textareaRef.value) return;
const el = textareaRef.value;
const text = props.content;
const hasExplicitInsertionIndex = typeof insertionIndex === "number";
const start = hasExplicitInsertionIndex ? clampOffset(insertionIndex, text.length) : el.selectionStart;
const end = hasExplicitInsertionIndex ? start : el.selectionEnd;
const newContent = text.substring(0, start) + token + text.substring(end);
emit("update:content", newContent);
// Set cursor position after update
setTimeout(() => {
focusTextareaWithoutScroll(el);
el.setSelectionRange(start + token.length, start + token.length);
restoreTextareaScroll(scrollState.top, scrollState.left);
}, 0);
}
function handleAiGenerate() {
if (hasPromptContent.value) return;
if (!aiDescription.value.trim()) return;
emit("ai-generate", aiDescription.value);
}
function handleContentInput(event: Event) {
emit("update:content", (event.target as HTMLTextAreaElement).value);
hideSelectionAiAction(true);
syncScrollFromTextarea();
}
function scheduleTextareaSelectionSync() {
window.setTimeout(() => {
syncTextareaSelectionAction();
}, 0);
}
function syncTextareaSelectionAction() {
const textarea = textareaRef.value;
if (!textarea) {
return;
}
showSelectionAiAction(textarea.selectionStart, textarea.selectionEnd);
}
function handleHighlightPointerDown(event: PointerEvent) {
hideSelectionAiAction(!props.selectionAiOpen);
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;
}
const overlaySelection = getOverlaySelectionRange(highlight);
if (overlaySelection) {
const scrollTop = textarea.scrollTop;
const scrollLeft = textarea.scrollLeft;
textarea.setSelectionRange(
overlaySelection.start,
overlaySelection.end,
overlaySelection.direction,
);
focusTextareaWithoutScroll(textarea);
restoreTextareaScroll(scrollTop, scrollLeft);
showSelectionAiAction(overlaySelection.start, overlaySelection.end);
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(scrollTop, scrollLeft);
hideSelectionAiAction(!props.selectionAiOpen);
if (pointerState.key) {
emit("focus-variable", pointerState.key);
}
}
function handleHighlightPointerCancel() {
pointerSelectionState = null;
}
function showSelectionAiAction(start: number, end: number) {
const selectionStart = Math.min(start, end);
const selectionEnd = Math.max(start, end);
const selectedText = props.content.slice(selectionStart, selectionEnd);
if (selectionStart === selectionEnd || !selectedText.trim()) {
hideSelectionAiAction(!props.selectionAiOpen);
return;
}
const highlight = highlightRef.value;
if (!highlight) {
return;
}
const rect = getTextRangeVisualRect(highlight, selectionStart, selectionEnd);
if (!rect) {
return;
}
const panelFrame = resolveSelectionAiPanelFrame(rect);
const preferredButtonTop = rect.top - AI_SELECTION_ACTION_SIZE - AI_SELECTION_ACTION_GAP;
const buttonY =
preferredButtonTop >= 12 ? preferredButtonTop : rect.bottom + AI_SELECTION_ACTION_GAP;
selectionAiAction.value = {
visible: !props.selectionAiOpen,
buttonX: clampNumber(
rect.left + rect.width / 2,
AI_SELECTION_ACTION_SIZE / 2 + 12,
window.innerWidth - AI_SELECTION_ACTION_SIZE / 2 - 12,
),
buttonY: clampNumber(
buttonY,
12,
window.innerHeight - AI_SELECTION_ACTION_SIZE - 12,
),
start: selectionStart,
end: selectionEnd,
selectedText,
x: panelFrame.x,
y: panelFrame.y,
width: panelFrame.width,
placement: panelFrame.placement,
boundary: panelFrame.boundary,
};
syncSelectionAiHighlight(selectionStart, selectionEnd);
}
function hideSelectionAiAction(clearHighlight: boolean) {
selectionAiAction.value = {
...selectionAiAction.value,
visible: false,
};
if (clearHighlight) {
clearSelectionAiHighlight();
}
}
function openSelectionAiPanel() {
const action = selectionAiAction.value;
if (!action.selectedText.trim()) {
return;
}
emit("ai-optimize-selection", {
start: action.start,
end: action.end,
selectedText: action.selectedText,
x: action.x,
y: action.y,
width: action.width,
placement: action.placement,
boundary: action.boundary,
});
hideSelectionAiAction(false);
}
function handleWindowPointerDown(event: PointerEvent) {
const target = event.target;
if (target instanceof Node) {
if (textareaRef.value?.contains(target) || highlightRef.value?.contains(target)) {
return;
}
}
if (target instanceof Element && target.closest(".prompt-selection-ai-action")) {
return;
}
hideSelectionAiAction(!props.selectionAiOpen);
}
function handleViewportChange() {
hideSelectionAiAction(!props.selectionAiOpen);
}
function getPlaceholderKeyFromTarget(target: EventTarget | null): string | null {
const targetElement =
target instanceof HTMLElement
? target
: target instanceof Text
? target.parentElement
: null;
return targetElement?.closest<HTMLElement>("[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 getOverlaySelectionRange(container: HTMLElement): OverlaySelectionRange | null {
if (!hasOverlaySelection(container)) {
return null;
}
const selection = window.getSelection();
if (!selection) {
return null;
}
const anchorIndex = resolveCaretIndex(container, selection.anchorNode, selection.anchorOffset);
const focusIndex = resolveCaretIndex(container, selection.focusNode, selection.focusOffset);
if (anchorIndex === null || focusIndex === null) {
return null;
}
return {
start: Math.min(anchorIndex, focusIndex),
end: Math.max(anchorIndex, focusIndex),
direction: anchorIndex <= focusIndex ? "forward" : "backward",
};
}
function getCaretIndexFromPoint(container: HTMLElement, point: ClientPoint): number {
const browserIndex = getBrowserCaretIndex(container, point);
if (browserIndex !== null) {
return clampOffset(browserIndex, props.content.length);
}
const approximateIndex = getApproximateCaretIndex(container, point.clientX, point.clientY);
return clampOffset(approximateIndex, props.content.length);
}
function getBrowserCaretIndex(
container: HTMLElement,
point: ClientPoint,
): number | null {
if ("caretPositionFromPoint" in document) {
const position = document.caretPositionFromPoint(point.clientX, point.clientY);
const index = resolveCaretIndex(container, position?.offsetNode ?? null, position?.offset);
if (index !== null) {
return index;
}
}
if ("caretRangeFromPoint" in document) {
const range = document.caretRangeFromPoint(point.clientX, point.clientY);
const index = resolveCaretIndex(container, range?.startContainer ?? null, range?.startOffset);
if (index !== null) {
return index;
}
}
return null;
}
function getCaretVisualRectAtIndex(container: HTMLElement, targetIndex: number): CaretVisualRect {
const fallbackRect = getFallbackCaretVisualRect(container);
const position = getTextPositionAtIndex(container, targetIndex);
if (!position) {
return fallbackRect;
}
const collapsedRange = document.createRange();
collapsedRange.setStart(position.node, position.offset);
collapsedRange.collapse(true);
const collapsedRect = firstUsableRangeRect(collapsedRange);
if (collapsedRect) {
return {
left: collapsedRect.left,
top: collapsedRect.top,
height: collapsedRect.height,
};
}
const characterRect = getAdjacentCharacterRect(position.node, position.offset);
if (characterRect) {
return characterRect;
}
return fallbackRect;
}
function getTextRangeVisualRect(container: HTMLElement, start: number, end: number): DOMRect | null {
const startPosition = getTextPositionAtIndex(container, start);
const endPosition = getTextPositionAtIndex(container, end);
if (!startPosition || !endPosition) {
return null;
}
const range = document.createRange();
range.setStart(startPosition.node, startPosition.offset);
range.setEnd(endPosition.node, endPosition.offset);
const rects = Array.from(range.getClientRects()).filter(
(rect) => rect.width > 0 || rect.height > 0,
);
if (rects.length === 0) {
const rect = range.getBoundingClientRect();
return rect.width > 0 || rect.height > 0 ? rect : null;
}
return combineRects(rects);
}
function combineRects(rects: DOMRect[]): DOMRect {
const first = rects[0];
return rects.slice(1).reduce(
(combined, rect) => {
const left = Math.min(combined.left, rect.left);
const top = Math.min(combined.top, rect.top);
const right = Math.max(combined.right, rect.right);
const bottom = Math.max(combined.bottom, rect.bottom);
return new DOMRect(left, top, Math.max(right - left, 1), Math.max(bottom - top, 1));
},
new DOMRect(first.left, first.top, Math.max(first.width, 1), Math.max(first.height, 1)),
);
}
function resolveSelectionAiPanelFrame(anchorRect: DOMRect): { x: number; y: number; width: number; placement: EditorAiAssistPlacement; boundary: EditorAiAssistBoundary } {
const viewportPadding = 16;
const panelPadding = 24;
const preferredWidth = 1120;
const surfaceRect = highlightRef.value?.getBoundingClientRect();
const boundary = normalizeEditorBoundary(surfaceRect);
const maxWidth = Math.max(240, boundary.right - boundary.left - viewportPadding * 2);
const widthFromSurface = surfaceRect
? Math.max(240, surfaceRect.width - panelPadding * 2)
: preferredWidth;
const availableBelow = boundary.bottom - anchorRect.bottom - AI_SELECTION_PANEL_GAP - viewportPadding;
const availableAbove = anchorRect.top - boundary.top - AI_SELECTION_PANEL_GAP - viewportPadding;
const placement: EditorAiAssistPlacement =
availableBelow >= AI_SELECTION_PANEL_PREFERRED_HEIGHT || availableBelow >= availableAbove
? "bottom"
: "top";
return {
x: anchorRect.left + anchorRect.width / 2,
y:
placement === "top"
? Math.min(boundary.bottom - viewportPadding, anchorRect.top - AI_SELECTION_PANEL_GAP)
: Math.max(boundary.top + viewportPadding, anchorRect.bottom + AI_SELECTION_PANEL_GAP),
width: Math.min(maxWidth, preferredWidth, widthFromSurface),
placement,
boundary,
};
}
function createViewportBoundary(): EditorAiAssistBoundary {
return {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight,
};
}
function normalizeEditorBoundary(rect: DOMRect | null | undefined): EditorAiAssistBoundary {
if (!rect) {
return createViewportBoundary();
}
const left = clampNumber(rect.left, 0, window.innerWidth);
const top = clampNumber(rect.top, 0, window.innerHeight);
const right = clampNumber(rect.right, left, window.innerWidth);
const bottom = clampNumber(rect.bottom, top, window.innerHeight);
if (right - left < 1 || bottom - top < 1) {
return createViewportBoundary();
}
return { left, top, right, bottom };
}
function getCSSHighlightsRegistry():
| {
set: (name: string, value: unknown) => void;
delete: (name: string) => void;
}
| null {
const cssWithHighlights = globalThis.CSS as typeof globalThis.CSS & {
highlights?: {
set: (name: string, value: unknown) => void;
delete: (name: string) => void;
};
};
return cssWithHighlights.highlights ?? null;
}
function clearSelectionAiHighlight() {
getCSSHighlightsRegistry()?.delete(AI_SELECTION_HIGHLIGHT_NAME);
}
function syncSelectionAiHighlight(start: number, end: number) {
clearSelectionAiHighlight();
const registry = getCSSHighlightsRegistry();
const HighlightCtor = window.Highlight as (new (...ranges: Range[]) => unknown) | undefined;
const highlight = highlightRef.value;
if (!registry || !HighlightCtor || !highlight) {
return;
}
const startPosition = getTextPositionAtIndex(highlight, start);
const endPosition = getTextPositionAtIndex(highlight, end);
if (!startPosition || !endPosition) {
return;
}
const range = document.createRange();
range.setStart(startPosition.node, startPosition.offset);
range.setEnd(endPosition.node, endPosition.offset);
registry.set(AI_SELECTION_HIGHLIGHT_NAME, new HighlightCtor(range));
}
function getTextPositionAtIndex(
container: HTMLElement,
targetIndex: number,
): { node: Text; offset: number } | null {
const textNodes = getTextNodes(container);
if (textNodes.length === 0) {
return null;
}
let currentIndex = 0;
for (const textNode of textNodes) {
const textLength = textNode.textContent?.length ?? 0;
if (targetIndex <= currentIndex + textLength) {
return {
node: textNode,
offset: clampNumber(targetIndex - currentIndex, 0, textLength),
};
}
currentIndex += textLength;
}
const lastNode = textNodes[textNodes.length - 1];
return {
node: lastNode,
offset: lastNode.textContent?.length ?? 0,
};
}
function firstUsableRangeRect(range: Range): DOMRect | null {
const rects = Array.from(range.getClientRects());
const rect = rects.find((item) => item.height > 0);
if (rect) {
return rect;
}
const boundingRect = range.getBoundingClientRect();
return boundingRect.height > 0 ? boundingRect : null;
}
function getAdjacentCharacterRect(node: Text, offset: number): CaretVisualRect | null {
const textLength = node.textContent?.length ?? 0;
const range = document.createRange();
if (offset > 0) {
range.setStart(node, offset - 1);
range.setEnd(node, offset);
const rect = firstUsableRangeRect(range);
if (rect) {
return {
left: rect.right,
top: rect.top,
height: rect.height,
};
}
}
if (offset < textLength) {
range.setStart(node, offset);
range.setEnd(node, offset + 1);
const rect = firstUsableRangeRect(range);
if (rect) {
return {
left: rect.left,
top: rect.top,
height: rect.height,
};
}
}
return null;
}
function getFallbackCaretVisualRect(container: HTMLElement): CaretVisualRect {
const rect = container.getBoundingClientRect();
const style = window.getComputedStyle(container);
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
return {
left: rect.left + paddingLeft,
top: rect.top + paddingTop,
height: getEditorLineHeight(container),
};
}
function getEditorLineHeight(container: HTMLElement): number {
const style = window.getComputedStyle(container);
const lineHeight = Number.parseFloat(style.lineHeight);
if (Number.isFinite(lineHeight)) {
return lineHeight;
}
const fontSize = Number.parseFloat(style.fontSize) || 16;
return fontSize * 1.6;
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
return Math.min(Math.max(value, min), max);
}
function isPointInsideElement(point: ClientPoint, element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
return (
point.clientX >= rect.left &&
point.clientX <= rect.right &&
point.clientY >= rect.top &&
point.clientY <= rect.bottom
);
}
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;
}
}
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(scrollTop: number, scrollLeft: number) {
restoreEditorScroll({ top: scrollTop, left: scrollLeft });
requestAnimationFrame(() => {
restoreEditorScroll({ top: scrollTop, left: scrollLeft });
});
}
function readEditorScrollState(): EditorScrollState {
return {
top: highlightRef.value?.scrollTop ?? textareaRef.value?.scrollTop ?? 0,
left: highlightRef.value?.scrollLeft ?? textareaRef.value?.scrollLeft ?? 0,
};
}
function restoreEditorScroll(state: EditorScrollState) {
if (textareaRef.value) {
textareaRef.value.scrollTop = state.top;
textareaRef.value.scrollLeft = state.left;
}
if (highlightRef.value) {
highlightRef.value.scrollTop = state.top;
highlightRef.value.scrollLeft = state.left;
}
if (dropCaret.value.visible && dragInsertIndex.value !== null) {
updateDropCaretPosition(dragInsertIndex.value);
}
}
function syncScrollFromTextarea() {
if (!textareaRef.value || !highlightRef.value) {
return;
}
if (dragScrollLock) {
restoreDragScrollLock();
return;
}
if (syncingScroll) {
return;
}
syncingScroll = true;
highlightRef.value.scrollTop = textareaRef.value.scrollTop;
highlightRef.value.scrollLeft = textareaRef.value.scrollLeft;
if (dropCaret.value.visible && dragInsertIndex.value !== null) {
updateDropCaretPosition(dragInsertIndex.value);
}
requestAnimationFrame(() => {
syncingScroll = false;
});
}
function syncScrollFromHighlight() {
if (!textareaRef.value || !highlightRef.value) {
return;
}
if (dragScrollLock) {
restoreDragScrollLock();
return;
}
if (syncingScroll) {
return;
}
syncingScroll = true;
textareaRef.value.scrollTop = highlightRef.value.scrollTop;
textareaRef.value.scrollLeft = highlightRef.value.scrollLeft;
if (dropCaret.value.visible && dragInsertIndex.value !== null) {
updateDropCaretPosition(dragInsertIndex.value);
}
requestAnimationFrame(() => {
syncingScroll = false;
});
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttribute(value: string): string {
return escapeHtml(value).replaceAll('"', "&quot;");
}
</script>
<template>
<div
class="kol-prompt-edit-area"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<div v-if="!hasPromptContent || aiBusy" class="ai-assist-bar">
<a-input
v-model:value="aiDescription"
class="ai-input"
:placeholder="t('kol.manage.editor.aiPlaceholder')"
:disabled="aiBusy"
@press-enter="handleAiGenerate"
>
<template #prefix>
<ExperimentOutlined style="color: #722ed1" />
</template>
</a-input>
<a-button
v-if="!hasPromptContent"
type="primary"
class="ai-btn"
:loading="aiBusy"
@click="handleAiGenerate"
>
<template #icon><ThunderboltOutlined /></template>
{{ t('kol.manage.editor.aiGenerate') }}
</a-button>
<span v-if="aiBusy" class="ai-status-text">AI 正在处理中...</span>
</div>
<div class="editor-container" :class="{ 'editor-container--drop-active': dropCaret.visible }">
<div
ref="highlightRef"
class="prompt-highlight"
aria-hidden="true"
v-html="highlightedContent"
@pointerdown="handleHighlightPointerDown"
@pointerup="handleHighlightPointerUp"
@pointercancel="handleHighlightPointerCancel"
@scroll="syncScrollFromHighlight"
></div>
<textarea
ref="textareaRef"
class="prompt-textarea"
:value="content"
:placeholder="editorPlaceholder"
@input="handleContentInput"
@select="scheduleTextareaSelectionSync"
@mouseup="scheduleTextareaSelectionSync"
@keyup="scheduleTextareaSelectionSync"
@scroll="syncScrollFromTextarea"
></textarea>
<div
v-if="dropCaret.visible"
class="prompt-drop-caret"
:style="{
height: `${dropCaret.height}px`,
transform: `translate3d(${dropCaret.left}px, ${dropCaret.top}px, 0)`,
}"
></div>
</div>
<Teleport to="body">
<button
v-if="selectionAiAction.visible"
type="button"
class="prompt-selection-ai-action"
:style="{
transform: `translate3d(${selectionAiAction.buttonX}px, ${selectionAiAction.buttonY}px, 0) translateX(-50%)`,
}"
:title="t('kol.manage.editor.aiOptimize')"
@pointerdown.prevent.stop
@click.prevent.stop="openSelectionAiPanel"
>
<svg
class="prompt-selection-ai-action__icon"
aria-hidden="true"
viewBox="0 0 1024 1024"
width="1em"
height="1em"
fill="currentColor"
>
<path d="M716.571429 332l167.428571-167.428571-61.142857-61.142857-167.428571 167.428571zm255.428571-167.428571q0 15.428571-10.285714 25.714286l-734.857143 734.857143q-10.285714 10.285714-25.714286 10.285714t-25.714286-10.285714l-113.142857-113.142857q-10.285714-10.285714-10.285714-25.714286t10.285714-25.714286l734.857143-734.857143q10.285714-10.285714 25.714286-10.285714t25.714286 10.285714l113.142857 113.142857q10.285714 10.285714 10.285714 25.714286zm-772-108.571429l56 17.142857-56 17.142857-17.142857 56-17.142857-56-56-17.142857 56-17.142857 17.142857-56zm200 92.571429l112 34.285714-112 34.285714-34.285714 112-34.285714-112-112-34.285714 112-34.285714 34.285714-112zm531.428571 273.142857l56 17.142857-56 17.142857-17.142857 56-17.142857-56-56-17.142857 56-17.142857 17.142857-56zm-365.714286-365.714286l56 17.142857-56 17.142857-17.142857 56-17.142857-56-56-17.142857 56-17.142857 17.142857-56z"></path>
</svg>
</button>
</Teleport>
</div>
</template>
<style scoped>
:global(::highlight(kol-prompt-ai-optimize-selection)) {
color: inherit;
background: rgba(156, 163, 175, 0.42);
}
.kol-prompt-edit-area {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
min-width: 0;
}
.ai-assist-bar {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
gap: 8px;
background: #f9f0ff;
}
.ai-input {
flex: 1;
}
.ai-btn {
flex-shrink: 0;
}
.prompt-selection-ai-action {
position: fixed;
top: 0;
left: 0;
z-index: 1320;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 14px;
background: #ffffff;
color: #8c8c8c;
box-shadow:
0 10px 30px rgba(15, 23, 42, 0.18),
0 0 0 1px rgba(15, 23, 42, 0.04);
cursor: pointer;
transition:
color 0.16s ease,
box-shadow 0.16s ease;
}
.prompt-selection-ai-action:hover {
color: #1677ff;
box-shadow:
0 14px 34px rgba(22, 119, 255, 0.2),
0 0 0 1px rgba(22, 119, 255, 0.16);
}
.prompt-selection-ai-action__icon {
width: 22px;
height: 22px;
}
.ai-status-text {
display: inline-flex;
align-items: center;
color: #722ed1;
font-size: 13px;
white-space: nowrap;
}
.editor-container {
flex: 1;
position: relative;
display: flex;
overflow: hidden;
transition: background-color 0.18s ease;
}
.editor-container--drop-active {
background: linear-gradient(90deg, rgba(22, 119, 255, 0.035), rgba(114, 46, 209, 0.035));
}
.prompt-highlight {
position: absolute;
inset: 0;
z-index: 2;
overflow: auto;
padding: 24px;
white-space: pre-wrap;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: normal;
font-family: inherit;
font-size: 16px;
line-height: 1.6;
letter-spacing: normal;
color: #262626;
cursor: text;
user-select: text;
box-sizing: border-box;
}
.prompt-textarea {
position: relative;
z-index: 1;
flex: 1;
border: none;
resize: none;
padding: 24px;
background: transparent;
font-family: inherit;
font-size: 16px;
line-height: 1.6;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: normal;
letter-spacing: normal;
outline: none;
color: transparent;
caret-color: #262626;
box-sizing: border-box;
}
.prompt-textarea::placeholder {
color: transparent;
}
.prompt-drop-caret {
position: absolute;
top: 0;
left: 0;
z-index: 5;
width: 2px;
min-height: 20px;
border-radius: 999px;
background: #1677ff;
box-shadow:
0 0 0 3px rgba(22, 119, 255, 0.12),
0 0 18px rgba(22, 119, 255, 0.35);
pointer-events: none;
transition:
transform 0.12s cubic-bezier(0.22, 1, 0.36, 1),
height 0.12s ease,
opacity 0.12s ease;
animation: promptDropCaretBlink 0.9s ease-in-out infinite;
}
.prompt-drop-caret::before,
.prompt-drop-caret::after {
position: absolute;
left: 50%;
width: 12px;
height: 2px;
border-radius: 999px;
background: #1677ff;
content: "";
transform: translateX(-50%);
}
.prompt-drop-caret::before {
top: -1px;
}
.prompt-drop-caret::after {
bottom: -1px;
}
@keyframes promptDropCaretBlink {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 1;
}
}
.prompt-highlight :deep(.token-highlight) {
color: #eb2f96;
}
.prompt-highlight :deep(.token-highlight__content) {
cursor: text;
}
.prompt-highlight :deep(.editor-placeholder) {
color: #bfbfbf;
}
</style>