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>
419 lines
18 KiB
JavaScript
419 lines
18 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
} |