// 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) ? '' : 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 += '
'; Object.keys(map).forEach(function(k, idx) { h += '
'; h += ''; h += '
'; }); 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], tooltip = f[7]; 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 ''; } function lblSpan() { if (tooltip) return '' + esc(label) + ''; return '' + esc(label) + ''; } if (type === 'checkbox') { return wrap('obj-field obj-check', ' ' + lblSpan()); } 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 = lblSpan() + 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 : ''; }); } 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)) { if (val.length === 0) { delete obj[key]; } else { for (var i = val.length - 1; i >= 0; i--) { if (typeof val[i] === 'object' && val[i] !== null) { ced_cleanOutput(val[i]); if (Object.keys(val[i]).length === 0) val.splice(i, 1); } } if (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 = '
'; h += 'Atk Type'; h += '
'; meleeOpts.forEach(function(t, i) { h += ''; }); h += '
'; h += '
'; h += ''; h += ''; h += '
'; 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; } function ced_renderTagHelp(type) { var h = '
'; if (type === 'color') { h += 'Text fields support color tags: {NN}text{/} where NN is a hex color index (00–FF).'; } else if (type === 'crafting') { h += 'Message fields support template variables:
%i1 = first ingredient name
%i2 = second ingredient name
%n = output item name.
Color tags are also supported: {NN}text{/} where NN is a hex color index (00–FF).'; } else if (type === 'gather') { h += 'Message fields support template variables: {name} = object name.
Color tags are also supported: {NN}text{/} where NN is a hex color index (00–FF).'; } h += '
'; return h; }