210 lines
6.9 KiB
JavaScript
210 lines
6.9 KiB
JavaScript
/** Escape for safe text nodes (not HTML injection). */
|
|
function appendText(parent, text) {
|
|
if (!text) return;
|
|
parent.appendChild(document.createTextNode(text));
|
|
}
|
|
|
|
const PLAYER_CHOSE_RE = /^\[Player chose:\s*(.+)\]$/s;
|
|
const RP_TOKEN_RE = /("(?:[^"\\]|\\.)*"|«[^»]*»|'(?:[^'\\]|\\.)*'|\*\*[^*]+\*\*|\*[^*]+\*)/g;
|
|
|
|
const OUTCOME_CLASS = {
|
|
'critical failure': 'outcome-crit-fail',
|
|
failure: 'outcome-fail',
|
|
success: 'outcome-success',
|
|
'critical success': 'outcome-crit-success',
|
|
};
|
|
|
|
export function isPlayerChoiceContent(text) {
|
|
return PLAYER_CHOSE_RE.test((text || '').trim());
|
|
}
|
|
|
|
export function parsePlayerChoice(text) {
|
|
const m = (text || '').trim().match(PLAYER_CHOSE_RE);
|
|
return m ? m[1].trim() : null;
|
|
}
|
|
|
|
function appendPlayerChoice(frag, label) {
|
|
const wrap = document.createElement('span');
|
|
wrap.className = 'rp-choice';
|
|
const tag = document.createElement('span');
|
|
tag.className = 'rp-choice-tag';
|
|
tag.textContent = '🔘 Выбор';
|
|
wrap.appendChild(tag);
|
|
appendText(wrap, ' ');
|
|
const lab = document.createElement('span');
|
|
lab.className = 'rp-choice-label';
|
|
lab.textContent = label;
|
|
wrap.appendChild(lab);
|
|
frag.appendChild(wrap);
|
|
}
|
|
|
|
/**
|
|
* Build DOM fragment for RP formatting (dialogue, action, player choice).
|
|
*/
|
|
export function buildRpFormatFragment(text) {
|
|
const frag = document.createDocumentFragment();
|
|
if (!text) return frag;
|
|
|
|
const trimmed = text.trim();
|
|
const choiceLabel = parsePlayerChoice(trimmed);
|
|
if (choiceLabel !== null) {
|
|
appendPlayerChoice(frag, choiceLabel);
|
|
return frag;
|
|
}
|
|
|
|
let last = 0;
|
|
for (const m of trimmed.matchAll(RP_TOKEN_RE)) {
|
|
const idx = m.index ?? 0;
|
|
if (idx > last) appendText(frag, trimmed.slice(last, idx));
|
|
|
|
const token = m[0];
|
|
if (
|
|
(token.startsWith('"') && token.endsWith('"'))
|
|
|| (token.startsWith('«') && token.endsWith('»'))
|
|
|| (token.startsWith("'") && token.endsWith("'"))
|
|
) {
|
|
const span = document.createElement('span');
|
|
span.className = 'rp-dialogue';
|
|
span.textContent = token;
|
|
frag.appendChild(span);
|
|
} else if (token.startsWith('**') && token.endsWith('**')) {
|
|
const em = document.createElement('em');
|
|
em.className = 'rp-action';
|
|
em.textContent = token.slice(2, -2);
|
|
frag.appendChild(em);
|
|
} else if (token.startsWith('*') && token.endsWith('*')) {
|
|
const em = document.createElement('em');
|
|
em.className = 'rp-action';
|
|
em.textContent = token.slice(1, -1);
|
|
frag.appendChild(em);
|
|
} else {
|
|
appendText(frag, token);
|
|
}
|
|
last = idx + token.length;
|
|
}
|
|
if (last < trimmed.length) appendText(frag, trimmed.slice(last));
|
|
return frag;
|
|
}
|
|
|
|
/**
|
|
* Dice override block on user bubble (intent struck through → resolution).
|
|
*/
|
|
export function buildDiceOverrideFragment(resolution) {
|
|
const { intent_text, resolution_text, roll, outcome } = resolution;
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'dice-user-override';
|
|
|
|
const badge = document.createElement('div');
|
|
badge.className = `dice-user-badge ${OUTCOME_CLASS[outcome] || ''}`;
|
|
badge.textContent = `🎲 ${roll} · ${outcome || 'roll'}`;
|
|
wrap.appendChild(badge);
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'dice-user-row';
|
|
|
|
const struck = document.createElement('span');
|
|
struck.className = 'dice-intent-struck';
|
|
struck.textContent = intent_text || '';
|
|
row.appendChild(struck);
|
|
|
|
const arrow = document.createElement('span');
|
|
arrow.className = 'dice-intent-arrow';
|
|
arrow.textContent = '→';
|
|
row.appendChild(arrow);
|
|
|
|
const resolved = document.createElement('span');
|
|
resolved.className = 'dice-intent-resolved';
|
|
resolved.textContent = resolution_text || '';
|
|
row.appendChild(resolved);
|
|
|
|
wrap.appendChild(row);
|
|
return wrap;
|
|
}
|
|
|
|
export function applyDiceOverrideToBubble(bubble, resolution) {
|
|
if (!resolution?.resolution_text) return;
|
|
bubble.dataset.diceOverride = '1';
|
|
const raw = bubble.dataset.raw ?? '';
|
|
const frag = document.createDocumentFragment();
|
|
if (raw && !isPlayerChoiceContent(raw)) {
|
|
frag.appendChild(buildRpFormatFragment(raw));
|
|
frag.appendChild(document.createElement('br'));
|
|
} else if (raw) {
|
|
frag.appendChild(buildRpFormatFragment(raw));
|
|
frag.appendChild(document.createElement('br'));
|
|
}
|
|
frag.appendChild(buildDiceOverrideFragment(resolution));
|
|
bubble.innerHTML = '';
|
|
bubble.appendChild(frag);
|
|
bubble.classList.add('rp-formatted', 'has-dice-override');
|
|
}
|
|
|
|
export function applyRpFormatToBubble(bubble) {
|
|
if (bubble.dataset.diceOverride === '1' && bubble._diceResolution) {
|
|
applyDiceOverrideToBubble(bubble, bubble._diceResolution);
|
|
return;
|
|
}
|
|
const raw = bubble.dataset.raw ?? bubble.textContent ?? '';
|
|
bubble.dataset.raw = raw;
|
|
bubble.innerHTML = '';
|
|
bubble.appendChild(buildRpFormatFragment(raw));
|
|
bubble.classList.add('rp-formatted');
|
|
}
|
|
|
|
export function applyRpPlainToBubble(bubble) {
|
|
const raw = bubble.dataset.raw ?? bubble.textContent ?? '';
|
|
bubble.dataset.raw = raw;
|
|
bubble.textContent = raw;
|
|
bubble.classList.remove('rp-formatted');
|
|
}
|
|
|
|
export function attachFormatToggle(wrapper, bubble) {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'format-btn';
|
|
btn.textContent = '↩ Текст';
|
|
let showingPlain = false;
|
|
|
|
const syncBtn = () => {
|
|
btn.textContent = showingPlain ? '✨' : '↩ Текст';
|
|
btn.title = showingPlain ? 'Форматировать' : 'Показать оригинал';
|
|
};
|
|
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (showingPlain) {
|
|
if (bubble._diceResolution) {
|
|
applyDiceOverrideToBubble(bubble, bubble._diceResolution);
|
|
} else {
|
|
applyRpFormatToBubble(bubble);
|
|
}
|
|
showingPlain = false;
|
|
} else {
|
|
applyRpPlainToBubble(bubble);
|
|
showingPlain = true;
|
|
}
|
|
syncBtn();
|
|
});
|
|
|
|
syncBtn();
|
|
wrapper.appendChild(btn);
|
|
return btn;
|
|
}
|
|
|
|
export function initBubbleContent(bubble, rawText, { formatted = true, actionResolution = null } = {}) {
|
|
bubble.dataset.raw = rawText ?? '';
|
|
bubble._diceResolution = actionResolution || null;
|
|
if (actionResolution?.resolution_text && formatted) {
|
|
bubble.dataset.diceOverride = '1';
|
|
applyDiceOverrideToBubble(bubble, actionResolution);
|
|
return;
|
|
}
|
|
if (formatted && rawText) {
|
|
applyRpFormatToBubble(bubble);
|
|
} else {
|
|
bubble.textContent = rawText ?? '';
|
|
bubble.classList.remove('rp-formatted', 'has-dice-override');
|
|
}
|
|
}
|