mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-29 08:51:27 -03:00
feat(recipes): restore Copy Recipe Syntax button in recipe modal header
- Add an icon-only copy button next to Send to ComfyUI in the header
actions row, styled as a textless variant of the neighboring pill
buttons
- Restore fetchAndCopyRecipeSyntax() wiring against the existing
/api/lm/recipe/{id}/syntax endpoint (context menu action unaffected)
- Add recipes.actions.copyRecipeSyntax i18n key, reusing the existing
per-locale translations of the identical context menu string
- Sync modal test fixtures and add copySyntax tests
This commit is contained in:
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Send to ComfyUI",
|
"sendCheckpoint": "Send to ComfyUI",
|
||||||
"sendRecipe": "Send to ComfyUI",
|
"sendRecipe": "Send to ComfyUI",
|
||||||
|
"copyRecipeSyntax": "Rezept-Syntax kopieren",
|
||||||
"deleteRecipeWithShortcut": "Rezept löschen (Del)"
|
"deleteRecipeWithShortcut": "Rezept löschen (Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Send to ComfyUI",
|
"sendCheckpoint": "Send to ComfyUI",
|
||||||
"sendRecipe": "Send to ComfyUI",
|
"sendRecipe": "Send to ComfyUI",
|
||||||
|
"copyRecipeSyntax": "Copy Recipe Syntax",
|
||||||
"deleteRecipeWithShortcut": "Delete recipe (Del)"
|
"deleteRecipeWithShortcut": "Delete recipe (Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Enviar a ComfyUI",
|
"sendCheckpoint": "Enviar a ComfyUI",
|
||||||
"sendRecipe": "Enviar a ComfyUI",
|
"sendRecipe": "Enviar a ComfyUI",
|
||||||
|
"copyRecipeSyntax": "Copiar sintaxis de receta",
|
||||||
"deleteRecipeWithShortcut": "Eliminar receta (Del)"
|
"deleteRecipeWithShortcut": "Eliminar receta (Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Envoyer vers ComfyUI",
|
"sendCheckpoint": "Envoyer vers ComfyUI",
|
||||||
"sendRecipe": "Envoyer vers ComfyUI",
|
"sendRecipe": "Envoyer vers ComfyUI",
|
||||||
|
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
|
||||||
"deleteRecipeWithShortcut": "Supprimer la Recipe (Del)"
|
"deleteRecipeWithShortcut": "Supprimer la Recipe (Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "שלח ל-ComfyUI",
|
"sendCheckpoint": "שלח ל-ComfyUI",
|
||||||
"sendRecipe": "שלח ל-ComfyUI",
|
"sendRecipe": "שלח ל-ComfyUI",
|
||||||
|
"copyRecipeSyntax": "העתק תחביר מתכון",
|
||||||
"deleteRecipeWithShortcut": "מחק מתכון (Del)"
|
"deleteRecipeWithShortcut": "מחק מתכון (Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "ComfyUIへ送信",
|
"sendCheckpoint": "ComfyUIへ送信",
|
||||||
"sendRecipe": "ComfyUIへ送信",
|
"sendRecipe": "ComfyUIへ送信",
|
||||||
|
"copyRecipeSyntax": "レシピ構文をコピー",
|
||||||
"deleteRecipeWithShortcut": "レシピを削除(Del)"
|
"deleteRecipeWithShortcut": "レシピを削除(Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "ComfyUI로 보내기",
|
"sendCheckpoint": "ComfyUI로 보내기",
|
||||||
"sendRecipe": "ComfyUI로 보내기",
|
"sendRecipe": "ComfyUI로 보내기",
|
||||||
|
"copyRecipeSyntax": "레시피 문법 복사",
|
||||||
"deleteRecipeWithShortcut": "레시피 삭제(Del)"
|
"deleteRecipeWithShortcut": "레시피 삭제(Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Отправить в ComfyUI",
|
"sendCheckpoint": "Отправить в ComfyUI",
|
||||||
"sendRecipe": "Отправить в ComfyUI",
|
"sendRecipe": "Отправить в ComfyUI",
|
||||||
|
"copyRecipeSyntax": "Копировать синтаксис рецепта",
|
||||||
"deleteRecipeWithShortcut": "Удалить рецепт (Del)"
|
"deleteRecipeWithShortcut": "Удалить рецепт (Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "发送到 ComfyUI",
|
"sendCheckpoint": "发送到 ComfyUI",
|
||||||
"sendRecipe": "发送到 ComfyUI",
|
"sendRecipe": "发送到 ComfyUI",
|
||||||
|
"copyRecipeSyntax": "复制配方语法",
|
||||||
"deleteRecipeWithShortcut": "删除配方(Del)"
|
"deleteRecipeWithShortcut": "删除配方(Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -860,6 +860,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "傳送到 ComfyUI",
|
"sendCheckpoint": "傳送到 ComfyUI",
|
||||||
"sendRecipe": "傳送到 ComfyUI",
|
"sendRecipe": "傳送到 ComfyUI",
|
||||||
|
"copyRecipeSyntax": "複製配方語法",
|
||||||
"deleteRecipeWithShortcut": "刪除配方(Del)"
|
"deleteRecipeWithShortcut": "刪除配方(Del)"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -190,6 +190,44 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Icon-only companion to the Send button: same pill style as its neighbors,
|
||||||
|
just without the text label. */
|
||||||
|
.modal-copy-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modal-copy-btn {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-copy-btn:hover {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-copy-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-copy-btn i {
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-height: 860px) {
|
@media (max-height: 860px) {
|
||||||
.recipe-header-actions {
|
.recipe-header-actions {
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
|
|||||||
@@ -1383,6 +1383,15 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy recipe syntax button (header actions)
|
||||||
|
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
|
||||||
|
if (copyRecipeSyntaxBtn) {
|
||||||
|
copyRecipeSyntaxBtn.addEventListener('click', () => {
|
||||||
|
// Use backend API to get recipe syntax
|
||||||
|
this.fetchAndCopyRecipeSyntax();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Send prompt to workflow buttons
|
// Send prompt to workflow buttons
|
||||||
const sendPromptBtn = document.getElementById('sendPromptBtn');
|
const sendPromptBtn = document.getElementById('sendPromptBtn');
|
||||||
const sendNegativePromptBtn = document.getElementById('sendNegativePromptBtn');
|
const sendNegativePromptBtn = document.getElementById('sendNegativePromptBtn');
|
||||||
@@ -1466,6 +1475,35 @@ class RecipeModal {
|
|||||||
copyToClipboard(text, successMessage);
|
copyToClipboard(text, successMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch recipe syntax from backend and copy to clipboard
|
||||||
|
async fetchAndCopyRecipeSyntax() {
|
||||||
|
if (!this.recipeId) {
|
||||||
|
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch recipe syntax from backend
|
||||||
|
const response = await fetch(`/api/lm/recipe/${this.recipeId}/syntax`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.syntax) {
|
||||||
|
// Use the centralized copyToClipboard utility function
|
||||||
|
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'No syntax returned from server');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recipe syntax:', error);
|
||||||
|
showToast('toast.recipes.copyFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send recipe to ComfyUI workflow
|
// Send recipe to ComfyUI workflow
|
||||||
async sendRecipeToWorkflow() {
|
async sendRecipeToWorkflow() {
|
||||||
if (!this.recipeId) {
|
if (!this.recipeId) {
|
||||||
|
|||||||
@@ -14,11 +14,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Header Actions: Send button is static; source URL button is appended dynamically in RecipeModal.js -->
|
<!-- Header Actions: Send and Copy buttons are static; source URL button is appended dynamically in RecipeModal.js -->
|
||||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
<i class="fas fa-paper-plane"></i> <span>{{ t('recipes.actions.sendRecipe') }}</span>
|
<i class="fas fa-paper-plane"></i> <span>{{ t('recipes.actions.sendRecipe') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="modal-copy-btn" id="copyRecipeSyntaxBtn" title="{{ t('recipes.actions.copyRecipeSyntax') }}" aria-label="{{ t('recipes.actions.copyRecipeSyntax') }}">
|
||||||
|
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
<button class="modal-delete-btn" id="deleteRecipeBtn" title="{{ t('recipes.actions.deleteRecipeWithShortcut') }}" aria-label="{{ t('recipes.actions.deleteRecipeWithShortcut') }}">
|
<button class="modal-delete-btn" id="deleteRecipeBtn" title="{{ t('recipes.actions.deleteRecipeWithShortcut') }}" aria-label="{{ t('recipes.actions.deleteRecipeWithShortcut') }}">
|
||||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const copyToClipboardMock = vi.fn();
|
||||||
|
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||||
|
|
||||||
|
const loadingManagerStub = {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateStub = {
|
||||||
|
global: { settings: {}, loadingManager: loadingManagerStub },
|
||||||
|
loadingManager: loadingManagerStub,
|
||||||
|
virtualScroller: { updateSingleItem: vi.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
copyToClipboard: copyToClipboardMock,
|
||||||
|
sendLoraToWorkflow: vi.fn(),
|
||||||
|
sendModelPathToWorkflow: vi.fn(),
|
||||||
|
openCivitaiByMetadata: vi.fn(),
|
||||||
|
stripLoraTags: vi.fn((text) => text),
|
||||||
|
sendPromptToWorkflow: vi.fn(),
|
||||||
|
sendGenParamsToWorkflow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: translateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: stateStub,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
|
setSessionItem: vi.fn(),
|
||||||
|
removeSessionItem: vi.fn(),
|
||||||
|
getStorageItem: vi.fn(() => null),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
fetchRecipeDetails: vi.fn(),
|
||||||
|
updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
|
sendRecipeWorkflow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
MODEL_TYPES: {
|
||||||
|
LORA: 'loras',
|
||||||
|
CHECKPOINT: 'checkpoints',
|
||||||
|
EMBEDDING: 'embeddings',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function flushAsyncTasks() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRecipeModal() {
|
||||||
|
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||||
|
return new RecipeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecipeModal copy recipe syntax', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn"><i class="fas fa-paper-plane"></i></button>
|
||||||
|
<button class="modal-copy-btn" id="copyRecipeSyntaxBtn"><i class="fas fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the recipe syntax when the header copy button is clicked', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.recipeId = 'recipe-1';
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ success: true, syntax: '<lora:foo:1>' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
recipeModal.setupCopyButtons();
|
||||||
|
document.getElementById('copyRecipeSyntaxBtn').dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/syntax');
|
||||||
|
expect(copyToClipboardMock).toHaveBeenCalledWith('<lora:foo:1>', 'Recipe syntax copied to clipboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error toast and skips the API call without a recipe ID', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.recipeId = null;
|
||||||
|
|
||||||
|
await recipeModal.fetchAndCopyRecipeSyntax();
|
||||||
|
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.noRecipeId', {}, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error toast when the backend returns no syntax', async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.recipeId = 'recipe-1';
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ success: false, error: 'no syntax' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await recipeModal.fetchAndCopyRecipeSyntax();
|
||||||
|
|
||||||
|
expect(copyToClipboardMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.recipes.copyFailed',
|
||||||
|
{ message: 'no syntax' },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -110,6 +110,7 @@ function recipeModalFixture() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
<button class="modal-send-btn" id="sendRecipeBtn"><i class="fas fa-paper-plane"></i></button>
|
<button class="modal-send-btn" id="sendRecipeBtn"><i class="fas fa-paper-plane"></i></button>
|
||||||
|
<button class="modal-copy-btn" id="copyRecipeSyntaxBtn"><i class="fas fa-copy"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user