2846968cf7
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>
1383 lines
37 KiB
Vue
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">");
|
|
}
|
|
|
|
function escapeAttribute(value: string): string {
|
|
return escapeHtml(value).replaceAll('"', """);
|
|
}
|
|
</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>
|