// cardeditor.js — Shared utilities for card-based entity editors.
// Used by mobeditor, objecteditor, itemeditor, and future editors.
// Prefix all exports with ced_ to avoid collisions with editor.js globals.
// Hide any open search-results dropdown when clicking outside a search input.
document.addEventListener('click', function(e) {
if (!e.target.closest('.search-results') && !e.target.closest('.search-input')) {
document.querySelectorAll('.search-results').forEach(function(r) { r.style.display = 'none'; });
}
});
// ---- Path helpers ----
function ced_cardPath(sec) {
return sec.path || '';
}
function ced_getVal(data, sec, key) {
var p = ced_cardPath(sec);
if (sec.isArray) {
return data[p] && data[p][key] !== undefined ? data[p][key] : '';
}
var fp = p ? p + '.' + key : key;
var v = getNested(data, fp);
return v === undefined || v === null ? '' : v;
}
// ---- Field ID helpers ----
function ced_fieldID(sec, key, idx) {
if (idx !== undefined && idx >= 0) return 'f_' + sec.id + '_' + idx + '_' + key.replace(/\./g, '_');
return 'f_' + sec.id + '_' + key.replace(/\./g, '_');
}
function ced_fieldIDSub(sec, subKey, idx, fieldKey) {
return 'f_' + sec.id + '_' + subKey + '_' + idx + '_' + fieldKey;
}
// ---- Value collector ----
function ced_collectFieldValue(el, type) {
if (type === 'checkbox') return el.checked;
if (type === 'select') return el.value;
if (type === 'search') return el.value;
if (type === 'number') { var n = parseFloat(el.value); return isNaN(n) ? 0 : n; }
if (type === 'json') {
var raw = el.value.trim();
if (!raw) return null;
try { return JSON.parse(raw); } catch(e) { return raw; }
}
if (type === 'textarea') return el.value;
return el.value;
}
// ---- Tags rendering (comma-separated string arrays) ----
function ced_renderTags(sec, il, data, getter) {
var sectionRoot = data[ced_cardPath(sec)] || data;
var arr = getter ? getter(sectionRoot, il) : (sectionRoot[il.key] || []);
var str = Array.isArray(arr) ? arr.join(', ') : String(arr || '');
var fid = ced_fieldID(sec, il.key);
return '';
}
// ---- KV rendering (key=value map) ----
function ced_renderKV(sec, il, data, getter) {
var sectionRoot = data[ced_cardPath(sec)] || data;
var map = getter ? getter(sectionRoot, il) : (sectionRoot[il.key] || {});
var h = '
';
h += '
' + esc(il.label) + '';
h += '
';
h += '
';
h += '
';
return h;
}
function ced_addKVRow(cardID, subKey) {
var el = document.querySelector('.obj-kv-list[data-sec="' + cardID + '"][data-key="' + subKey + '"]');
if (!el) return;
var row = document.createElement('div');
row.className = 'obj-kv-row';
row.innerHTML = '';
el.appendChild(row);
}
function ced_buildDynamicSelect(fid, dpAttr, val, dynKey) {
var items = (window._dynOpts && window._dynOpts[dynKey]) ? window._dynOpts[dynKey].slice().sort() : [];
var s = '';
return s;
}
function ced_renderKVSearch(sec, il, data, getter) {
var sectionRoot = data[ced_cardPath(sec)] || data;
var map = getter ? getter(sectionRoot, il) : (sectionRoot[il.key] || {});
var entity = il.searchEntity || 'items';
var h = '';
h += '
' + esc(il.label) + '';
h += '
';
Object.keys(map).forEach(function(k, idx) {
h += '
';
h += '
';
h += '
';
h += '
';
});
h += '
';
h += '
';
h += '
';
return h;
}
function ced_addKVSearchRow(cardID, subKey, entity) {
var el = document.querySelector('.obj-kv-list[data-sec="' + cardID + '"][data-key="' + subKey + '"]');
if (!el) return;
var row = document.createElement('div');
row.className = 'obj-kv-row';
row.innerHTML = '';
el.appendChild(row);
ced_setupSearchFields(row);
}
// ---- Search autocomplete ----
function ced_setupSearchFields(root) {
root = root || document;
root.querySelectorAll('.search-input').forEach(function(input) {
if (input._searchSetup) return;
input._searchSetup = true;
var entityType = input.getAttribute('data-search-type') || 'items';
var results = input.parentElement.querySelector('.search-results');
if (!results) return;
results._targetInput = input;
input.addEventListener('input', function() {
var val = input.value.trim();
if (!val) { results.style.display = 'none'; results.innerHTML = ''; results._selectedIndex = -1; return; }
API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) {
var items = [];
if (entityType === 'items' && data.items) items = data.items;
if (entityType === 'objects' && data.objects) items = data.objects;
if (entityType === 'mobs' && data.mobs) items = data.mobs;
if (entityType === 'drops' && data.drops) items = data.drops;
if (items.length === 0) {
results.style.display = 'block';
results.innerHTML = 'No results
';
return;
}
results.style.display = 'block';
results.innerHTML = items.map(function(r) {
return '' + esc(r.id) + ' ' + esc(r.name || '') + '
';
}).join('');
results._items = items;
results._selectedIndex = -1;
}).catch(function() {
results.style.display = 'none';
});
});
input.addEventListener('focus', function() {
if (input.value.trim()) {
input.dispatchEvent(new Event('input'));
}
});
input.addEventListener('blur', function() {
setTimeout(function() { results.style.display = 'none'; }, 200);
});
input.addEventListener('keydown', function(e) {
if (results.style.display === 'none' || !results.children.length) return;
if (results._selectedIndex === undefined) results._selectedIndex = -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
results._selectedIndex = Math.min(results._selectedIndex + 1, results.children.length - 1);
ced_updateSearchHighlight(results);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
results._selectedIndex = Math.max(results._selectedIndex - 1, 0);
ced_updateSearchHighlight(results);
} else if (e.key === 'Enter') {
if (results._selectedIndex >= 0) {
e.preventDefault();
ced_selectSearchResult(results.children[results._selectedIndex]);
}
} else if (e.key === 'Escape') {
results.style.display = 'none';
results._selectedIndex = -1;
}
});
});
}
function ced_selectSearchResult(el) {
var results = el.parentElement;
var idx = Array.prototype.indexOf.call(results.children, el);
if (results._items && results._items[idx] && results._targetInput) {
results._targetInput.value = results._items[idx].id;
ced_persistSearchToData(results._targetInput, results._items[idx].id);
results._targetInput.dispatchEvent(new Event('input'));
results.style.display = 'none';
results.innerHTML = '';
}
}
function ced_persistSearchToData(input, value) {
var ed = window._editorData;
var dataPath = input.getAttribute('data-field-path');
if (!ed || !dataPath) return;
var m = dataPath.match(/^(.+)\[(\d+)\]\.(.+)$/);
if (m) {
var arr = ced_resolveDataPath(ed, m[1]);
if (arr && Array.isArray(arr)) {
if (!arr[m[2]]) arr[m[2]] = {};
arr[m[2]][m[3]] = value;
}
} else {
var m2 = dataPath.match(/^(.+)\.(.+)$/);
if (m2) {
var obj = ced_resolveDataPath(ed, m2[1]);
if (obj) obj[m2[2]] = value;
} else {
ed[dataPath] = value;
}
}
}
function ced_setPathInData(ed, dataPath, value) {
if (!ed || !dataPath) return;
var m = dataPath.match(/^(.+)\[(\d+)\]\.(.+)$/);
if (m) {
if (!ed[m[1]] || !Array.isArray(ed[m[1]])) return;
if (!ed[m[1]][m[2]]) return;
ed[m[1]][m[2]][m[3]] = value;
} else {
var m2 = dataPath.match(/^(.+)\.(.+)$/);
if (m2) {
if (ed[m2[1]] === undefined || ed[m2[1]] === null) return;
ed[m2[1]][m2[2]] = value;
} else {
ed[dataPath] = value;
}
}
}
function ced_syncDOMToData(ed) {
if (!ed) return;
var inputs = document.querySelectorAll('[data-field-path]');
for (var i = 0; i < inputs.length; i++) {
var el = inputs[i];
var dataPath = el.getAttribute('data-field-path');
if (!dataPath) continue;
var val;
if (el.tagName === 'TEXTAREA') {
val = el.value;
} else if (el.type === 'checkbox') {
val = el.checked;
} else if (el.type === 'number') {
var raw = el.value.trim();
val = raw === '' ? '' : (parseFloat(raw) || 0);
} else if (el.tagName === 'SELECT') {
val = el.value;
} else {
val = el.value;
}
ced_setPathInData(ed, dataPath, val);
}
// Condition & action DOM→data sync lives in renderCurrent()
// (the _ieRenderCurrent path) to avoid stale-DOM overwrites when
// mutation handlers call _ieRenderOnly after modifying in-memory data.
// Do NOT call _ieSyncAll here — it would collect stale DOM and undo
// the mutation before the next render.
}
function ced_resolveDataPath(obj, path) {
return path.split('.').reduce(function(o, k) {
if (o === undefined || o === null) return undefined;
return o[k] !== undefined ? o[k] : undefined;
}, obj);
}
function ced_updateSearchHighlight(results) {
for (var i = 0; i < results.children.length; i++) {
results.children[i].classList.toggle('active', i === results._selectedIndex);
if (i === results._selectedIndex) results.children[i].scrollIntoView({block: 'nearest'});
}
}
// ---- Generic field renderer (used by all card-based editors) ----
function ced_renderField(sec, f, data, idx, subPath, optStyle, cardPathFn, fieldIDFn, getValFn) {
var key = f[0], label = f[1], type = f[2], hint = f[3], wide = f[4], showIf = f[5], searchEntity = f[6];
var fp = subPath ? subPath + '.' + key : key;
var fid = fieldIDFn(sec, fp, idx);
var val;
if (sec.isArray && idx >= 0) {
var arr = data[cardPathFn(sec)] || [];
var item = arr[idx] || {};
val = item[key];
} else if (subPath) {
var sectionRoot = data[cardPathFn(sec)] || {};
var subObj = sectionRoot[subPath] || {};
val = subObj[key];
} else {
val = getValFn(data, sec, key);
}
val = val === undefined || val === null ? '' : val;
var hiddenStyle = '';
if (showIf) {
var sectionData = data[cardPathFn(sec)] || {};
if (!showIf(sectionData)) hiddenStyle = ' style="display:none"';
}
var pfx = cardPathFn(sec);
var dp;
if (idx >= 0) {
dp = pfx + '[' + idx + '].' + key;
} else if (subPath) {
dp = (pfx ? pfx + '.' : '') + subPath + '.' + key;
} else {
dp = (pfx ? pfx + '.' : '') + key;
}
var dpAttr = ' data-field-path="' + escAttr(dp) + '"';
function wrap(cls, inner) {
var w = (wide === 'wide') ? ' obj-wide' : '';
if (wide === 'narrow') w = ' obj-narrow';
var s = optStyle ? ' style="' + optStyle + '"' : '';
return '';
}
if (type === 'checkbox') {
return wrap('obj-field obj-check', ' ' + esc(label) + '');
}
if (type === 'json') {
var jstr = typeof val === 'object' ? JSON.stringify(val) : String(val);
return wrap('obj-field', '' + esc(label) + '');
}
if (type === 'textarea') {
var dstr = typeof val === 'object' ? (Array.isArray(val) ? val.map(function(v){return v.text||'';}).join('\n') : JSON.stringify(val)) : String(val);
return wrap('obj-field', '' + esc(label) + '');
}
if (type === 'select') {
if (hint && hint.indexOf('dyn:') === 0) {
var inner = '' + esc(label) + '' + ced_buildDynamicSelect(fid, dpAttr, val, hint.substring(4));
return wrap('obj-field', inner);
}
var opts = hint ? hint.split('|') : [];
var inner = '' + esc(label) + '';
return wrap('obj-field', inner);
}
if (type === 'color') {
var swatch = '';
return wrap('obj-field color-field', '' + esc(label) + '' + swatch + '
');
}
if (type === 'search') {
var entity = searchEntity || 'items';
var ph = hint ? ' placeholder="' + esc(hint) + '"' : ' placeholder="Search ' + entity + '..."';
return wrap('obj-field obj-search', '' + esc(label) + '');
}
var ph = hint ? ' placeholder="' + esc(hint) + '"' : '';
return wrap('obj-field', '' + esc(label) + '');
}
// ---- Subtable row add/remove ----
function ced_addSubRow(sec, subKey, dataRef, cardPathFn) {
var p = cardPathFn(sec);
var sectionRoot = p ? (dataRef[p] || {}) : dataRef;
var raw = sectionRoot[subKey];
var arr;
if (Array.isArray(raw)) {
arr = raw;
} else if (raw && typeof raw === 'object') {
arr = [raw];
} else {
arr = [];
}
var empty = {};
var st = (sec.subtables || []).find(function(s) { return s.key === subKey; });
if (st) {
st.fields.forEach(function(f) {
empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
});
}
arr.push(empty);
sectionRoot[subKey] = arr;
}
function ced_removeSubRow(sec, subKey, idx, dataRef, cardPathFn) {
var p = cardPathFn(sec);
var sectionRoot = p ? (dataRef[p] || {}) : dataRef;
var raw = sectionRoot[subKey];
var arr;
if (Array.isArray(raw)) {
arr = raw;
} else if (raw && typeof raw === 'object') {
arr = [raw];
} else {
arr = [];
}
arr.splice(idx, 1);
}
// ---- Field save helpers ----
function ced_saveSectionFields(sec, sectionObj, cardPathFn, fieldIDFn) {
var hasValues = sec.always;
sec.fields.forEach(function(f) {
var fid = fieldIDFn(sec, f[0]);
var el = document.getElementById(fid);
if (!el) return;
var val = ced_collectFieldValue(el, f[2]);
if (val !== '' && val !== false && val !== 0 && val !== null) hasValues = true;
sectionObj[f[0]] = val;
});
return hasValues;
}
function ced_saveSubtable(sec, st, sectionObj, cardPathFn, fieldIDSubFn) {
var items = [];
for (var i = 0; ; i++) {
var item = {};
var found = false;
st.fields.forEach(function(f) {
var fid = fieldIDSubFn(sec, st.key, i, f[0]);
var el = document.getElementById(fid);
if (el) found = true;
if (el) item[f[0]] = ced_collectFieldValue(el, f[2]);
});
if (!found) break;
if (Object.keys(item).length > 0) items.push(item);
}
if (st.single) {
if (items.length > 0) { sectionObj[st.key] = items[0]; return true; }
} else {
if (items.length > 0) { sectionObj[st.key] = items; return true; }
}
return false;
}
function ced_saveInlineFields(sec, sectionObj, cardPathFn, fieldIDFn) {
var hasValues = false;
sec.inline.forEach(function(il) {
if (il.type === 'tags') {
var fid = fieldIDFn(sec, il.key);
var el = document.getElementById(fid);
if (el && el.value.trim()) {
sectionObj[il.key] = el.value.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; });
hasValues = true;
}
} else if (il.type === 'kv' || il.type === 'kv_search') {
var kvEl = document.querySelector('.obj-kv-list[data-sec="' + sec.id + '"][data-key="' + il.key + '"]');
if (kvEl) {
var map = {};
kvEl.querySelectorAll('.obj-kv-row').forEach(function(row) {
var keyEl = row.querySelector('.obj-kv-key');
var valEl = row.querySelector('.obj-kv-val');
if (keyEl && keyEl.value.trim() && valEl) {
map[keyEl.value.trim()] = parseFloat(valEl.value) || 0;
}
});
if (Object.keys(map).length > 0) { sectionObj[il.key] = map; hasValues = true; }
}
} else {
var ssec = {};
var sHas = false;
il.fields.forEach(function(f) {
var fp = il.path + '.' + f[0];
var fid = fieldIDFn(sec, fp);
var el = document.getElementById(fid);
if (el) sHas = true;
if (el) ssec[f[0]] = ced_collectFieldValue(el, f[2]);
});
if (sHas) { sectionObj[il.path] = ssec; hasValues = true; }
}
});
return hasValues;
}
// ---- Validation ----
function ced_validateFields(SECTIONS, fieldIDFn, fieldIDSubFn) {
var errors = [];
document.querySelectorAll('.obj-field-error').forEach(function(field) {
field.classList.remove('obj-field-error');
});
document.querySelectorAll('input[id][type="text"], input[id]:not([type])').forEach(function(input) {
var id = input.id;
if (!id) return;
var labelEl = input.closest('.obj-field');
if (!labelEl) return;
if (input.classList.contains('search-input') || input.classList.contains('color-input')) return;
var isNumber = false;
var labelText = '';
var fieldDiv = labelEl.closest('[data-card]');
var cardID = fieldDiv ? fieldDiv.getAttribute('data-card') : '';
var sec = SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec) return;
var baseMatch = id.match(/^f_(\w+)_(\w+)$/);
var subMatch = id.match(/^f_(\w+)_(\w+)_(\d+)_(\w.+)$/);
if (subMatch) {
var subKey = subMatch[2], fieldKey = subMatch[4];
var st = (sec.subtables || []).find(function(s) { return s.key === subKey; });
if (st) {
var nf = st.fields.find(function(f) { return f[0] === fieldKey && f[2] === 'number'; });
if (nf) { isNumber = true; labelText = nf[1]; }
}
} else if (baseMatch) {
var fieldKey = baseMatch[2];
var nf = sec.fields ? sec.fields.find(function(f) {
var fid = fieldIDFn(sec, f[0], -1);
return fid === id && f[2] === 'number';
}) : null;
if (!nf && sec.inline) {
sec.inline.forEach(function(il) {
if (il.fields) {
var inf = il.fields.find(function(f) {
var fid = fieldIDFn(sec, il.path + '.' + f[0], -1);
return fid === id && f[2] === 'number';
});
if (inf) nf = inf;
}
});
}
if (nf) { isNumber = true; labelText = nf[1]; }
}
if (isNumber) {
var rawVal = input.value.trim();
if (rawVal !== '' && !/^-?\d*\.?\d*$/.test(rawVal)) {
labelEl.classList.add('obj-field-error');
input.focus();
input.select();
labelText = labelText || (labelEl.querySelector('span') || {}).textContent || '';
errors.push('Invalid value in "' + esc(labelText) + '": "' + esc(rawVal) + '"');
}
}
});
if (errors.length > 0) {
notify(errors.join('; '), 'error');
return false;
}
return true;
}
// ---- Output cleanup ----
function ced_cleanOutput(obj) {
for (var key in obj) {
var val = obj[key];
if (val === '' || val === null || val === undefined) {
delete obj[key];
} else if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
ced_cleanOutput(val);
if (Object.keys(val).length === 0) delete obj[key];
} else if (Array.isArray(val) && val.length === 0) {
delete obj[key];
}
}
}
function ced_unflattenObj(obj) {
var result = {};
Object.keys(obj).forEach(function(key) {
var parts = key.split('.');
var current = result;
for (var i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) current[parts[i]] = {};
current = current[parts[i]];
}
current[parts[parts.length - 1]] = obj[key];
});
return result;
}
function ced_normalizeAtkTypes(v) {
if (Array.isArray(v)) return v.slice();
if (typeof v === 'string' && v) return [v];
return [];
}
function ced_renderAtkTypeWidget(data, widgetID, selectFnName) {
var combat = data.combat || {};
var types = ced_normalizeAtkTypes(combat.attack_types);
var melee = types.filter(function(t) { return t === 'stab' || t === 'slash' || t === 'crush'; })[0] || 'crush';
var hasRanged = types.indexOf('ranged') >= 0;
var hasScience = types.indexOf('science') >= 0;
var meleeOpts = ['stab','slash','crush'];
var activeIdx = meleeOpts.indexOf(melee);
if (activeIdx < 0) activeIdx = 2;
var h = '';
return h;
}
function ced_atkPillSelect(val, btn) {
var pill = btn.closest('.ced-atktype-pill');
if (!pill) return;
var opts = pill.querySelectorAll('.seg-opt');
var idx = -1;
opts.forEach(function(o, i) {
if (o.getAttribute('data-v') === val) {
o.classList.add('seg-active');
idx = i;
} else {
o.classList.remove('seg-active');
}
});
pill.className = pill.className.replace(/seg-idx-\d/g, 'seg-idx-' + idx);
}
function ced_collectAtkType(widgetID) {
var widget = document.getElementById(widgetID);
if (!widget) return ['crush'];
var active = widget.querySelector('.ced-atktype-pill .seg-opt.seg-active');
var melee = active ? active.getAttribute('data-v') : 'crush';
var out = [melee];
var rng = widget.querySelector('.ced-atktype-ranged');
var sci = widget.querySelector('.ced-atktype-science');
if (rng && rng.checked) out.push('ranged');
if (sci && sci.checked) out.push('science');
return out;
}