Files
0nikod-ComfyUI-Simple-Prompt/src/components/TextEditor.vue
T
2026-05-14 08:52:07 +08:00

650 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, watch, nextTick, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { DuckDBService } from '../utils/duckdbService';
import { settings } from '../utils/settings';
import { getCaretCoordinates } from '../utils/caret';
import AutocompleteList from './AutocompleteList.vue';
import VisualTagArea from './VisualTagArea.vue';
import OtherFunctions from './OtherFunctions.vue';
import TagSearchModal from './TagSearchModal.vue';
import { formatGroupedTags, textToTags, tagsToText } from '../utils/promptParser';
import type { TagItem } from '../utils/types';
import { useTagCategoryEnrichment } from '../composables/useTagCategoryEnrichment';
import type { TagRecord } from '../api/types';
const props = defineProps({
modelValue: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'cursor-move', 'open-custom-tag']);
const { t } = useI18n();
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const localValue = ref(props.modelValue);
const tags = ref<TagItem[]>([]);
// Layout State
// Percentage of height taken by the Text Editor (top part)
// Default to 60%
const splitPercentage = ref(60);
const isResizing = ref(false);
const TEXT_PROCESS_DEBOUNCE_MS = 300;
let inputProcessTimeout: ReturnType<typeof setTimeout> | null = null;
let autocompleteRequestToken = 0;
const { cacheTagCategory, enrichTags, enrichTagsNow, stop: stopCategoryEnrichment } = useTagCategoryEnrichment(tags);
const cancelTextProcessing = () => {
if (inputProcessTimeout) {
clearTimeout(inputProcessTimeout);
inputProcessTimeout = null;
}
};
const refreshTagsFromText = (text: string) => {
tags.value = enrichTags(textToTags(text));
};
const scheduleTextProcessing = () => {
cancelTextProcessing();
inputProcessTimeout = setTimeout(() => {
inputProcessTimeout = null;
refreshTagsFromText(localValue.value);
const el = textareaRef.value;
if (!el || document.activeElement !== el) {
showAutocomplete.value = false;
loading.value = false;
return;
}
void checkAutocomplete(el);
}, TEXT_PROCESS_DEBOUNCE_MS);
};
// Initialize tags
tags.value = enrichTags(textToTags(localValue.value));
// Autocomplete State
const showAutocomplete = ref(false);
const searchResults = ref<TagRecord[]>([]);
const selectedIndex = ref(0);
const menuPosition = ref({ top: 0, left: 0 });
const currentQuery = ref('');
const queryStartPos = ref(0);
const loading = ref(false);
// Search Modal State
const showSearchModal = ref(false);
// Sync prop to local value (one-way from parent)
watch(() => props.modelValue, (newValue) => {
if (newValue !== localValue.value) {
cancelTextProcessing();
localValue.value = newValue;
refreshTagsFromText(newValue);
showAutocomplete.value = false;
searchResults.value = [];
selectedIndex.value = 0;
loading.value = false;
}
});
// Sync local value to parent & Update tags
const handleInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
localValue.value = target.value;
emit('update:modelValue', target.value);
showAutocomplete.value = false;
loading.value = false;
searchResults.value = [];
selectedIndex.value = 0;
scheduleTextProcessing();
};
// Copy functionality
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(localValue.value);
console.log('Copied to clipboard');
} catch (err) {
console.error('Failed to copy: ', err);
}
};
// Splitter Resize Logic
const startResize = (e: MouseEvent) => {
isResizing.value = true;
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', stopResize);
// Prevent text selection during drag
document.body.style.userSelect = 'none';
};
const doResize = (e: MouseEvent) => {
if (!isResizing.value || !containerRef.value) return;
// Calculate new percentage based on mouse Y relative to container
const containerRect = containerRef.value.getBoundingClientRect();
const relativeY = e.clientY - containerRect.top;
// Account for toolbar and footer fixed heights roughly (optional, but cleaner if precise)
// Or just use straight percentage of container.
// Let's stick to percentage of the *flexible area* (container height - toolbar - footer)
// but the container includes toolbar and footer.
// Easier approach: Just use direct percentage of container height for simplicity first,
// clamping to avoid breaking things.
let newPercent = (relativeY / containerRect.height) * 100;
// Clamp between 20% and 80%
if (newPercent < 20) newPercent = 20;
if (newPercent > 80) newPercent = 80;
splitPercentage.value = newPercent;
};
const stopResize = () => {
isResizing.value = false;
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', stopResize);
document.body.style.userSelect = '';
};
// Clean up event listeners just in case
onUnmounted(() => {
cancelTextProcessing();
stopCategoryEnrichment();
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', stopResize);
});
// Check if we should show autocomplete
const checkAutocomplete = async (el: HTMLTextAreaElement) => {
const cursor = el.selectionEnd;
const textBefore = el.value.substring(0, cursor);
// Regex to find the word being typed (alphanumeric + count > 2)
// We look for the last word boundary
const match = /([a-zA-Z0-9_\u4e00-\u9fa5]{2,})$/.exec(textBefore);
if (match) {
const query = match[1];
const requestToken = ++autocompleteRequestToken;
currentQuery.value = query;
queryStartPos.value = match.index;
// Calculate position
const coords = getCaretCoordinates(el, cursor);
menuPosition.value = {
top: coords.top + coords.height + 5, // 5px padding
left: coords.left
};
// Perform search
loading.value = true;
showAutocomplete.value = true;
try {
console.log(`[Autocomplete] Searching for: "${query}"`);
const db = DuckDBService.getInstance();
const results = await db.searchTags(query, 20, settings.useAliasesInAutocomplete);
if (requestToken !== autocompleteRequestToken) return;
searchResults.value = results;
selectedIndex.value = 0;
// Option: Close if no results
// if (results.length === 0) showAutocomplete.value = false;
} catch (e) {
if (requestToken === autocompleteRequestToken) {
console.error("Autocomplete search error:", e);
}
} finally {
if (requestToken === autocompleteRequestToken) {
loading.value = false;
}
}
} else {
autocompleteRequestToken++;
showAutocomplete.value = false;
searchResults.value = [];
selectedIndex.value = 0;
currentQuery.value = '';
loading.value = false;
}
};
// Handle Keyboard Navigation
const handleKeydown = (e: KeyboardEvent) => {
if (showAutocomplete.value && searchResults.value.length > 0) {
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex.value = (selectedIndex.value - 1 + searchResults.value.length) % searchResults.value.length;
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex.value = (selectedIndex.value + 1) % searchResults.value.length;
return;
}
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
selectItem(searchResults.value[selectedIndex.value]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
showAutocomplete.value = false;
return;
}
}
};
const selectItem = (item: TagRecord) => {
if (!textareaRef.value) return;
cancelTextProcessing();
const el = textareaRef.value;
const cursor = el.selectionEnd;
const text = localValue.value;
const tagName = formatTagNameForInsertion(item.name, item.category);
// Replace the query with the selected tag name
const prefix = text.substring(0, queryStartPos.value);
const suffix = text.substring(cursor);
// Auto-append comma
let newSuffix = suffix;
if (!suffix.trim().startsWith(',') && !suffix.trim().startsWith(')')) {
newSuffix = ', ' + suffix;
}
const newText = prefix + tagName + newSuffix;
localValue.value = newText;
emit('update:modelValue', newText);
// Update tags with enrichment
refreshTagsFromText(newText);
showAutocomplete.value = false;
nextTick(() => {
const newCursorPos = prefix.length + tagName.length + (newSuffix.startsWith(', ') ? 2 : 0);
el.setSelectionRange(newCursorPos, newCursorPos);
el.focus();
});
};
const handleBlur = () => {
setTimeout(() => {
showAutocomplete.value = false;
}, 200);
};
const formatTagNameForInsertion = (tagName: string, category?: number) => {
let formattedTagName = tagName;
cacheTagCategory(formattedTagName, category);
if (settings.convertUnderscoreToSpace) {
formattedTagName = formattedTagName.replace(/_/g, ' ');
cacheTagCategory(formattedTagName, category);
}
if (settings.escapeBrackets) {
formattedTagName = formattedTagName.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
return formattedTagName;
};
// Handle search modal
const openSearchModal = () => {
showSearchModal.value = true;
};
const closeSearchModal = () => {
showSearchModal.value = false;
};
// Add tag from search
const handleAddTag = (tagName: string, category: number) => {
cancelTextProcessing();
const formattedTagName = formatTagNameForInsertion(tagName, category);
// Create a new tag and add it to the list
const newTag: TagItem = {
id: `tag-${Date.now()}-${Math.random()}`,
text: formattedTagName,
weight: 1.0,
enabled: true,
category: category
};
const newTags = [...tags.value, newTag];
tags.value = newTags;
// Update text
const newText = tagsToText(newTags);
localValue.value = newText;
emit('update:modelValue', newText);
};
// Expose focus method
const focus = () => {
textareaRef.value?.focus();
};
// --- Footer Handlers ---
// Format: ,xxx -> , xxx
const handleFormat = () => {
cancelTextProcessing();
let newVal = localValue.value;
// Replace comma followed immediately by non-space with comma space
// Also trim multiple spaces
newVal = newVal.replace(/,(\S)/g, ', $1');
if (newVal !== localValue.value) {
localValue.value = newVal;
emit('update:modelValue', newVal);
refreshTagsFromText(newVal);
}
};
// Handle updates from VisualTagArea
const handleTagsUpdate = (newTags: TagItem[]) => {
cancelTextProcessing();
tags.value = newTags;
// If multiline is already present or we want to maintain the organized structure
// we use grouped formatting if at least one tag has a category.
const hasCategory = newTags.some(t => t.category !== undefined && t.category !== 0);
const newText = hasCategory ? formatGroupedTags(newTags) : tagsToText(newTags);
if (newText !== localValue.value) {
localValue.value = newText;
emit('update:modelValue', newText);
}
};
// Organize
const handleOrganize = async () => {
cancelTextProcessing();
const rawTags = textToTags(localValue.value);
const enriched = await enrichTagsNow(rawTags);
const newText = formatGroupedTags(enriched);
if (newText !== localValue.value) {
localValue.value = newText;
emit('update:modelValue', newText);
// Important: Update the tags ref with proper categories to keep visual synced
tags.value = textToTags(newText).map(t => {
const match = enriched.find(e => e.text === t.text);
return match ? { ...t, category: match.category } : t;
});
}
};
// Insert Meta (at cursor)
const handleInsertMeta = (metaWord: string) => {
const el = textareaRef.value;
if (!el) return;
cancelTextProcessing();
const start = el.selectionStart;
const end = el.selectionEnd;
const text = localValue.value;
// Add comma if needed
let insertText = metaWord;
const charBefore = text.substring(start - 1, start);
if (charBefore && charBefore.trim() && charBefore !== ',') {
insertText = ', ' + insertText;
} else if (charBefore === ',') {
insertText = ' ' + insertText;
}
// Add comma after if needed?
// Let's just insert.
const newText = text.substring(0, start) + insertText + text.substring(end);
localValue.value = newText;
emit('update:modelValue', newText);
refreshTagsFromText(newText);
nextTick(() => {
el.focus();
el.setSelectionRange(start + insertText.length, start + insertText.length);
});
};
// Toggle Auto Meta (Sync with settings)
const handleAutoMetaToggle = (val: boolean) => {
settings.autoMetaEnabled = val;
};
defineExpose({ focus });
</script>
<template>
<div class="sp-editor-container" ref="containerRef">
<!-- 1. Toolbar -->
<div class="sp-toolbar">
<button class="sp-btn" @click="copyToClipboard" :title="t('editor.copyTitle')">
<span class="icon">📋</span> {{ t('editor.copy') }}
</button>
<button class="sp-btn" @click="$emit('open-custom-tag')" :title="t('customTag.addBtnTitle')">
<span class="icon">➕</span> {{ t('customTag.addBtnTitle') }}
</button>
<!-- Add more buttons here as needed -->
</div>
<!-- 2. Text Editor -->
<div class="sp-editor-area" :style="{ height: `calc(${splitPercentage}% - 40px)` }">
<textarea
ref="textareaRef"
class="sp-textarea"
v-model="localValue"
@input="handleInput"
@keydown="handleKeydown"
@blur="handleBlur"
:placeholder="t('editor.placeholder')"
spellcheck="false"
></textarea>
<!-- Autocomplete Menu -->
<div v-if="showAutocomplete" class="autocomplete-wrapper" :style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }">
<AutocompleteList
:items="searchResults"
:selected-index="selectedIndex"
:loading="loading"
@select="selectItem"
/>
</div>
</div>
<!-- Splitter -->
<div class="sp-splitter" @mousedown="startResize">
<div class="sp-splitter-handle"></div>
</div>
<!-- 3. Visual Editor -->
<div class="sp-visual-area">
<VisualTagArea
:tags="tags"
@update:tags="handleTagsUpdate"
/>
</div>
<!-- 4. Other Functions (Footer) -->
<div class="sp-footer">
<OtherFunctions
:tag-count="tags.length"
:auto-meta="settings.autoMetaEnabled"
@open-search="openSearchModal"
@format="handleFormat"
@organize="handleOrganize"
@update:auto-meta="handleAutoMetaToggle"
@insert-meta="handleInsertMeta"
/>
</div>
<!-- Tag Search Modal -->
<TagSearchModal
:visible="showSearchModal"
@close="closeSearchModal"
@add-tag="handleAddTag"
/>
</div>
</template>
<style scoped>
.sp-editor-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #1e1e1e;
color: #e0e0e0;
overflow: hidden; /* Prevent container scroll, inner areas scroll */
}
/* 1. Toolbar */
.sp-toolbar {
padding: 8px;
background-color: #252526;
border-bottom: 1px solid #333;
display: flex;
gap: 8px;
flex-shrink: 0; /* Keep toolbar fixed size */
}
.sp-btn {
background-color: #333;
color: #eee;
border: 1px solid #444;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
.sp-btn:hover {
background-color: #444;
}
.sp-btn:active {
background-color: #2a2a2a;
}
/* 2. Editor Area */
.sp-editor-area {
/* Height is set dynamically by splitPercentage */
min-height: 50px;
position: relative; /* Context for autocomplete */
display: flex;
}
.sp-textarea {
flex: 1;
width: 100%;
height: 100%;
background-color: #1e1e1e;
color: #e0e0e0;
border: none;
padding: 16px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
resize: none;
outline: none;
}
/* Splitter */
.sp-splitter {
height: 6px;
background-color: #252526;
cursor: row-resize;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #333;
border-bottom: 1px solid #333;
flex-shrink: 0;
z-index: 10;
}
.sp-splitter:hover {
background-color: #007fd4; /* Highlight on hover */
}
.sp-splitter-handle {
width: 30px;
height: 2px;
background-color: #555;
border-radius: 1px;
}
/* 3. Visual Area */
.sp-visual-area {
flex: 1; /* Fills remaining space */
min-height: 50px;
background-color: #1a1a1a;
overflow: hidden; /* inner component handles scroll */
position: relative; /* In case we need overlapping */
}
/* 4. Footer */
.sp-footer {
padding: 8px;
background-color: #252526;
border-top: 1px solid #333;
font-size: 12px;
color: #888;
flex-shrink: 0;
}
.sp-textarea::placeholder {
color: #555;
}
/* Scrollbar styling */
.sp-textarea::-webkit-scrollbar {
width: 10px;
}
.sp-textarea::-webkit-scrollbar-track {
background: #1e1e1e;
}
.sp-textarea::-webkit-scrollbar-thumb {
background: #333;
border-radius: 5px;
}
.sp-textarea::-webkit-scrollbar-thumb:hover {
background: #444;
}
.autocomplete-wrapper {
position: absolute;
z-index: 20000;
}
</style>