diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9ab87b4..a5535d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -431,8 +431,11 @@ covered by the `styles.css` manifest. views are `ui_prompt_source_deck.js`, `ui_prompt_workbench.js`, and `ui_prompt_inspector.js`. Assembly plan data, track-vs-role separation, and cross-role tail smart-sorting are owned by `prompt_composition.js` and `prompt_studio_data.js`. - `ui_prompt_source_deck.js` owns the card preview popover with narrow bridging corridors, - differentiated hide timers, and fast dismissal when hovering or clicking library blank space. + `ui_prompt_source_deck.js` owns the card list, filters, new-card form, library sync and + renaming / deleting a saved prompt; `ui_prompt_card_popover.js` owns the card preview (hover + corridor, pin, Copy / Add / Rename / Delete); `prompt_card_drag.js` lets a card be dragged out + of the drawer onto a canvas prompt box or empty canvas (through `material_drag.js` and + `prompt_drop.js`), passing through inside the drawer so the assembly board still takes it. - `ui_node_prompts.js` is the current-node panel's prompt boxes: role, text and translation in place (Chinese to English written back with one undo, or the Chinese meaning shown without writing). The studio uses `ui_lifecycle.js` for global listeners, diff --git a/CHANGELOG.md b/CHANGELOG.md index 92fc88a..f22e235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - **Current node (当前节点, formerly Node Assistant)**: the panel no longer splits into "actions" and "parameter sets" tabs; it is one page with the model actions on top and **Parameters** below. Parameters lists every saved set of values for this kind of node in one list — from the material library and from recipes' parameter sets, the same values saved twice shown once — and each entry says exactly what it would change on the selected node ("steps: 20 → 30"). **Apply these N** changes only those; seeds and model files stay (change the model above), and the receipt undoes it. **Save these values** keeps the node's current values in the material library, so the next node of that kind can use them. - **Translation moved onto the node (翻译挪到节点上)**: Current node now lists the selected node's prompt boxes, each marked positive or negative by its wiring. A box written in Chinese has **Translate to English**, which writes the English back (the receipt undoes it); an English box has **Show in Chinese**, which shows the meaning underneath and writes nothing. The separate Prompt Translator window is gone; Prompt Studio keeps its own translate buttons. - **No more toolbox (工具箱拆掉了)**: every tool now opens from the page it belongs to. Workflow share codes (import / export) are the ⇅ button at the top right of **Workflows** (where a disabled import button used to sit); **Prompt Studio** is at the top right of **Materials** (Prompt Notes became **Combos**, a page of their own); **Model Sources** is at the end of the type chips on **Models**, and Model Check has **Where to download** for the open workflow's models; translation is on the prompt boxes in **Current node**. The rail keeps scan, Model Check and Current node. +- **Saved prompts are managed in Prompt Studio (存过的提示词在提示词工坊里管理)**: the studio's library cards for prompts you saved used to be read-only ("managed by the source material"). Their preview now has **Rename** and **Delete** (the whole saved prompt, positive and negative together, to the Recycle Bin), and any card can be dragged out of the studio onto the canvas: onto a prompt box it fills that box (outlined by role, with the text previewed before you let go, one Ctrl+Z or Undo in the message to take it back), onto empty canvas it makes a prompt node. Dragging a card onto the assembly board works as before. - **Keep a result from the gallery (图库里点 ☆ 收藏)**: every image in the gallery has a ☆. It asks what to keep, and says where each goes: **The whole workflow** (a Workflow Recipe on the Workflows page, with the picture as its cover), **Its combo** (the main model, LoRAs and positive prompt, on the Combos page, ready for Use; models this computer does not have are left out and the message says how many) or **Just the prompts** (positive and negative, in your saved prompts). No name is asked: it is named after the prompt. Kept images show ★; a row already kept shows ✓ and opens it. The image workbench's **Keep** opens the same menu (its name-and-tags form is gone). The Material Library's Recent shelf, which did the same for whole workflows only, is gone: the gallery lists the same images. Deleting an image from the gallery asks in the usual dialog instead of over the picture. - **Model sources on the Models page (模型来源进了模型页)**: the Sources chip at the end of the model types now shows, in place of the cards, where each model is downloaded, for **This workflow** or **All models**, filtered by with / without a link. Every link can be typed or pasted straight into its row (any site, not only Civitai), found automatically, opened and saved with one button; a link is kept in the model's own information (the same as the editor's download link) and, for this workflow's models, in the workflow too, so it travels when the workflow is shared. The list can go on the canvas as a note or to the clipboard. Model Check's **Where to download** opens it on This workflow. The separate pop-up, its lock-then-edit links and its four ways of saving are gone. - **One look everywhere (配色统一)**: pages made at different times used their own greys (bluish slate here, neutral grey there) and their own accent (sky blue, violet, indigo). They now all take the Home page's colours: neutral surfaces, the same text greys and one accent for links and selection, and they follow the Abyssal Scarlet theme too (the Material Library, Prompt Studio, voices and several dialogs used to stay blue-grey in it). The workflow share-code dialogs lost their blue and orange buttons for the usual ones. Status colours and prompt-role colours are unchanged. diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 4072fa8..7cd85df 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -250,8 +250,12 @@ cards are reconciled by filename and role from a complete paginated snapshot; renames/content edits replace them and deleted sources disappear. Failed or cancelled reads retain existing cards. Equal text from different sources retains each source identity. Built-in and unsaved local cards remain independent. -Library cards show their origin and have no delete action in the studio. Mixer -blocks are editable copies; refreshing sources must not modify existing drafts. +Library cards are saved prompts: their preview renames one (`update_material`, tags kept) +or deletes it (`delete_material`, to the Recycle Bin; both of its role cards go), then the +deck syncs. Any card with text can be dragged out of the drawer onto a canvas prompt box +or empty canvas (`prompt_card_drag.js`, one undo step); the same drag over the drawer is +left to the assembly board. Mixer blocks are editable copies; refreshing sources must not +modify existing drafts. The deck owns its refresh timer, listeners and AbortController and releases all of them when its view closes. New-card role is supplied by the workbench callback. @@ -264,7 +268,9 @@ to call `openPromptStudio(owner)`, and external prompt dispatch uses | --- | --- | | `ui_prompt_composer.js` | Active drawer, docking width/side, trigger visibility and plan-loading request | | `ui_prompt_workbench.js` | Owner-backed draft, active role, block editing, ordering, write/copy/save | -| `ui_prompt_source_deck.js` | Source cards, filters, new-card form, preview popover and current sync request | +| `ui_prompt_source_deck.js` | Source cards, filters, new-card form, rename/delete of saved prompts and current sync request | +| `ui_prompt_card_popover.js` | Card preview: hover corridor, pin, copy/add/rename/delete actions | +| `prompt_card_drag.js` | A card dragged onto the canvas: box fill or new prompt node, passing through the drawer | | `ui_prompt_inspector.js` | Full-text inspection window, role tab and its translation lifetime | | `prompt_studio_data.js` | Starter cards, display categories, draft/block initialization and synthesis | | `prompt_composition.js` | Pure plan conversion, sorting and composition; no DOM ownership | diff --git a/web/modules/locales.js b/web/modules/locales.js index a2531f1..ff1d411 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1142,11 +1142,33 @@ export const i18n = { sidebarBgAtmosphere: '背景氛围', sidebarBgAtmosphereHelp: '调节底图星图蓝图的可见度(0%为纯净黑曜石)', sidebarResetConfirm: '确认重置窗口位置、缩放和停靠状态吗?', - promptLibraryAutoSync: "素材库词卡自动同步", - promptLibrarySyncFailed: "素材库同步失败,已保留现有词卡,将自动重试", - promptLibraryBadge: "🔒 来自素材库", - promptLibrarySource: "来自素材库:{name}({filename})", - promptLibraryReadOnly: "由源素材管理,不能在工坊删除;拼装台中的副本可自由编辑。", + promptLibraryAutoSync: "存过的提示词会自动同步", + promptLibrarySyncFailed: "存过的提示词没同步上,已保留现有词卡,会自动重试", + promptLibraryBadge: "已存", + promptLibrarySource: "你存过的提示词,可以改名或删除", + promptCardPositive: "⊕ 正向", + promptCardNegative: "⊖ 负向", + promptCardCopy: "复制", + promptCardCopied: "已复制", + promptCardCopyHint: "复制提示词到剪贴板", + promptCardPin: "固定", + promptCardPinned: "已固定", + promptCardPinHint: "固定浮窗,移开鼠标也不关", + promptCardClose: "关闭浮窗", + promptCardAddHint: "点卡片加入拼装台,或拖到画布的提示词框里", + promptCardAdd: "加入", + promptCardAddTitle: "加入右边的拼装台", + promptCardRename: "改名", + promptCardRenameHint: "给这条存过的提示词改个名字", + promptCardRenameAsk: "新名字", + promptCardRenameFailed: "没改成,请再试一次。", + promptCardDelete: "删除", + promptCardDeleteHint: "删除这条存过的提示词(进回收站)", + promptCardDeleteConfirm: "删除“{name}”?它的正向和负向会一起删掉,移到系统回收站。", + promptCardDeleteFailed: "没删掉,请再试一次。", + promptCardDragCanvas: "松手:在这里新建一个提示词节点", + promptNodePositive: "CLIP 文本编码器 (正向)", + promptNodeNegative: "CLIP 文本编码器 (负向)", toolWorkflowTransferTitle: "工作流导入导出中心", toolPromptStudioShort: '提示词工坊', toolModelSourcesTitle: "每个模型去哪下载,链接可以改成任何网站", @@ -3095,11 +3117,33 @@ export const i18n = { sidebarBgAtmosphere: 'Background Atmosphere', sidebarBgAtmosphereHelp: 'Adjust blueprint backdrop opacity (0% is pure solid dark)', sidebarResetConfirm: 'Reset window position, scale and dock state?', - promptLibraryAutoSync: "Material Library cards sync automatically", - promptLibrarySyncFailed: "Library sync failed. Existing cards are kept; retrying automatically.", - promptLibraryBadge: "🔒 From Material Library", - promptLibrarySource: "Material Library: {name} ({filename})", - promptLibraryReadOnly: "Managed by the source material; cannot be deleted here. Mixer copies remain editable.", + promptLibraryAutoSync: "Your saved prompts sync automatically", + promptLibrarySyncFailed: "Saved prompts did not sync. Existing cards are kept; retrying automatically.", + promptLibraryBadge: "Saved", + promptLibrarySource: "A prompt you saved; you can rename or delete it", + promptCardPositive: "⊕ Positive", + promptCardNegative: "⊖ Negative", + promptCardCopy: "Copy", + promptCardCopied: "Copied", + promptCardCopyHint: "Copy the prompt text", + promptCardPin: "Pin", + promptCardPinned: "Pinned", + promptCardPinHint: "Keep the preview open", + promptCardClose: "Close the preview", + promptCardAddHint: "Click a card to add it to the board, or drag it onto a prompt box on the canvas", + promptCardAdd: "Add", + promptCardAddTitle: "Add it to the assembly board", + promptCardRename: "Rename", + promptCardRenameHint: "Rename this saved prompt", + promptCardRenameAsk: "New name", + promptCardRenameFailed: "Could not rename it; try again.", + promptCardDelete: "Delete", + promptCardDeleteHint: "Delete this saved prompt (to the Recycle Bin)", + promptCardDeleteConfirm: "Delete “{name}”? Its positive and negative parts go together, to the Recycle Bin.", + promptCardDeleteFailed: "Could not delete it; try again.", + promptCardDragCanvas: "Release: a new prompt node here", + promptNodePositive: "CLIP Text Encode (Positive)", + promptNodeNegative: "CLIP Text Encode (Negative)", toolWorkflowTransferTitle: "Workflow Import / Export Center", toolPromptStudioShort: 'Prompt Studio', toolModelSourcesTitle: "Where each model is downloaded; any site's link can go here", diff --git a/web/modules/material_drag.js b/web/modules/material_drag.js index 80fab9d..8e19cf2 100644 --- a/web/modules/material_drag.js +++ b/web/modules/material_drag.js @@ -34,8 +34,10 @@ export function materialDropNode(event, canvas, graph) { * accepted node will do; optional `rejectHint(node, data, event)` explains why a hovered * node is refused. Optional `onStart(data)` may return what to undo when the drag ends; * optional `onMove(node, data, event)` follows the accepted node under the pointer (or null). + * Optional `passThrough(event)` leaves events over some part of the page (a drop zone of its + * own) alone; `effectAllowed` defaults to 'copy'. `owner` may be null (no browser to fold). */ -export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropOnCanvas, targetHint, rejectHint, onStart, onMove }) { +export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropOnCanvas, targetHint, rejectHint, onStart, onMove, passThrough, effectAllowed = 'copy' }) { element.draggable = true; element.addEventListener('dragstart', event => { if (event.target !== element && event.target?.closest?.('button, input, textarea, select')) { event.preventDefault(); return; } @@ -46,7 +48,7 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropO activeDrag?.(); event.stopPropagation(); event.dataTransfer.setData('application/x-anomalous-material', 'local-drag'); - event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.effectAllowed = effectAllowed; const defaultHint = data.dragHint || t('materialDropHint'); const hint = text(document.body, 'div', defaultHint, 'anomalous-material-drag-hint'); hint.setAttribute('role', 'status'); @@ -54,7 +56,7 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropO const cleanup = () => { clearTimeout(reveal); stopStart?.(); - owner.modal?.classList.remove('anomalous-material-dragging'); + owner?.modal?.classList.remove('anomalous-material-dragging'); hint.remove(); for (const [name, fn] of listeners) window.removeEventListener(name, fn, true); if (activeDrag === cleanup) activeDrag = null; @@ -68,6 +70,12 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropO }; const target = event => app.graph === graph && app.canvas === canvas ? materialDropNode(event, canvas, graph) : null; const over = event => { + if (passThrough?.(event)) { + hint.style.visibility = 'hidden'; + onMove?.(null, data, event); + return; + } + hint.style.visibility = ''; event.preventDefault(); event.stopImmediatePropagation(); const node = target(event); const validNode = !!node && accepts?.(node, data, event); @@ -93,6 +101,10 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropO hint.style.top = `${Math.max(8, event.clientY - 48)}px`; }; const finish = async event => { + if (passThrough?.(event)) { + cleanup(); + return; + } event.preventDefault(); event.stopImmediatePropagation(); const node = target(event); const validNode = !!node && accepts?.(node, data, event); @@ -114,7 +126,7 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropO const escape = event => { if (event.key === 'Escape') cleanup(); }; const listeners = [['dragover', over], ['drop', finish], ['dragend', cleanup], ['keydown', escape], ['blur', cleanup]]; // Let the browser capture the card's drag image before revealing the canvas. - const reveal = setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0); + const reveal = setTimeout(() => owner?.modal?.classList.add('anomalous-material-dragging'), 0); activeDrag = cleanup; for (const [name, fn] of listeners) window.addEventListener(name, fn, true); }); diff --git a/web/modules/prompt_card_drag.js b/web/modules/prompt_card_drag.js new file mode 100644 index 0000000..b172b3a --- /dev/null +++ b/web/modules/prompt_card_drag.js @@ -0,0 +1,112 @@ +/** + * Dragging a Prompt Studio card out of the drawer onto the canvas (material_drag.js runs the + * drag, prompt_drop.js outlines the boxes and shows what lands where): on a prompt box the + * card's text fills it; on empty canvas it becomes a prompt node of the card's role. Inside + * the drawer the drag passes through, so the assembly board still takes the card. A fill or + * new node is one Ctrl+Z step, and the toast's Undo takes it back. + */ + +import { app } from '../../../scripts/app.js'; +import { translate as t } from './locales.js'; +import { recordCanvasStep } from './canvas_history.js'; +import { bindMaterialDrag } from './material_drag.js'; +import { fillPrompt } from './node_material_actions.js'; +import { + clearDropPreview, markTargetBox, outlinePromptBoxes, prepareTextDrag, previewPromptDrop, promptBoxAt, + promptDropHint, promptRefusal, +} from './prompt_drop.js'; +import { showWorkbenchToast } from './ui_prompt_toast.js'; + +const NODE_COLOURS = { positive: ['#235327', '#143818'], negative: ['#532323', '#381616'] }; + +/** A card's text as the envelope prompt boxes are filled from. */ +function envelopeOf(card) { + const negative = card.role === 'negative'; + return { + positive: negative ? '' : card.content, + negative: negative ? card.content : '', + singleText: card.content, + primaryRole: negative ? 'negative' : 'positive', + hasPrompt: Boolean(card.content.trim()), + }; +} + +function undoToast(message, undo) { + showWorkbenchToast(message, { label: t('activityUndo'), run: undo }); +} + +function fillBox(node, box, envelope) { + if (node.widgets?.[box.index] !== box.widget) throw new Error('materialTargetChanged'); + const result = fillPrompt(app, node, envelope, box, { record: false }); + recordCanvasStep(app); + undoToast(t('promptFilledOne', { node: `#${node.id}` }), () => { + result.undo(); + recordCanvasStep(app); + }); +} + +function addPromptNode(graph, position, card) { + const node = globalThis.LiteGraph?.createNode('CLIPTextEncode'); + if (!node) return; + const role = card.role === 'negative' ? 'negative' : 'positive'; + node.title = t(role === 'negative' ? 'promptNodeNegative' : 'promptNodePositive'); + [node.color, node.bgcolor] = NODE_COLOURS[role]; + node.pos = [position[0], position[1]]; + graph.add(node); + const box = node.widgets?.find(widget => widget.name === 'text'); + if (box) { + box.value = card.content; + box.callback?.call(box, card.content, app.canvas, node); + } + app.canvas?.selectNode?.(node); + graph.setDirtyCanvas?.(true, true); + recordCanvasStep(app); + undoToast(t('promptNodesCreated', { count: 1 }), () => { + const loose = graph.getNodeById(node.id) === node + && !(node.inputs || []).some(input => input?.link != null) && !(node.outputs || []).some(output => output?.links?.length); + if (app.graph !== graph || !loose) { + showWorkbenchToast(t('materialUndoChanged')); + return; + } + graph.remove(node); + graph.setDirtyCanvas?.(true, true); + recordCanvasStep(app); + }); +} + +/** + * Lets `element`, the card of `card` ({ id, title, content, role }), be dragged onto the + * canvas; `drawer` is the studio drawer the drag passes through. + */ +export function bindPromptCardDrag(element, card, drawer) { + const envelope = envelopeOf(card); + const source = { filename: `card:${card.id}`, name: card.title, kind: 'prompt_plan', envelope }; + bindMaterialDrag(element, null, { + effectAllowed: 'copyMove', + passThrough: event => drawer.contains(event.target), + payload: () => ({ ...source, dragHint: t('promptDragHint'), dragTargetHint: t('promptCardDragCanvas') }), + accepts: (node, data, event) => !promptRefusal(node, event), + targetHint: (node, data, event) => promptDropHint(data, node, promptBoxAt(node, event)), + rejectHint: (node, data, event) => promptRefusal(node, event), + onStart: () => { + prepareTextDrag(source.filename, envelope); + const removeOutlines = outlinePromptBoxes(app.graph); + return () => { clearDropPreview(); removeOutlines(); }; + }, + onMove: (node, data, event) => { + const box = node && promptBoxAt(node, event); + markTargetBox(box); + previewPromptDrop(data, node, box); + }, + drop: (node, data, graph, event) => { + if (app.graph !== graph) throw new Error('materialTargetChanged'); + const box = promptBoxAt(node, event); + if (!box) throw new Error('promptDropPickBox'); + fillBox(node, box, envelope); + }, + dropOnCanvas: async (event, data, graph, position) => { + if (app.graph !== graph || !position) throw new Error('materialTargetChanged'); + addPromptNode(graph, position, card); + }, + }); +} diff --git a/web/modules/prompt_drop.js b/web/modules/prompt_drop.js index 69dc118..a102d77 100644 --- a/web/modules/prompt_drop.js +++ b/web/modules/prompt_drop.js @@ -33,6 +33,11 @@ export function prepareMaterialDrag(material) { .catch(() => {}); // hints stay general; the drop fetches again and reports failures } +/** A drag whose text is known already (a Prompt Studio card, keyed `key`). */ +export function prepareTextDrag(key, envelope) { + dragged = { filename: key, envelope, payload: null }; +} + const known = material => (dragged.filename === material.filename ? dragged : null); const brief = (value) => { diff --git a/web/modules/ui_prompt_card_popover.js b/web/modules/ui_prompt_card_popover.js new file mode 100644 index 0000000..9ea0f58 --- /dev/null +++ b/web/modules/ui_prompt_card_popover.js @@ -0,0 +1,189 @@ +/** + * The preview beside a Prompt Studio library card (ui_prompt_source_deck.js): the card's + * category, role and full text, with Copy, Pin (keeps it open), Add to the board, and for + * a saved prompt Rename and Delete. It opens on hover and stays while the pointer is on the + * card, the preview or the corridor between them; the deck's view scope releases it. + */ + +import { translate as t } from './locales.js'; +import { CATEGORY_META } from './prompt_studio_data.js'; + +const OPEN_DELAY = 100; +const HIDE_DELAY = 200; +const HIDE_FAST = 90; +const WIDTH = 320; // keep in step with .anomalous-card-preview-popover + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function button(className, label, title, onClick) { + const node = el('button', className, label); + node.type = 'button'; + node.title = title; + node.onclick = (event) => { + event.stopPropagation(); + onClick(); + }; + return node; +} + +const within = (rect, x, y, pad) => x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad; + +/** + * `drawer`: the studio drawer (its dock side places the preview); `scope`: the deck's view + * scope; `onAdd(card)`, `onRename(card)`, `onDelete(card)`: the preview's actions. + * Returns { hover(card, anchor), leave(), hide(force), isPinned() }. + */ +export function createCardPopover({ drawer, scope, onAdd, onRename, onDelete }) { + let popover = null; + let anchor = null; + let pinned = false; + let openTimer = null; + let hideTimer = null; + let pointer = { x: 0, y: 0 }; + + const dockLeft = () => drawer?.classList.contains('is-dock-left') ?? true; + + /** On the card, on the preview, or crossing the gap between them. */ + function inSafeZone(x, y) { + if (!popover?.isConnected || !anchor?.isConnected) return false; + const pop = popover.getBoundingClientRect(); + const card = anchor.getBoundingClientRect(); + if (within(card, x, y, 4) || within(pop, x, y, 6)) return true; + const [left, right] = dockLeft() ? [card.right - 6, pop.left + 6] : [pop.right - 6, card.left + 6]; + const top = Math.min(card.top, pop.top) - 10; + const bottom = Math.max(card.bottom, pop.bottom) + 10; + return x >= left && x <= right && y >= top && y <= bottom; + } + + function hide(force = false) { + if (pinned && !force) return; + clearTimeout(hideTimer); + clearTimeout(openTimer); + anchor?.classList.remove('is-preview-active'); + anchor = null; + popover?.remove(); + popover = null; + pinned = false; + } + + function scheduleHide(fast = false) { + if (pinned) return; + clearTimeout(hideTimer); + hideTimer = setTimeout(() => { + if (!inSafeZone(pointer.x, pointer.y)) hide(); + }, fast ? HIDE_FAST : HIDE_DELAY); + } + + function header(card) { + const head = el('div', 'anomalous-popover-header'); + const top = el('div', 'anomalous-popover-top'); + const tags = el('div', 'anomalous-popover-tags'); + const meta = CATEGORY_META[card.category] || CATEGORY_META.subject; + const category = el('span', 'anomalous-popover-cat', window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en); + Object.assign(category.style, { color: meta.color, background: meta.bg, borderColor: meta.border }); // the category's own colours + tags.append(category, el('span', `anomalous-popover-role is-${card.role}`, t(card.role === 'negative' ? 'promptCardNegative' : 'promptCardPositive'))); + + const actions = el('div', 'anomalous-popover-top-actions'); + const copy = button('anomalous-popover-copy-btn', `📋 ${t('promptCardCopy')}`, t('promptCardCopyHint'), () => { + navigator.clipboard.writeText(card.content).then(() => { + copy.textContent = `✅ ${t('promptCardCopied')}`; + setTimeout(() => { if (copy.isConnected) copy.textContent = `📋 ${t('promptCardCopy')}`; }, 1200); + }); + }); + const close = button('anomalous-popover-close-btn', '✕', t('promptCardClose'), () => hide(true)); + close.hidden = true; + const pin = button('anomalous-popover-pin-btn', `📌 ${t('promptCardPin')}`, t('promptCardPinHint'), () => { + pinned = !pinned; + popover.classList.toggle('is-pinned', pinned); + pin.classList.toggle('is-active', pinned); + pin.textContent = `📌 ${t(pinned ? 'promptCardPinned' : 'promptCardPin')}`; + close.hidden = !pinned; + }); + actions.append(copy, pin, close); + top.append(tags, actions); + head.append(top, el('div', 'anomalous-popover-title', card.title)); + if (card.sourceKind === 'material') head.append(el('div', 'anomalous-source-origin', t('promptLibrarySource'))); + return head; + } + + function footer(card) { + const foot = el('div', 'anomalous-popover-footer'); + foot.append(el('span', 'anomalous-popover-hint', t('promptCardAddHint'))); + const actions = el('div', 'anomalous-popover-footer-actions'); + if (card.sourceKind === 'material') { + actions.append( + button('anomalous-popover-text-btn', t('promptCardRename'), t('promptCardRenameHint'), () => { hide(true); onRename(card); }), + button('anomalous-popover-text-btn is-danger', t('promptCardDelete'), t('promptCardDeleteHint'), () => { hide(true); onDelete(card); }), + ); + } + actions.append(button('anomalous-popover-add-btn', `+ ${t('promptCardAdd')}`, t('promptCardAddTitle'), () => { onAdd(card); hide(true); })); + foot.append(actions); + return foot; + } + + function show(card, anchorEl) { + clearTimeout(hideTimer); + clearTimeout(openTimer); + if (!anchorEl?.isConnected || pinned) return; + if (anchor && anchor !== anchorEl) anchor.classList.remove('is-preview-active'); + anchor = anchorEl; + anchor.classList.add('is-preview-active'); + popover?.remove(); + popover = el('div', 'anomalous-card-preview-popover'); + popover.card = card; + popover.onmouseenter = (event) => { + pointer = { x: event.clientX, y: event.clientY }; + clearTimeout(hideTimer); + clearTimeout(openTimer); + }; + popover.onmouseleave = (event) => { + pointer = { x: event.clientX, y: event.clientY }; + scheduleHide(); + }; + const body = el('div', 'anomalous-popover-body'); + body.append(el('pre', 'anomalous-popover-snippet', card.content)); + popover.append(header(card), body, footer(card)); + document.body.append(popover); + + const rect = anchorEl.getBoundingClientRect(); + const height = popover.offsetHeight || 160; + popover.style.top = `${Math.max(12, Math.min(window.innerHeight - height - 12, rect.top - 6))}px`; + popover.classList.add(dockLeft() ? 'is-dock-left' : 'is-dock-right'); + popover.style.left = dockLeft() ? `${rect.right + 6}px` : `${Math.max(12, rect.left - WIDTH - 6)}px`; + } + + scope.listen(window, 'pointermove', (event) => { + pointer = { x: event.clientX, y: event.clientY }; + if (!popover || pinned) return; + if (inSafeZone(event.clientX, event.clientY)) clearTimeout(hideTimer); + else scheduleHide(true); + }, { passive: true }); + scope.listen(document, 'pointerdown', (event) => { + if (popover && !popover.contains(event.target) && !anchor?.contains(event.target)) hide(true); + }); + scope.onDispose(() => hide(true)); + + return { + /** The pointer entered `anchorEl`, the card of `card`. */ + hover(card, anchorEl, event) { + pointer = { x: event.clientX, y: event.clientY }; + clearTimeout(hideTimer); + clearTimeout(openTimer); + if (pinned || popover?.card === card) return; + openTimer = setTimeout(() => show(card, anchorEl), OPEN_DELAY); + }, + /** The pointer left a card or blank space of the list. */ + leave(event, fast = false) { + if (event) pointer = { x: event.clientX, y: event.clientY }; + clearTimeout(openTimer); + if (popover) scheduleHide(fast); + }, + hide, + isPinned: () => pinned, + }; +} diff --git a/web/modules/ui_prompt_source_deck.js b/web/modules/ui_prompt_source_deck.js index f24a4e4..be5990d 100644 --- a/web/modules/ui_prompt_source_deck.js +++ b/web/modules/ui_prompt_source_deck.js @@ -2,11 +2,13 @@ import { app } from '../../../scripts/app.js'; import { translate as t } from './locales.js'; import { text, jsonResponse } from './ui_dom.js'; import { materialNodeHeading } from './material_inspector.js'; -import { anomalousAlert } from './ui_dialog.js'; +import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js'; import { selectedMaterialNode } from './node_material_actions.js'; import { promptBoxes } from './prompt_boxes.js'; import { categorizePromptSnippet } from './prompt_composition.js'; import { CATEGORY_META, STARTER_SOURCE_PROMPTS } from './prompt_studio_data.js'; +import { createCardPopover } from './ui_prompt_card_popover.js'; +import { bindPromptCardDrag } from './prompt_card_drag.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; import { loadPromptSourceCards, mergePromptSourceCards } from './prompt_material_source.js'; import { translatePromptText } from './translation_service.js'; @@ -16,114 +18,13 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa let sourceFilterCategory = 'all'; let sourceFilterKeyword = ''; let isCreatingNewCard = false; - let activeCardPreviewPopover = null; - let activeCardAnchorEl = null; - let hidePopoverTimer = null; - let openPopoverTimer = null; - let isPopoverPinned = false; - - let lastPointerX = 0; - let lastPointerY = 0; - - function isPointInSafeZone(clientX, clientY) { - if (!activeCardPreviewPopover?.isConnected || !activeCardAnchorEl?.isConnected) return false; - const popRect = activeCardPreviewPopover.getBoundingClientRect(); - const cardRect = activeCardAnchorEl.getBoundingClientRect(); - - // 1. Inside anchor card with 4px buffer - if ( - clientX >= cardRect.left - 4 && - clientX <= cardRect.right + 4 && - clientY >= cardRect.top - 4 && - clientY <= cardRect.bottom + 4 - ) { - return true; - } - - // 2. Inside popover with 6px buffer - if ( - clientX >= popRect.left - 6 && - clientX <= popRect.right + 6 && - clientY >= popRect.top - 6 && - clientY <= popRect.bottom + 6 - ) { - return true; - } - - // 3. Narrow bridging corridor strictly between card and popover - const isDockLeft = drawer?.classList.contains('is-dock-left') ?? true; - let inBridgeX = false; - if (isDockLeft) { - // Card is on the left, Popover is on the right - const bridgeLeft = cardRect.right - 6; - const bridgeRight = popRect.left + 6; - inBridgeX = clientX >= bridgeLeft && clientX <= bridgeRight; - } else { - // Card is on the right, Popover is on the left - const bridgeLeft = popRect.right - 6; - const bridgeRight = cardRect.left + 6; - inBridgeX = clientX >= bridgeLeft && clientX <= bridgeRight; - } - - if (inBridgeX) { - const minY = Math.min(cardRect.top, popRect.top) - 10; - const maxY = Math.max(cardRect.bottom, popRect.bottom) + 10; - if (clientY >= minY && clientY <= maxY) { - return true; - } - } - - return false; - } - - function scheduleHidePopover(fast = false) { - if (isPopoverPinned) return; - clearTimeout(hidePopoverTimer); - const delay = fast ? 90 : 200; - hidePopoverTimer = setTimeout(() => { - if (isPointInSafeZone(lastPointerX, lastPointerY)) { - return; - } - hideCardPreviewPopover(); - }, delay); - } - - function cancelHidePopover() { - clearTimeout(hidePopoverTimer); - } - - function hideCardPreviewPopover(force = false) { - if (isPopoverPinned && !force) return; - clearTimeout(hidePopoverTimer); - clearTimeout(openPopoverTimer); - activeCardAnchorEl?.classList.remove('is-preview-active'); - activeCardAnchorEl = null; - activeCardPreviewPopover?.remove(); - activeCardPreviewPopover = null; - isPopoverPinned = false; - } - scope.onDispose(() => hideCardPreviewPopover(true)); - - const onPointerMove = (e) => { - lastPointerX = e.clientX; - lastPointerY = e.clientY; - if (!activeCardPreviewPopover || isPopoverPinned) return; - - if (isPointInSafeZone(e.clientX, e.clientY)) { - cancelHidePopover(); - } else { - scheduleHidePopover(true); - } - }; - scope.listen(window, 'pointermove', onPointerMove, { passive: true }); - - const onDocPointerDown = (e) => { - if (!activeCardPreviewPopover) return; - if (!activeCardPreviewPopover.contains(e.target) && !activeCardAnchorEl?.contains(e.target)) { - hideCardPreviewPopover(true); - } - }; - scope.listen(document, 'pointerdown', onDocPointerDown); + const popover = createCardPopover({ + drawer, + scope, + onAdd: card => addSourceCardToMixer(card), + onRename: card => void renameLibraryCard(card), + onDelete: card => void deleteLibraryCard(card), + }); const leftPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-left-panel'); const leftHeader = text(leftPanel, 'div', '', 'anomalous-workbench-col-header'); const leftTitleWrap = text(leftHeader, 'div', '', 'anomalous-workbench-col-title'); @@ -175,27 +76,51 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa // Source Cards List const sourceCardsList = text(leftPanel, 'div', '', 'anomalous-source-cards-list'); - // Dismiss preview popover when pointer is on blank space of the list or panel + // Blank space of the list or panel lets the preview go. sourceCardsList.addEventListener('pointermove', (e) => { - if (isPopoverPinned || !activeCardPreviewPopover) return; - if (e.target === sourceCardsList) { - scheduleHidePopover(true); - } + if (e.target === sourceCardsList) popover.leave(e, true); }, { passive: true }); - sourceCardsList.addEventListener('pointerdown', (e) => { - if (e.target === sourceCardsList) { - hideCardPreviewPopover(true); - } + if (e.target === sourceCardsList) popover.hide(true); }); - leftPanel.addEventListener('pointermove', (e) => { - if (isPopoverPinned || !activeCardPreviewPopover) return; - if (e.target === leftPanel || e.target === leftFilterBar || e.target === leftCategoryPills) { - scheduleHidePopover(true); - } + if ([leftPanel, leftFilterBar, leftCategoryPills].includes(e.target)) popover.leave(e, true); }, { passive: true }); + /** Renames a saved prompt (both of its cards); its tags stay. */ + async function renameLibraryCard(card) { + const name = String(await anomalousPrompt(t('promptCardRenameAsk'), card.title, t('promptCardRename')) || '').trim(); + if (!name || name === card.title) return; + try { + const detail = await jsonResponse(await fetch(`/anomalous/material_full?include_workflow=0&filename=${encodeURIComponent(card.filename)}`), 'read prompt'); + const response = await fetch('/anomalous/update_material', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: card.filename, name, tags: detail.data?.tags || [] }), + }); + await jsonResponse(response, 'rename prompt'); + await syncMaterialsIntoSourceDeck(); + } catch (error) { + await anomalousAlert(t('promptCardRenameFailed')); + } + } + + /** Deletes a saved prompt (both of its cards) to the Recycle Bin, after asking. */ + async function deleteLibraryCard(card) { + if (!await anomalousConfirm(t('promptCardDeleteConfirm', { name: card.title }), t('promptCardDelete'), { okLabel: t('promptCardDelete') })) return; + try { + const response = await fetch('/anomalous/delete_material', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: card.filename }), + }); + await jsonResponse(response, 'delete prompt'); + await syncMaterialsIntoSourceDeck(); + } catch (error) { + await anomalousAlert(t('promptCardDeleteFailed')); + } + } + // Extract prompts from selected canvas node function extractPromptsFromSelectedNode(intoRightMixer = false) { const node = selectedMaterialNode(app); @@ -462,188 +387,10 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa renderNewCardFormUI(); }; - function showCardPreviewPopover(card, anchorEl) { - cancelHidePopover(); - clearTimeout(openPopoverTimer); - if (!anchorEl?.isConnected) return; - if (isPopoverPinned) return; - - if (activeCardAnchorEl && activeCardAnchorEl !== anchorEl) { - activeCardAnchorEl.classList.remove('is-preview-active'); - } - activeCardAnchorEl = anchorEl; - anchorEl.classList.add('is-preview-active'); - - if (activeCardPreviewPopover) { - activeCardPreviewPopover.remove(); - activeCardPreviewPopover = null; - } - - const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject; - const popover = document.createElement('div'); - popover.className = 'anomalous-card-preview-popover'; - popover.__card = card; - popover.__anchorEl = anchorEl; - - popover.onmouseenter = (e) => { - lastPointerX = e.clientX; - lastPointerY = e.clientY; - cancelHidePopover(); - clearTimeout(openPopoverTimer); - }; - popover.onmouseleave = (e) => { - lastPointerX = e.clientX; - lastPointerY = e.clientY; - scheduleHidePopover(); - }; - - const header = document.createElement('div'); - header.className = 'anomalous-popover-header'; - - const topRow = document.createElement('div'); - topRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:6px;'; - - const tags = document.createElement('div'); - tags.className = 'anomalous-popover-tags'; - - const catBadge = document.createElement('span'); - catBadge.className = 'anomalous-popover-cat'; - catBadge.style.color = catMeta.color; - catBadge.style.background = catMeta.bg; - catBadge.style.borderColor = catMeta.border; - catBadge.textContent = window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en; - tags.appendChild(catBadge); - - const roleBadge = document.createElement('span'); - roleBadge.className = `anomalous-popover-role is-${card.role}`; - roleBadge.textContent = card.role === 'negative' - ? (window.anomalous_browser_lang === 'zh' ? '⊖ 负向' : '⊖ Negative') - : (window.anomalous_browser_lang === 'zh' ? '⊕ 正向' : '⊕ Positive'); - tags.appendChild(roleBadge); - topRow.appendChild(tags); - - const topActions = document.createElement('div'); - topActions.style.cssText = 'display:flex;align-items:center;gap:4px;'; - - const copyBtn = document.createElement('button'); - copyBtn.type = 'button'; - copyBtn.className = 'anomalous-popover-copy-btn'; - copyBtn.innerHTML = '📋 ' + (window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'); - copyBtn.title = window.anomalous_browser_lang === 'zh' ? '复制提示词到剪贴板' : 'Copy prompt text'; - copyBtn.onclick = (e) => { - e.stopPropagation(); - navigator.clipboard.writeText(card.content).then(() => { - copyBtn.innerHTML = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已复制' : 'Copied'); - setTimeout(() => { - if (copyBtn.isConnected) copyBtn.innerHTML = '📋 ' + (window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'); - }, 1200); - }); - }; - topActions.appendChild(copyBtn); - - const pinBtn = document.createElement('button'); - pinBtn.type = 'button'; - pinBtn.className = 'anomalous-popover-pin-btn'; - pinBtn.innerHTML = '📌 ' + (window.anomalous_browser_lang === 'zh' ? '固定' : 'Pin'); - pinBtn.title = window.anomalous_browser_lang === 'zh' ? '固定浮窗防止移动时自动关闭' : 'Pin preview to keep open'; - pinBtn.onclick = (e) => { - e.stopPropagation(); - isPopoverPinned = !isPopoverPinned; - popover.classList.toggle('is-pinned', isPopoverPinned); - pinBtn.classList.toggle('is-active', isPopoverPinned); - pinBtn.innerHTML = isPopoverPinned - ? '📌 ' + (window.anomalous_browser_lang === 'zh' ? '已固定' : 'Pinned') - : '📌 ' + (window.anomalous_browser_lang === 'zh' ? '固定' : 'Pin'); - closeBtn.style.display = isPopoverPinned ? 'inline-flex' : 'none'; - }; - topActions.appendChild(pinBtn); - - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'anomalous-popover-close-btn'; - closeBtn.innerHTML = '✕'; - closeBtn.title = window.anomalous_browser_lang === 'zh' ? '关闭浮窗' : 'Close popover'; - closeBtn.style.display = 'none'; - closeBtn.onclick = (e) => { - e.stopPropagation(); - hideCardPreviewPopover(true); - }; - topActions.appendChild(closeBtn); - - topRow.appendChild(topActions); - header.appendChild(topRow); - - const titleEl = document.createElement('div'); - titleEl.className = 'anomalous-popover-title'; - titleEl.textContent = card.title; - header.appendChild(titleEl); - - popover.appendChild(header); - - if (card.sourceKind === 'material') { - text(header, 'div', t('promptLibrarySource', { name: card.title, filename: card.filename }), 'anomalous-source-origin'); - text(header, 'div', t('promptLibraryReadOnly'), 'anomalous-source-origin'); - } - - const body = document.createElement('div'); - body.className = 'anomalous-popover-body'; - const snippet = document.createElement('pre'); - snippet.className = 'anomalous-popover-snippet'; - snippet.textContent = card.content; - body.appendChild(snippet); - popover.appendChild(body); - - const footer = document.createElement('div'); - footer.className = 'anomalous-popover-footer'; - footer.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;'; - - const hint = document.createElement('span'); - hint.textContent = window.anomalous_browser_lang === 'zh' - ? '💡 点击卡片直接添加' - : '💡 Click card to add'; - footer.appendChild(hint); - - const addBtn = document.createElement('button'); - addBtn.type = 'button'; - addBtn.className = 'anomalous-popover-add-btn'; - addBtn.innerHTML = '+ ' + (window.anomalous_browser_lang === 'zh' ? '加入台' : 'Add'); - addBtn.title = window.anomalous_browser_lang === 'zh' ? '将词卡加入右侧拼装台' : 'Add card to track'; - addBtn.onclick = (e) => { - e.stopPropagation(); - addSourceCardToMixer(card); - hideCardPreviewPopover(true); - }; - footer.appendChild(addBtn); - - popover.appendChild(footer); - - document.body.appendChild(popover); - activeCardPreviewPopover = popover; - - // Smart Positioning - const rect = anchorEl.getBoundingClientRect(); - const isDockLeft = drawer?.classList.contains('is-dock-left') ?? true; - const popoverHeight = popover.offsetHeight || 160; - const top = Math.max(12, Math.min(window.innerHeight - popoverHeight - 12, rect.top - 6)); - popover.style.top = `${top}px`; - - if (isDockLeft) { - popover.classList.add('is-dock-left'); - popover.style.left = `${rect.right + 6}px`; - } else { - popover.classList.add('is-dock-right'); - popover.style.left = `${Math.max(12, rect.left - 326)}px`; - } - } - function renderSourceCardsList() { - hideCardPreviewPopover(true); + popover.hide(true); sourceCardsList.replaceChildren(); - sourceCardsList.onscroll = () => { - if (!isPopoverPinned) { - hideCardPreviewPopover(true); - } - }; + sourceCardsList.onscroll = () => popover.hide(); const filtered = sourceCards.filter(card => { if (sourceFilterCategory !== 'all' && card.category !== sourceFilterCategory) return false; @@ -671,31 +418,13 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa const cardEl = text(sourceCardsList, 'div', '', `anomalous-source-card-compact is-cat-${card.category} is-role-${card.role}`); cardEl.setAttribute('draggable', 'true'); - // Custom Eye-Catching Hover Preview Popover (replaces native OS browser title tooltip) - cardEl.onmouseenter = (e) => { - lastPointerX = e.clientX; - lastPointerY = e.clientY; - cancelHidePopover(); - clearTimeout(openPopoverTimer); - if (activeCardPreviewPopover && activeCardPreviewPopover.__card === card) { - return; - } - if (isPopoverPinned) return; + // The preview beside the card (ui_prompt_card_popover.js) replaces the browser's tooltip. + cardEl.onmouseenter = (e) => popover.hover(card, cardEl, e); + cardEl.onmouseleave = (e) => popover.leave(e); - openPopoverTimer = setTimeout(() => { - showCardPreviewPopover(card, cardEl); - }, 100); - }; - cardEl.onmouseleave = (e) => { - lastPointerX = e.clientX; - lastPointerY = e.clientY; - clearTimeout(openPopoverTimer); - scheduleHidePopover(); - }; - - // Drag Start + // Into the assembly board, or out of the drawer onto the canvas (prompt_card_drag.js). cardEl.ondragstart = (e) => { - hideCardPreviewPopover(true); + popover.hide(true); const payload = { title: card.title, content: card.content, @@ -713,6 +442,7 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa cardEl.classList.remove('is-dragging-source'); workbenchGrid.classList.remove('is-source-dragging'); }; + if (card.content.trim()) bindPromptCardDrag(cardEl, card, drawer); const dot = text(cardEl, 'span', '', 'anomalous-source-card-dot'); dot.style.backgroundColor = catMeta.color; @@ -725,13 +455,13 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa if (card.sourceKind === 'material') { cardEl.dataset.sourceFilename = card.filename; const origin = text(nameWrap, 'span', t('promptLibraryBadge'), 'anomalous-source-origin'); - origin.title = t('promptLibrarySource', { name: card.title, filename: card.filename }) + '\n' + t('promptLibraryReadOnly'); + origin.title = t('promptLibrarySource'); } const addIcon = text(cardEl, 'span', '+', 'anomalous-source-card-add-icon'); cardEl.onclick = () => { - hideCardPreviewPopover(true); + popover.hide(true); addSourceCardToMixer(card); }; }); @@ -744,7 +474,7 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa extractSelected: extractPromptsFromSelectedNode, refresh: renderSourceCardsList, sync: syncMaterialsIntoSourceDeck, - hidePreview: (force = false) => hideCardPreviewPopover(force), + hidePreview: (force = false) => popover.hide(force), focusSearch: () => leftSearch.focus(), }; } diff --git a/web/styles.css b/web/styles.css index 0569bfa..52fbd44 100644 --- a/web/styles.css +++ b/web/styles.css @@ -7,7 +7,7 @@ @import url("./styles/06-material-workbench.css?v=20261003-keep-1"); @import url("./styles/07-prompt-studio.css?v=20261002-theme-1"); @import url("./styles/08-theme-gallery-overrides.css?v=20261003-recipe-slim-1"); -@import url("./styles/09-prompt-drawers.css?v=20261002-theme-1"); +@import url("./styles/09-prompt-drawers.css?v=20261003-cards-1"); @import url("./styles/10-model-sources.css?v=20261002-sources-1"); @import url("./styles/11-audio-studio.css?v=20261002-voiceover-2"); @import url("./styles/12-tts-setup.css?v=20261002-theme-1"); diff --git a/web/styles/09-prompt-drawers.css b/web/styles/09-prompt-drawers.css index e4ccde2..90f0d1f 100644 --- a/web/styles/09-prompt-drawers.css +++ b/web/styles/09-prompt-drawers.css @@ -538,10 +538,52 @@ color: var(--amb-text-muted); display: flex; align-items: center; + justify-content: space-between; + gap: 8px; background: rgba(0, 0, 0, 0.25); border-top: 1px solid var(--amb-border); } +.anomalous-popover-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} + +.anomalous-popover-top-actions, +.anomalous-popover-footer-actions { + display: flex; + flex: none; + align-items: center; + gap: 4px; +} + +.anomalous-popover-hint { + min-width: 0; +} + +/* Rename / Delete on a saved prompt's preview (ui_prompt_card_popover.js). */ +.anomalous-popover-text-btn { + padding: 2px 8px; + border: 1px solid var(--amb-border-strong); + border-radius: 4px; + color: var(--amb-text-soft); + background: transparent; + font-size: 0.7rem; + cursor: pointer; +} + +.anomalous-popover-text-btn:hover { + border-color: var(--amb-link); + color: var(--amb-text-main); +} + +.anomalous-popover-text-btn.is-danger:hover { + border-color: rgba(239, 68, 68, 0.5); + color: #fca5a5; +} + /* Assembly Dropzone Notice */ .anomalous-assembly-dropzone { display: flex;