Files
DemonGatanjieuandClaude Opus 5.5 653386ad6e Prompt Studio: a clicked card opens as a reference to take single tags
A user asked to pick tags from a saved prompt instead of pouring the
whole card in. A clicked card now opens between the cards and the prompt
being edited (ui_prompt_reference.js): a click on one of its tags adds
that tag, Add all the whole card, ticks show what the box has. The
preview's Add and dragging a card work as before; beside the canvas the
drawer widens by the column while it is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:31:20 +08:00

419 lines
18 KiB
JavaScript
Raw Permalink 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.
/**
* One prompt box in Prompt Studio (ui_prompt_target.js makes one per box of the selected node,
* or two for the draft). Its text shows as tags (prompt_tags.js) or as the whole text; with a
* meaning language picked, each tag has its meaning under it (prompt_gloss.js). A tag is clicked to
* select it (weight − / +, edit, remove; Delete and + / − on the keyboard), double-clicked to
* edit, dragged to move. Cards dropped on the box go in where they land, typed text at the end;
* text that is not English is translated to English first, and tags the box has already are
* not added twice.
* Every change goes through `write`, which the target turns into a node write or a draft edit.
*/
import { translate as t } from './locales.js';
import { bindPromptCardDrag } from './prompt_card_drag.js';
import { fetchGlosses, glossOf } from './prompt_gloss.js';
import { insertTags, moveTag, promptTagsOf, removeTag, replaceTag, splitPrompt, tagWeight, withWeight } from './prompt_tags.js';
import { needsEnglish, translatePromptText } from './translation_service.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
const TAG_TYPE = 'application/x-anomalous-prompt-tag';
const CARD_TYPE = 'application/json';
const ROLE_KEYS = { positive: 'recipePromptRolePositive', negative: 'recipePromptRoleNegative', both: 'recipePromptRoleBoth' };
const WEIGHT_STEP = 0.1;
let nextEditorId = 1;
function el(tag, className, content) {
const node = document.createElement(tag);
if (className) node.className = className;
if (content !== undefined) node.textContent = content;
return node;
}
function button(className, label, title, onClick) {
const node = el('button', className, label);
node.type = 'button';
if (title) node.title = title;
node.onclick = onClick;
return node;
}
// Translators capitalise the first word; prompt tags are lower case.
const asTag = text => (/^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.slice(1) : text);
/**
* `role` and `name` label the box; `getValue()` reads its text and `write(value)` changes it
* (false when it did not). `prefs` is the studio's { view: 'tags' | 'text', glossLang }. `refuse(card)`
* says why a card may not go in ('' when it may); `onSave(value)` keeps the text as a card.
* `drawer` is the studio drawer, for the handle that drags the text onto the canvas.
* Returns { element, role, value(), render(), refresh(), addText(text) }.
*/
export function createPromptBoxEditor({ role, name, getValue, write, prefs, refuse, onSave, drawer, scope }) {
const id = nextEditorId++;
let lastValue = '';
let selected = null;
let editing = false;
let busy = false;
let glossTimer = null;
let glossFailed = ''; // the meaning language the service failed for
let textTimer = null;
scope.onDispose(() => { clearTimeout(glossTimer); clearTimeout(textTimer); });
const element = el('section', `anomalous-ps-box is-${role || 'plain'}`);
const head = el('header', 'anomalous-ps-box-head');
head.append(el('span', `anomalous-ps-role is-${role || 'plain'}`, t(ROLE_KEYS[role] || 'currentNodePromptPlain')));
if (name) head.append(el('span', 'anomalous-ps-box-name', name));
const actions = el('span', 'anomalous-ps-box-actions');
const copy = button('anomalous-ps-mini', t('promptStudioCopy'), '', async () => {
try {
await navigator.clipboard.writeText(getValue());
copy.textContent = t('promptStudioCopied');
setTimeout(() => { if (copy.isConnected) copy.textContent = t('promptStudioCopy'); }, 1200);
} catch {
showWorkbenchToast(t('materialCopyError'));
}
});
const save = button('anomalous-ps-mini', t('promptStudioSave'), t('promptStudioSaveHint'), () => {
if (getValue().trim()) onSave(getValue());
});
// The box's text as a card, for dragging onto the canvas (prompt_card_drag.js reads it at drag start).
const card = { id: `box_${id}`, title: '', content: '', role: role === 'negative' ? 'negative' : 'positive' };
const handle = el('span', 'anomalous-ps-drag', '⠿');
handle.title = t('promptStudioDragOut');
bindPromptCardDrag(handle, card, drawer);
actions.append(copy, save, handle);
head.append(actions);
const foreignBar = el('div', 'anomalous-ps-foreign');
foreignBar.append(el('span', '', t('promptStudioHasForeign')),
button('anomalous-ps-mini is-accent', t('promptStudioToEnglish'), '', () => void translateForeign()));
const tagsEl = el('div', 'anomalous-ps-tags');
const addInput = el('input', 'anomalous-ps-add');
addInput.placeholder = t('promptStudioAddPlaceholder');
addInput.title = t('promptStudioAddHint');
addInput.onkeydown = async (event) => {
if (event.key !== 'Enter' || event.isComposing) return;
event.preventDefault();
if (await addText(addInput.value)) addInput.value = '';
addInput.focus();
};
const tools = el('div', 'anomalous-ps-tools');
const textArea = el('textarea', 'anomalous-ps-text');
textArea.rows = 6;
textArea.oninput = () => {
clearTimeout(textTimer);
textTimer = setTimeout(flushText, 500);
};
textArea.onblur = () => flushText();
element.append(head, foreignBar, tagsEl, tools, textArea);
const toast = message => showWorkbenchToast(message);
function flushText() {
clearTimeout(textTimer);
if (textArea.value === getValue()) return;
if (write(textArea.value) !== false) lastValue = textArea.value;
}
function commit(next) {
if (write(next) !== false) render();
}
/** Runs `change(text)` on the box's text, unless it changed since it was drawn. */
function act(change) {
const now = getValue();
if (now !== lastValue) { render(); return; }
const next = change(now);
if (next !== now) commit(next);
}
function setBusy(on) {
busy = on;
addInput.disabled = on;
addInput.placeholder = t(on ? 'promptStudioTranslating' : 'promptStudioAddPlaceholder');
}
async function toEnglish(text) {
if (!needsEnglish(text)) return text;
setBusy(true);
const result = await translatePromptText(text, { targetLang: 'en', signal: scope.signal });
setBusy(false);
if (scope.signal.aborted || !element.isConnected) return null;
if (!result.ok) { toast(t('promptStudioTranslateFailed')); return null; }
return result.translated;
}
/** Puts `text` (several tags may be in it) in before tag `index`, or at the end. */
async function addText(text, index = null) {
const typed = String(text || '').trim();
if (!typed || busy) return false;
const english = await toEnglish(typed);
if (english === null) return false;
const now = getValue();
const at = now === lastValue ? index : null;
const result = insertTags(now, promptTagsOf(english).map(asTag), at);
if (!result.added) { toast(t('promptStudioAllThere')); return true; }
if (write(result.value) === false) return false;
selected = null;
if (result.skipped) toast(t('promptStudioAddedSkipped', { count: result.added, skipped: result.skipped }));
render();
return true;
}
async function translateForeign() {
if (busy) return;
const now = getValue();
const targets = splitPrompt(now).items.map((item, index) => ({ index, ...tagWeight(item.text) }))
.filter(item => needsEnglish(item.core));
if (!targets.length) return;
setBusy(true);
const results = await Promise.all(targets.map(item => translatePromptText(item.core, { targetLang: 'en', signal: scope.signal })));
setBusy(false);
if (scope.signal.aborted || !element.isConnected) return;
if (getValue() !== now) { toast(t('currentNodeChangedMeanwhile')); render(); return; }
let next = now;
// A translation the box has already (by its words) goes instead of in twice.
const wordsOf = tag => tagWeight(tag).core.trim().toLowerCase();
const have = new Set(splitPrompt(now).items.map(item => wordsOf(item.text)));
// From the last tag back, so a translation that splits into several tags moves nothing before it.
for (let position = targets.length - 1; position >= 0; position--) {
const result = results[position];
if (!result.ok || !result.translated) continue;
const english = withWeight(asTag(result.translated), targets[position].weight);
if (have.has(wordsOf(english))) next = removeTag(next, targets[position].index);
else {
have.add(wordsOf(english));
next = replaceTag(next, targets[position].index, english);
}
}
if (results.some(result => !result.ok)) toast(t('promptStudioTranslateFailed'));
if (next !== now) commit(next);
}
function weigh(index, step) {
act(value => {
const tag = splitPrompt(value).items[index]?.text;
return tag ? replaceTag(value, index, withWeight(tag, tagWeight(tag).weight + step)) : value;
});
}
function remove(index) {
// The selection stays on its tag.
if (selected === index) selected = null;
else if (selected !== null && selected > index) selected--;
act(value => removeTag(value, index));
}
function startEdit(index, chip) {
const tag = splitPrompt(getValue()).items[index]?.text;
if (tag === undefined) return;
editing = true;
const input = el('input', 'anomalous-ps-tag-input');
input.value = tag;
input.size = Math.max(6, tag.length);
chip.replaceWith(input);
input.focus();
input.select();
let done = false;
const finish = async (keep) => {
if (done) return;
done = true;
editing = false;
const typed = input.value.trim();
if (!keep || typed === tag) { render(); return; }
const english = await toEnglish(typed);
if (english === null) { render(); return; }
act(value => replaceTag(value, index, asTag(english)));
render();
};
input.onkeydown = (event) => {
if (event.isComposing) return;
if (event.key === 'Enter') { event.preventDefault(); void finish(true); }
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); void finish(false); }
};
input.onblur = () => void finish(true);
}
function glossText(tag) {
const meaning = glossOf(tag, prefs.glossLang);
return meaning === undefined ? (glossFailed === prefs.glossLang ? '' : '…') : meaning;
}
function showGlosses() {
for (const span of tagsEl.querySelectorAll('.anomalous-ps-tag-gloss')) span.textContent = glossText(span.dataset.tag);
}
function wantGlosses(tags) {
clearTimeout(glossTimer);
const lang = prefs.glossLang;
if (!lang || glossFailed === lang || tags.every(tag => glossOf(tag, lang) !== undefined)) return;
glossTimer = setTimeout(async () => {
const ok = await fetchGlosses(tags, lang, scope.signal);
if (scope.signal.aborted || !element.isConnected) return;
if (!ok) glossFailed = lang;
showGlosses();
}, 250);
}
function chipFor(item, index) {
const { core, weight } = tagWeight(item.text);
const chip = el('span', 'anomalous-ps-tag');
chip.classList.toggle('is-selected', index === selected);
chip.classList.toggle('is-foreign', needsEnglish(core));
chip.classList.toggle('is-up', weight > 1);
chip.classList.toggle('is-down', weight < 1);
chip.draggable = true;
chip.tabIndex = 0;
chip.dataset.index = String(index);
chip.title = t('promptStudioTagHint');
const main = el('span', 'anomalous-ps-tag-main');
main.append(el('span', 'anomalous-ps-tag-text', core));
if (weight !== 1) main.append(el('span', 'anomalous-ps-tag-weight', String(weight)));
main.append(button('anomalous-ps-tag-x', '×', t('promptStudioRemoveTag'), (event) => {
event.stopPropagation();
remove(index);
}));
chip.append(main);
if (prefs.glossLang) {
const gloss = el('span', 'anomalous-ps-tag-gloss', glossText(item.text));
gloss.dataset.tag = item.text;
chip.append(gloss);
}
chip.onclick = () => {
selected = selected === index ? null : index;
render();
tagsEl.querySelector(`[data-index="${index}"]`)?.focus();
};
chip.ondblclick = (event) => {
event.preventDefault();
startEdit(index, chip);
};
chip.onkeydown = (event) => {
if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); remove(index); }
else if (event.key === '+' || event.key === '=') { event.preventDefault(); weigh(index, WEIGHT_STEP); }
else if (event.key === '-') { event.preventDefault(); weigh(index, -WEIGHT_STEP); }
else if (event.key === 'Enter') { event.preventDefault(); startEdit(index, chip); }
else if (event.key === 'Escape' && selected !== null) { event.stopPropagation(); selected = null; render(); }
};
chip.ondragstart = (event) => {
event.dataTransfer.setData(TAG_TYPE, JSON.stringify({ editor: id, index }));
event.dataTransfer.setData('text/plain', item.text);
event.dataTransfer.effectAllowed = 'move';
chip.classList.add('is-dragging');
};
chip.ondragend = () => chip.classList.remove('is-dragging');
return chip;
}
function renderTools(items) {
tools.replaceChildren();
const item = selected === null ? null : items[selected];
tools.hidden = !item;
if (!item) return;
const index = selected;
const { weight } = tagWeight(item.text);
tools.append(
button('anomalous-ps-mini', '−', t('promptStudioWeightDown'), () => weigh(index, -WEIGHT_STEP)),
el('span', 'anomalous-ps-tools-weight', t('promptStudioWeight', { weight })),
button('anomalous-ps-mini', '+', t('promptStudioWeightUp'), () => weigh(index, WEIGHT_STEP)),
button('anomalous-ps-mini', t('promptStudioEditTag'), '', () => {
const chip = tagsEl.querySelector(`[data-index="${index}"]`);
if (chip) startEdit(index, chip);
}),
button('anomalous-ps-mini is-danger', t('promptStudioRemoveTag'), '', () => remove(index)),
);
}
/** Draws the box from its text. */
function render() {
const value = getValue();
lastValue = value;
card.content = value;
card.title = name || t(ROLE_KEYS[role] || 'currentNodePromptPlain');
foreignBar.hidden = !needsEnglish(value);
const asText = prefs.view === 'text';
element.classList.toggle('is-text-view', asText);
tagsEl.hidden = asText;
textArea.hidden = !asText;
if (asText) {
tools.hidden = true;
if (document.activeElement !== textArea) textArea.value = value;
return;
}
const items = splitPrompt(value).items;
if (selected !== null && selected >= items.length) selected = null;
const focused = document.activeElement === addInput;
tagsEl.replaceChildren(...items.map(chipFor));
if (!items.length) tagsEl.append(el('span', 'anomalous-ps-empty', t('promptStudioEmpty')));
tagsEl.append(addInput);
if (focused) addInput.focus();
renderTools(items);
wantGlosses(items.map(item => item.text));
}
// Where a drop lands: before the tag under or after the pointer, else the end (null).
function dropIndex(event) {
for (const chip of tagsEl.querySelectorAll('.anomalous-ps-tag')) {
const rect = chip.getBoundingClientRect();
if (event.clientY < rect.top || (event.clientY <= rect.bottom && event.clientX < rect.left + rect.width / 2)) return Number(chip.dataset.index);
}
return null;
}
function markDrop(index) {
for (const chip of tagsEl.querySelectorAll('.anomalous-ps-tag')) chip.classList.toggle('is-drop-before', Number(chip.dataset.index) === index);
addInput.classList.toggle('is-drop-here', index === null);
}
function clearDrop() {
element.classList.remove('is-drop-target');
for (const chip of tagsEl.querySelectorAll('.is-drop-before')) chip.classList.remove('is-drop-before');
addInput.classList.remove('is-drop-here');
}
const carries = event => [...(event.dataTransfer?.types || [])].some(type => type === TAG_TYPE || type === CARD_TYPE);
element.addEventListener('dragover', (event) => {
if (!carries(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = event.dataTransfer.types.includes(TAG_TYPE) ? 'move' : 'copy';
element.classList.add('is-drop-target');
if (prefs.view !== 'text') markDrop(dropIndex(event));
});
element.addEventListener('dragleave', (event) => {
if (!element.contains(event.relatedTarget)) clearDrop();
});
element.addEventListener('drop', (event) => {
if (!carries(event)) return;
event.preventDefault();
const at = prefs.view === 'text' ? null : dropIndex(event);
clearDrop();
const tagData = event.dataTransfer.getData(TAG_TYPE);
if (tagData) {
const moved = JSON.parse(tagData);
if (moved.editor !== id) return; // tags move within their own box
selected = null;
act(value => moveTag(value, moved.index, at ?? splitPrompt(value).items.length));
return;
}
let dropped = null;
try { dropped = JSON.parse(event.dataTransfer.getData(CARD_TYPE)); } catch { return; }
if (!dropped?.content) return;
const refusal = refuse(dropped);
if (refusal) toast(refusal);
else void addText(dropped.content, at);
});
return {
element,
role,
value: getValue,
render,
/** Redraws when the text changed elsewhere (not while it is being edited here). */
refresh() {
if (editing || busy || document.activeElement === textArea || getValue() === lastValue) return;
selected = null;
render();
},
addText,
};
}