Obsidian Portal
Menu
Sign In / Create Account
JavaScript is currently disabled. Obsidian Portal has a lot of really cool features that use JavaScript. You should check them out. We think you'll have a much more enjoyable experience.
Home
Campaigns
Games Nearby
Plans
Community
Help
Resources
Wraith-Shadow (Fumbral)
Author:
neqis
Slug:
mll_shadow
System:
World of Darkness
DST Source Code
HTML Template
<div class="mll_sheet mll_owod mll_wraith mll_shadow"> <template class="item"> <li><label><span class="dsf dsf_{base}_label_{i}"></span></label><span class="dsf dsf_{base}_{i}"></span></li> </template> <div class="page stats"> <section class="basic"> <h2>Shadow</h2> <span class="dsf dsf_avatar_image"></span> <dl> <dt><label>Psyche</label></dt> <dd><span class="dsf dsf_psyche"></span></dd> <dt><label>Archetype</label></dt> <dd><span class="dsf dsf_archetype"></span></dd> <dt>Shadowguide Player</dt> <dd><span class="dsf dsf_player readonly"></span></dd> <dt>Chronicle</dt> <dd><span class="dsf dsf_campaign readonly"></span></dd> </dl> </section> <section class="advantages"> <h3>Advantages</h3> <section class="thorn"> <h3>Thorns</h3> <template class="item"> <li><span class="dsf dsf_thorn_{i}"></span></li> </template> <ul class="udf nopips"> </ul> </section> <section class="dark_passion"> <h3>Dark Passions</h3> <ul class="udf pips"> </ul> </section> </section> <section class="attributes"> <h3>Attributes</h3> <div> <ul class="udf pips"> </ul> </div> </section> <section class="resources"> <h3>Resources</h3> <section> <div> <h4><label>Angst</label></h4> <span class="dsf dsf_perm_angst perm pips" data-pips="10"></span> <span class="dsf dsf_curr_angst current pips" data-pips="10"></span> </div> <div> <h4>Psyche Willpower</h4> <span class="dsf dsf_perm_willpower perm pips" data-pips="10"></span> </div> </section> <section> <div> <h4><label>Experience</label></h4> <span class="dsf dsf_experience"></span> </div> </section> </section> </div> <div class="page bio"> <section> <h4>Shadowlife</h4> <div><span class="dsf dsf_bio readonly"></span></div> </section> <hr /> <div>Sheet by <span class="dsf dsf_dst_author"></span></div> </div> </div>
CSS
/***** * Inspired in part by: * + nWoD RELOADED!, by Jp12x * + Old World of Darkness Generic, by Monstah * + Changeling the Dreaming CS, by Takissis *****/ .mll_sheet template { display: none; } .mll_sheet dd { padding: 0; margin: 0; } .mll_sheet ul, li { list-style-type: none; padding: 0; margin: 0; } .mll_sheet .aliases, .mll_sheet .hidden { display: none; } /** sheet */ .mll_sheet .stats > section { display: grid; grid-template-columns: 1fr 1fr; } .mll_sheet .stats > section.attributes { grid-template-columns: 1fr 1fr 1fr; } .mll_sheet .stats > section.basic { display: block; position: relative; } .mll_sheet.tabbed .bio { display: none; } /** general sections */ .mll_sheet .page > section { clear: both; overflow: auto; padding: 0; } .mll_sheet .page > section:nth-of-type(2n+1) { background: radial-gradient(circle at 105% 95%, black 0%, #888 10%, white 100%); } .mll_sheet .page > section:nth-of-type(2n) { background: radial-gradient(circle at -5% 95%, black 0%, #888 10%, white 100%); } .mll_sheet section { margin-top: 1em; /* solid line all the way to left side */ /*border-top: 2px solid black;*/ } .mll_sheet section > section { display: inline-block; border-top: 0; margin-top: 0; background-color: transparent; } /* override site style */ .mll_sheet .page > section > section { background-color: transparent; } .mll_sheet section > h2:first-child, .mll_sheet section > h3:first-child { margin-top: 0; } .mll_sheet .page > section > h2, .mll_sheet .page > section > h3 { grid-column: 1 / -1; grid-row: 1; position: relative; padding-left: 1em; padding-top: 0.25em; /* hollow on left side */ border-top: 2px solid black; border-left: 3px double black; border-bottom: 3px solid transparent; } .mll_sheet h4:first-child { margin-top: 0px; } .mll_sheet .page > section > section, .mll_sheet .page > section > div { display: inline-block; vertical-align: top; /* width: 45%; */ padding: 0.5em; /* leave space for clear-box*/ /* padding-left: 16px; */ } .mll_sheet section > section > div { display: block; } .mll_sheet dt { float: left; clear: left; min-width: 4.5em; /*margin-right: 1em;*/ } .mll_sheet dt label { display: inline-block; } /** specific sections */ .mll_sheet .basic { text-align: right; } .mll_sheet .basic > * { text-align: left; } .mll_sheet .basic h2 { /*float: left;*/ } /* .page is needed for specificity */ .mll_sheet .page .basic > div { min-width: 20%; width: auto; } .mll_sheet .basic dl { margin-top: 0; clear: left; } .mll_sheet .basic dt { margin: 0 0.5em 0 0; background-color: #DDD; padding: 0.2em; } .mll_sheet .basic dt:after { content: ":"; } .mll_sheet .basic dd { padding: 0.2em; min-height: 1em; /*outline: 1px solid grey;*/ } .mll_sheet .basic dd > :first-child { border-bottom: 1px solid #AAA; } .mll_sheet .physical dt { min-width: 6.5em; } .mll_sheet .social dt { min-width: 7.75em; } .mll_sheet .mental dt { min-width: 7.5em; } .mll_sheet li label { display: inline-block; min-width: 6em; } /** Fields */ .mll_sheet .dsf blockquote, .mll_sheet .dsf blockquote p { color: #600; } .mll_sheet .dsf_bio textarea { width: 95%; min-height: 10em; } .mll_sheet .dsf_avatar_image { float: left; margin-top: -2.5em; margin-right: 1em; } .mll_sheet.mll_shadow .dsf_avatar_image { float: right; } /** UDFs */ .mll_sheet .controls { display: none; } .mll_sheet span.controls { margin-left: 0.5em; } .mll_sheet .pips + .controls { margin-left: 0; } .mll_sheet .controls > button, .mll_sheet .controls > span { cursor: pointer; display: inline-block; background: white; color: black; font-size: 0.75em; text-align: center; vertical-align: top; height: 1.5em; width: 1.5em; padding: 0; border: 1px solid black; border-radius: 3px; margin: 0 1px; } .mll_sheet.editing .controls { display: unset; } .mll_shadow .attributes .udf { display: grid; grid-template-columns: 1fr 1fr 1fr; } .mll_shadow .attributes .udf li { padding: 0.1em 0.5em; border-right: 1px solid black; } .mll_shadow .attributes .udf li:last-child { border-right: 0; } /** Editing */ .mll_sheet .editonly { display: none; } .mll_sheet.editing .editonly { display: unset; } .mll_sheet.editing .dsf:empty:not(.readonly)::before { content: "Click to edit"; } .mll_sheet .dsf:content("Click to edit") { text-decoration: 1px dotted underline; } .mll_sheet .dsf:empty:not(.readonly)::before, .mll_sheet.editing .dsf:not(.readonly) { text-decoration: 1px dotted underline; } .mll_sheet input.checkbox.dsf::before, .mll_sheet input[type="checkbox"].dsf::before { content: "" !important /* edit marker selector above is more specific (has more classes), so overrides this ruleset without the !important */; } /** Playing */ .mll_sheet .dsf:empty.readonly:not(.hidden)::after { content: " "; display: inline-block; height: 1em; } /** Pips */ .mll_sheet .pips { /*display: inline-block;*/ white-space: nowrap; vertical-align: top; /* space for 0-pip */ /*padding-left: 4px;*/ margin-left: 2px; } .mll_sheet .dsf.pips.perm { display: block; } /*.mll_sheet.editing .pips::before,*/ .mll_sheet .pips > span { display: inline-block; margin: 2px; border: 1px solid black; border-radius: 1em; /* as long as it's > 50% of height */ width: 0.75em; height: 0.75em; background: white; } .mll_sheet.editing .pips > span, .mll_sheet .pips.current > span { cursor: pointer; } .mll_sheet .pips.demi > span, .mll_sheet .demi .pips > span { width: 0.375em; } .mll_sheet .pips.demi > span:nth-child(2n), .mll_sheet .demi .pips > span:nth-child(2n) { border-top-right-radius: 0; border-bottom-right-radius: 0; border-right: 0; margin-right: 0; } .mll_sheet .pips.demi > span:nth-child(2n+1), .mll_sheet .demi .pips > span:nth-child(2n+1) { border-top-left-radius: 0; border-bottom-left-radius: 0; /* overrides border for clear box */ /*border-left: 0;*/ margin-left: 0; } /*.mll_sheet.editing .pips::before,*/ .mll_sheet .pips > span:first-child, .mll_sheet .demi .pips > span:first-child { /*display: none;*/ visibility: hidden; vertical-align: top; border-style: dotted; border-radius: 0; position: relative; width: 12px; height: 12px; } /* clear box */ .mll_sheet .pips > span:first-child::before { position: absolute; top: 1px; left: -1px; content: "╳"; line-height: 12px; font-size: 14px; } .mll_sheet.editing .pips > span:first-child, .mll_sheet .pips.current > span:first-child { display: inline-block; visibility: visible; } /* filled pip */ .mll_sheet .pips > span.X { background-color: black; } /* blocked pip */ .mll_sheet .pips > span.D { background-color: #CCC; } .mll_sheet .pips > span.X.D { background-color: #A44; } /* */ .mll_sheet .pips.current > span { border-radius: initial; }
Javascript
/** * TODO: * + UDF reorder button for mobile (click & swap) */ (function ($) { var $slug = $('.dst_slug'), slug = $slug.text(), globals = {}, editMarker = 'Click to edit', containerId, pippedKinds = ['attributes', 'abilities', 'advantages', 'arcanoi']; const aliases = globals.aliases = { simple: { // attributes 'presence': 'charisma', 'composure': 'appearance', // resources 'experiance': 'experience', // jp12x_splat 'willpower': 'perm_willpower', 'current_will': 'curr_willpower', 'health': 'perm_health', 'chealth': 'curr_health', // old_wod_generic 'humanity_value': 'health', }, templates: { // old_wod_generic 'bg{i}': 'bg_{i}_name', 'bg{i}_value': 'bg_{i}_value', 'bg{i}_expanded{j}': 'bg_{i}_descr', // cWoD-Revised } }; /** * Fill an object with a single value for multiple keys. * * @param {[string]} keys * @param value * @param {object} target Object to add entries to. */ function fillObject(keys, value, target = {}) { if (! (keys instanceof Array || Array.isArray(keys))) { keys = Object.keys(keys); } for (let key of keys) { target[key] = value; } return target; } /** * Add a value to an object property. * * Instead of overwriting existing properties, they're converted to arrays (if necessary) and the new value is added to the array. * * @param {object} keys * @param {string} key * @param value */ function mergeVal(obj, key, val) { if (key in obj) { if (Array.isArray(obj[key])) { obj[key].push(val); } else { obj[key] = [obj[key], val]; } } else { obj[key] = val; } } /** * Exchange keys & values. * * Different keys for the same value get merged into an array. Array values get unpacked into multiple keys. * * Example: * flipObject({keys: 'str', names: 'str', xs: ['nums', 'ints']}); * // result is: * {str: ['keys', 'names'], nums: 'xs', ints: 'xs'}; */ function flipObject(obj) { let result = {}; for (let [key, val] of Object.entries(obj)) { if (Array.isArray(val)) { for (let v of val) { mergeVal(result, v, key); } } else { mergeVal(result, val, key); } } return result; } /** */ function memoize(f) { var results = {}; return function (name, ...args) { if (! (name in results)) { results[name] = f.bind(this)(name, ...args); } return results[name]; } } /** * The number of digits in a number. */ function nDigits(x) { return Math.floor(1 + Math.log10(x)); } /** * Return where a child node is among its parent's children. */ function nodeIndex(node) { return Array.prototype.indexOf.call(node.parentNode.children, node); } /** * Test whether the given element has an ancestor with the given class (making it of that kind). * * @param {string | [string]} kind Class names to test. * @param {HTMLElement} elt */ function is_kind(kind, elt) { let $elt = $(elt); if ('string' == typeof(kind)) { return $elt.closest(`.${kind}`).length; } else { return kind.find(kind => $elt.closest(`.${kind}`).length); } } function is_ability(elt) { return is_kind('abilities', elt); } function is_advantage(elt) { return is_kind('advantages', elt); } function is_attribute(elt) { return is_kind('attributes', elt); } function is_flag(elt, name, value) { return elt.type == 'checkbox' || elt.className.match(/\bcheckbox\b/) || 'boolean' == typeof(value); } function is_undefined(value) { return 'undefined' == typeof(value); } /* Add the given field kinds to the kinds that should use pips. */ function addPippedKinds(...kinds) { pippedKinds.push(...kinds); } /* Sets the kinds of fields that should use pips. */ function setPippedKinds(kinds) { pippedKinds = kinds; } /*** jQuery extensions */ $.fn.closestHaving = function(sel, pred) { if ('string' == typeof(pred)) { // to support ':scope' let psel = pred; pred = (i, node) => node.querySelector(psel); } let $nodes = this, $candidates = {}; for (; $nodes.length && ! $candidates.length; $nodes = $nodes.parent().closest(sel)) { $candidates = $nodes.filter(pred); }; return $candidates; }; const included = ["pips","udfs","dsf","field","klass","reorder"]; /* TODO: * + don't demi-ize UDF size DSFs. */ /*** * Pipped fields * * @requires dsf, */ let pips = globals.pips = { init() { this.demi.init(); this.clicker = this.clicked.bind(this); this.clickerInLimit = this.clickedInLimit.bind(this); //$('.pips.current').on('click', 'span', this.clickerInLimit); // use delegate, as pipped fields likely haven't been pippified yet $('.pips.current').on('click', 'span', this.clicker); for (let fn of this.init.queue) { fn(); } this.init.done = true; }, addKinds: addPippedKinds, /** * Change the number of pips. * * Currently doesn't work for demi-pipped fields (not that there are any in practice that should be adjustable). * * @param {jQuery} $elt DSF containing pips. * @param {int} delta Amount to change pips by. */ adjust($elt, delta) { let $kids = $elt.children(); if (delta > 0) { for (let i = 0; i < delta; ++i) { $elt.append($(`<span></span>`)); } // TODO: handle demi pips this.mark($elt, this.value($elt)); } else if (delta < 0) { $kids.slice($kids.length + delta).remove(); } }, assemble($elt) { let nPips = this.count($elt[0]); if (this.demi.is($elt)) { nPips *= 2; } for (let i = 0; i <= nPips; ++i) { $elt.append($(`<span></span>`)); } //$elt.children(':first-child').text('╳'); }, /* Mark pips as being "blocked" off. */ block($elt, value) { value = +value; let $pips = $elt.find('span'); $pips.slice(1, value+1).removeClass('D'); $pips.slice(value+1).addClass('D'); }, /* Mark current pips beyond permanent pips as "blocked". */ blockCurr(name, nPip) { let matches = name.match(/perm_(?<curr>.*)/); if (matches) { let curr = matches.groups.curr; this.block($(`.dsf_curr_${curr}`), nPip); } }, /* Remove all marks from pips. */ clear(elt) { let $elt; if (elt instanceof $) { $elt = elt; elt = elt[0]; } else { $elt = $(elt); } if ($elt.hasClass('pips')) { this.mark($elt, 0); } else { this.mark($elt.find('.pips'), 0); } }, clicked(evt) { if (this.demi.is(evt.target)) { this.demi.clicked(evt); } else { let nPip = this.countPips(evt.target); this.setPips(evt.target, nPip); } }, clickedInLimit(evt) { let name = dsf.name(evt.target.parentNode), maxPips = dsf.lookup(`perm_${name}`, 10), nPip = Math.min(this.countPips(evt.target), maxPips); this.setPips(evt.target, nPip); }, count(elt, dflt) { let nPips = +elt.dataset.pips || +dflt || 5, name = dsf.name(elt), extra = dsf.linked.extra(name), nExtra = 0; if (extra) { nExtra = + dsf.value(extra); } return nPips + nExtra; }, countPips: nodeIndex, demi: { init() { this.clicker = this.clicked.bind(this); }, assemble($elt) { let nPips = 2 * pips.count($elt[0]); for (let i = 0; i <= nPips; ++i) { $elt.append($(`<span></span>`)); } //$elt.children(':first-child').text('╳'); }, clicked(evt) { let {chirality, i} = this.locate(evt.target); this.setPips(evt.target, chirality, i); }, is(elt) { return $(elt).closest('.demi').length; }, locate(pip) { let nPip = pips.countPips(pip); if (nPip > 0) { if (nPip % 2) { return {chirality: 'left', i: Math.ceil(nPip / 2)}; } else { return {chirality: 'right', i: Math.ceil(nPip / 2)}; } } return {i: nPip}; }, mark($elt, chirality, value) { value = +value; let $pips = $elt.find(`span`).slice(1); switch (chirality) { case 'left': $pips = $pips.even(); break; case 'right': $pips = $pips.odd(); break; } $pips.slice(0, value).addClass('X'); $pips.slice(value).removeClass('X'); }, parse(value) { if ('string' == typeof(value)) { let values = value.match(/(\d+) *\/ *(\d+)/), _, left, right; if (values) { [_, left, right] = values; } else if ((values = value.match(/\d+/))) { left = values[0]; right = values[0]; } else { debugger; } value = {left, right, value}; } else if ('left' in value || 'right' in value) { value.value = `${value.left || 0} / ${value.right || 0}`; } else if ('value' in value) { // shouldn't be reached with current usage return this.parse(value.value); } return value; }, pippify($elt, {name, value=0}={}) { // parses string & sets field value value = this.value($elt, value); this.assemble($elt); this.mark($elt, 'left', value.left); this.mark($elt, 'right', value.right); }, reassemble($elt) { let nPips = 2 * pips.count($elt[0]), nKids = $(elt).children().length, delta = nPips - nKids + 1; // 1 for clear box pips.adjust($elt, delta); //$elt.children(':first-child').text('╳'); }, setPips(ndPip, chirality, nPip) { let field = ndPip.parentNode, $field = $(field), name = dsf.name(field), // get the value val = this.value(field); if (chirality) { val[chirality] = nPip; // ensures recalculation delete val.value; } else { // no chirality means 1st child (clear box) was clicked this.zero(val); } // set the value this.value(field, val); this.mark($field, chirality, nPip); }, start() { }, value(field, value) { if (field instanceof $) { field = field[0]; } if (is_undefined(value)) { //value = {left: 0, right: 0, value: '0 / 0'}; } else if (value) { value = this.parse(value); dsf.update(field, name, value.value); $.extend(field.dataset, value); } else if (! ('left' in field.dataset)) { $.extend(field.dataset, this.parse(field.dataset.value)); } return field.dataset; }, zero(value) { value.left = value.right = 0; value.value = '0/0'; return value; }, }, is(elt, name, value) { let $elt = $(elt); return ! $elt.parent('label').length && ! $elt.hasClass('hidden')//$elt.closest('.hidden').length && ! $elt.closest('.nopips').length && ( $elt.closest('.pips').length || (is_kind(pippedKinds, elt) && ! is_flag(elt, name, value))); }, mark($elt, value) { value = +value; let $pips = $elt.find('span'); $pips.slice(1, value+1).addClass('X'); $pips.slice(value+1).removeClass('X'); }, pippify($elt, {name, value=0}={}) { name ||= dsf.name($elt[0]); // .readonly to disable DSF framework's click listener & field editor $elt.addClass('pips readonly'); if (this.demi.is($elt)) { this.demi.pippify($elt, {name, value}); return; } value = +value; this.assemble($elt); this.mark($elt, value); if (name) { this.ready(() => this.blockCurr(name, value)); } }, ready(fn) { if (this.init.done) { fn.bind(this)(); } else { this.init.queue.push(fn.bind(this)); } }, /** alter the number of pips */ reassemble($elt) { let nPips = this.count($elt[0]), nKids = $elt.children().length, delta; if (this.demi.is($elt)) { nPips *= 2; } delta = nPips - nKids + 1; // 1 for clear box this.adjust($elt, delta); }, setKinds: setPippedKinds, setPips(ndPip, nPip) { let field = ndPip.parentNode, $field = $(field), name = dsf.name(field); dsf.update(field, name, nPip); this.mark($field, nPip); this.blockCurr(name, nPip); }, start() { $('input.dsf').prop('disabled', false); /* delegated handler, to catch UDFs */ $('.mll_sheet') .addClass('editing') .on('click', '.pips:not(.current) > span', this.clicker); this.demi.start(); }, stop() { $('.mll_sheet') .off('click', '.pips:not(.current) > span', this.clicker) .removeClass('editing'); $('input.dsf').prop('disabled', true); }, unpippify($elt, {name, value=0}={}) { //$elt.text($elt.data('value')); value ||= field.value($elt[0], name) || $elt.find('.X').length; $elt.find('span').remove(); $elt.text(value); }, value(elt) { if (elt instanceof $) { elt = elt[0]; } return +field.value(elt) || 0; }, }; pips.init.queue = []; /*** * User-defined * * @requires dfs, klass, pips, reorder */ let udfs = globals.udfs = { // itemNumberWidth: 2, udfSel: '.udf', _createControls() { this.$listControls = $('<div class="controls"><button class="add" title="add">+</button></div>'), // note: can't use button for "draggable" handle, as jQueryUI doesn't recognize it (click overrides drag). this.$itemControls = $('<span class="controls"><span class="del" title="delete">–</span><span class="drag" title="drag">⇅</span></span>'); }, /** * Ensures that a UDF has a DSF to hold its size. */ _createSizeDsf(udf, {base, $udf}={}) { base ||= this.base(udf); $udf ||= $(udf); let $size = this.sizeField(udf, base); if (! $size.length) { $udf.before($(`<span class="dsf ${$size.name} readonly hidden"></span>`)); } }, _createSizeDsfs() { $(this.udfSel).each(function (i, elt) { udfs._createSizeDsf(elt); }); }, _subscribeListeners() { $(document).on('click', '.udf + .controls button', function (evt) { evt.preventDefault(); }); $(document).on('click', '.udf + .controls .add', function (evt) { udfs.add($(evt.target).closest('.controls').prev()[0]); }); $(document).on('click', '.udf .del', function (evt) { udfs.del($(evt.target).closest('.udf > *')); }); $(document).on('drag', '.udf .drag', function (evt) { }); }, init({containerId, slug}) { this.containerId = containerId; this.slug = slug; this._createControls(); this._subscribeListeners(); this.createItems(); }, add(ndList, {tpl}={}) { tpl ||= this.template(ndList); let ndItem, $dsfs; ndItem = this.newItem(ndList); $(ndList).append(ndItem); if ( (pips.is(ndItem)) && ($dsfs = $(ndItem).find('span.dsf')).length >= 2) { pips.pippify($dsfs.last()); } //this.renumberItem(ndItem, iItem); // handled during dataPreSave: //this.reCount(ndList) reorder.makeDraggable(ndItem); // make new fields editable dsf.bindFields(ndItem); }, /** * Return the base portion of a UDF name, given a list node. */ base(ndList) { let name = klass.param(ndList, 'list'); if (! name) { name = ndList.dataset.base; } if (! name) { name = ndList.dataset.name; } if (! name) { name = $(ndList).parent().closest('[class]')[0].className.split(/\s+/)[0]; } return name; }, /** * Return a list of the base field name for all UDFs. */ bases() { return $(this.udfSel) .parent() .closest('[class]') .toArray() .map(elt => elt.className.replace(/ .*/, '')); }, baseToRe: memoize(function (base) { return new RegExp(`^(?:dsf_)?${base}(?:_|$)`); }), countUdfItems(udfs) { udfs ||= window.dynamic_sheet_attrs; let fieldInfo = this.fieldInfo(), counts = {}; for (let name of Object.keys(udfs)) { let base = this.dsfBase(name, fieldInfo), {key, i} = this.splitName(name, base); if (base && i) { // name is a UDF name if (base in counts) { counts[base] = Math.max(counts[base], +i); } else { counts[base] = +i; } } } return counts; }, createItemsFor(ndList, nItems, {base}) { base ||= udfs.base(ndList); let $ndList = $(ndList), scion = this.newItem(ndList, {base}), clone; for (let i = 1; i <= nItems; ++i) { clone = scion.cloneNode(true); this.renumberItem(clone, i); $ndList.append(clone); } }, createItems() { /* TODO: * 1. for each stored DSF, if from UDF record max index * 2. for each UDF, add items up to max index */ let udfCounts = this.countUdfItems(window.dynamic_sheet_attrs); $(this.udfSel).each(function (i, ndList) { let base = udfs.base(ndList); udfs.createItemsFor(ndList, udfCounts[base], {base}); }); }, del(item) { let ndNext = $(item).next()[0]; $(item).remove(); if (ndNext) { this.renumberItems(ndNext); } }, /** * Returns the base portion of a UDF name, given a DSF name & field info. */ dsfBase: memoize(function (name, fieldInfo) { let {base} = this.udfField(name, fieldInfo) || {}; return base; }), each(fn, self) { if (self) { fn = fn.bind(self); } $(this.udfSel).each(function (i, elt) { fn.bind(this)(elt, i); }); }, /** * Field information for UDFs on this sheet. * * Field info has the structure: * { * shorthand: { * short: [base], * }, * fields: { * base: [templates], * } * } * * base: UDF base name. * short: short name for UDF; if UDF base name is compound (i.e. has underscores; e.g. 'some_thing'), the short name is the first component (e.g. 'some'). This eases finding the UDF for a DSF. * templates: the classname templates for DSFs in a UDF item template, without the 'dsf_' prefix. */ fieldInfo() { // TODO: find a better name // ? put field info in subproperty (so as to prevent collisions w/ 'shorthand') let fieldInfo = { shorthand: {}, fields: {}, }; $(this.udfSel).each(function (i, udf) { let base = udfs.base(udf), $udf = $(udf), parts, short; if ((short = udfs.shorthand(base))) { if (short in fieldInfo.shorthand) { fieldInfo.shorthand[short].push(base); } else { fieldInfo.shorthand[short] = [base]; } } udfs._createSizeDsf(udf, {base, $udf}); fieldInfo.fields[base] = udfs.fieldsFor(udf, {base}); }); // save results for future calls this.fieldInfo = function () { return fieldInfo; }; return fieldInfo; }, /** * Extract names of fields for a given UDF. * * @returns {[string]} list of DSF names (w/o 'dsf_' prefix) */ fieldsFor(ndList, {base, tpl}={}) { base ||= this.base(ndList); if (base in this.fieldsFor) { // memoize result for base return this.fieldsFor[base]; } // tpl may be a template node, or an actual node from ndList tpl ||= this.templateItem(ndList); let env = {base, name: base}, $dsfs = $(tpl).find('.dsf'), // note: dsf.name removes 'dsf_' prefix names = $dsfs.toArray().map(elt => klass.eval(dsf.name(elt), env).replace(/\d+$/, '{i}')); this.fieldsFor[base] = names; return names; }, /** * The 1-based index of a UDF in the parent collection. * * @param {string} name * @param {string} the template matching name */ indexOf(name, tpl) { let parts; if (tpl && (vars = klass.extract(tpl, name)) && 'i' in vars) { return +vars.i; } else if ((parts = name.match(/\d+$/))) { return +parts[0]; } }, /** * Get all UDFs from stored DSFs. * * Turns a flattened list (i.e. the DSFs stored as key-values in `dynamic_sheet_attrs`) into nested. For example, converts {'base_key_01':value, 'base_key_02':value, } to: * { * base: [{key:value}, {key:value}], * ... * } */ inflate(dsfs=null) { let fieldInfo = this.fieldInfo(), base, data = {}; dsfs ||= window.dynamic_sheet_attrs; for (let [name, value] of Object.entries(dsfs)) { if ((base = this.dsfBase(name, fieldInfo))) { // name is a UDF name let {key, i} = this.splitName(name, base); data[udfField.base] ||= []; data[udfField.base][+i] ||= {}; data[udfField.base][+i][key || ''] = value; } } return data; }, /** 1-based index number of an item. */ itemNumber(ndItem) { return nodeIndex(ndItem) + 1; }, nameRe: memoize(function (base) { return new RegExp(`^(?:dsf_)?(?<base>${base})_(?:(?<key>.*)_)?(?<i>\\d+)$`); }), /* newItem(ndList, env={}) { let tpl = this.template(ndList), iItem = ndList.children.length + 1, ndItem, $dsfs; env.base ||= this.base(ndList); env.i ||= this.zeroPad(iItem); if (tpl) { ndItem = tpl.content.firstElementChild.cloneNode(true); } else { ndItem = ndList.children[0].cloneNode(true); } return ndItem; }, */ // TODO: finish newItem(ndList, env={}) { let tpl = this.template(ndList), // 1-based index (used by other sheets & CSS) iItem = ndList.children.length + 1, ndItem; env.base ||= this.base(ndList); env.name ||= env.base; env.i ||= this.zeroPad(iItem); if (tpl) { ndItem = tpl.content.firstElementChild.cloneNode(true); this.renameItem(ndItem, env); } else { ndItem = ndList.children[0].cloneNode(true); $ndItem = $(ndItem); // TODO: clear values $ndItem.find('.dsf').each((i, elt) => dsf.clear(elt)); // TODO: check that (what?) pips.clear($ndItem); // clear label of any content & add a DSF for the label $ndItem.find('label').empty().append($(`<span class="dsf dsf_${env.base}_label_${env.i}"></span>`)); //$(ndItem).data('value', null); this.renumberItem(ndItem, iItem); } return ndItem; }, preSave() { $(udfSel).each(function (i, elt) { udfs.reCount(elt); }); }, reCount(ndList) { let base = this.base(ndList), n = ndList.children.length; this.sizeField(ndList, base).text(n); }, reCountAll() { this.each(function (ndList) { udfs.reCount(ndList); }); }, renameItem(ndItem, env) { $(ndItem).find('.dsf').each(function (i, node) { node.className = klass.eval(node.className, env); }); }, /** * @param { (node) => null } addl Additional . */ renumberItem(ndItem, i, {width, addl}={}) { if (! i) { i = this.itemNumber(ndItem); } $(ndItem).find('.dsf').each(function (_, dsf) { dsf.className = dsf.className.replace( /\b(dsf_[^ ]*?)(?:_\d+)?\b/, '$1_' + udfs.zeroPad(i, width) ); }); addl && addl(ndItem); }, renumberItems(ndItem, {addl}={}) { let iItem = this.itemNumber(ndItem); for (; ndItem; ndItem = ndItem.nextSibling) { // Note: 1-based index this.renumberItem(ndItem, iItem++, {addl}); } }, renumberList(ndList, addl) { let ndItem; for (let i = 0; i < ndList.children.length; ++i) { // Note: 1-based index this.renumberItem(ndList.children[i], i + 1, {addl}); } }, shorthand(name) { let parts = name.match(/^(?:dsf_)?([^_]+)_/); if (parts) { return parts[1]; } }, sizeField(ndList, base) { base ||= this.base(ndList); let name = `dsf_${base}_size`, $size = $(ndList).parent().find(`.${name}`); $size.name = name; return $size; }, /** * Split a UDF field name into base, key and index */ splitName: memoize(function (name, base) { let nameRe = this.nameRe(base), parts = name.match(nameRe) || {}; return parts.groups || {}; }), start() { $('template.item').each(function (i, doc) { $(doc.content.firstElementChild).append(udfs.$itemControls.clone()); }); $(this.udfSel).each(function(i, ndList) { let $list = $(ndList), $controls = $list.find('+ .controls'); if ($controls.length) { $controls.show(); } else { $list.after(udfs.$listControls.clone()); } }); $('.udf > *').each(function (i, ndItem) { let $controls = $(ndItem).find('.controls'); if ($controls.length) { $controls.show(); } else { $(ndItem).append(udfs.$itemControls.clone()); } }); }, stop() { /* handled via CSS $('.udf .controls').hide(); $('.udf+.controls').hide(); */ }, template(ndList) { return $(ndList).parent() .closestHaving('[class]', ':scope > template') .children('template')[0]; /* jQuery selectors don't seem to work on document fragments, so return * the template itself and let caller pull elements from it. .children(':first-child')[0]; */ }, templateItem(ndList) { let tpl = this.template(ndList); if (tpl) { return tpl.content.firstElementChild; } /* Should dsf names be converted back to template classes? Or at least * the item number be converted to a template param (`{i}`)? */ /* tpl = ndList.children[0].clone(true); $(tpl).find('.dsf'); */ return ndList.children[0]; }, /** * Search for UDF field info for the given field name. * * @returns {{base:string, field:string}} The base name for the UDF & the field template. */ udfField: memoize(function (name, fieldInfo) { let short = this.shorthand(name), candidates; // ? check fieldInfo.shorthand before fieldInfo.fields, in case `short` matches a UDF whose name is the prefix for another UDF that matches `name`. if (short in fieldInfo.shorthand) { for (let udf of fieldInfo.shorthand[short]) { let field = klass.matchesAny(fieldInfo.fields[udf], name); if (field) { return {base: udf, field}; } } } if (short in fieldInfo.fields) { let field = klass.matchesAny(fieldInfo.fields[short], name); if (field) { return {base: short, field}; } } for (let [udf, fields] of Object.entries(fieldInfo.fields)) { let re = this.baseToRe(udf); if (name.match(re)) { let field = klass.matchesAny(fields, name); if (field) { return {base: udf, field}; } } } }), zeroPad(i, width) { return i.toString().padStart(width || this.itemNumberWidth, '0') }, }; /*** * Dynamic Sheet Fields * * @requires field, klass, pips, aisleten */ let dsf = globals.dsf = { init({containerId}) { this.sheetId = containerId; this.each(function (elt, $elt, name, value) { // strip 'Click to edit' text? if (editMarker == elt.textContent) { elt.textContent = ''; } value = this.override(name, value) if (is_flag(elt, name, value)) { } else if (pips.is(elt, name, value)) { //let val = $elt.text() || elt.dataset.value || $elt.data('value'); $elt.text(''); pips.pippify($elt, {name, value}); } else { } }, this); $('input[type="checkbox"].dsf').each(function (i, elt) { if (elt.className.match(/_specialty\b/)) { elt.title = 'Specialty'; } }); }, /* make node's descendant DSFs editable */ bindFields(node) { $(node) .find('.dsf') .not('.readonly') //.not('.pips') // should be covered by .readonly //.not('.hidden') // should be covered by .readonly .each(function (i, elt) { let name = dsf.stripPrefix(dsf.name(elt)); aisleten.characters.bindField(name, udfs.containerId, udfs.slug); }); }, /* Remove the value set on a DSF node. */ clear(eltDsf) { delete eltDsf.dataset.value; let $eltDsf = $(eltDsf); $eltDsf.removeData('value'); if (! $eltDsf.hasClass('pips')) { $eltDsf.empty(); } }, /** * @param {(Node, jQuery, string, string) => null} fn (element, $(element), name, value) */ each(fn, self) { if (self) { fn = fn.bind(self); } $('.dsf').each(function (i, elt) { let $elt = $(elt), name = dsf.name(elt), value = field.value(elt, name); // elt.dataset.value || $elt.data('value') || field.value(name); fn(elt, $elt, name, value); }); }, linked: { base(name) { if (this.isExtra(name)) { return name.replace(/(^|_)extra_/, '$1perm_'); } }, extra(name) { if (this.isBase(name)) { return name.replace(/(^|_)perm_/, '$1extra_'); } }, isBase(name) { return name.match(/(^|_)perm_/); }, isExtra(name) { return name.match(/(^|_)extra_/); }, /* has_extra(name) { }, */ }, isVolatile(node) { return node.className.match(/(?:\b|_)curr_|\bcurrent\b/); }, addPrefix(name) { return name.replace(/^(?:dsf_)?/, 'dsf_'); }, /** * A unique string for a name. * * Allows DSFs that use the same name from multiple sheets to be stored in local storage without colliding. */ sku(name) { return this.sheetId + '.' + name; }, stripPrefix(name) { return name.replace(/^dsf_/, ''); }, name(elt) { return klass.param(elt, 'dsf'); }, /* get/set value from dsf */ value(name, value) { name = this.addPrefix(name); let $dsf = $(`.${name}`); if (value) { $dsf.text(value); } return $dsf.text(); }, // TODO: find better name /** * Use value from local storage (if any) instead of value from page load. * * Allows for values to be set outside of edit mode. */ override(name, value) { name = dsf.stripPrefix(name); let key = this.sku(name); if (key in localStorage) { value = localStorage[key]; // overwrite value this.value(name, value); } return value; }, /* Volatile & DSFs are currently incompatible (as reordering changes field name, but localStorage isn't updated) */ update(ndField, name, value) { ndField.dataset.value = value; if (this.isVolatile(ndField)) { name = this.sku(dsf.stripPrefix(name)); localStorage[name] = value; } //$(ndField).data('value', value); //this.value(name, value); }, }; /*** */ let field = globals.field = { value(elt, name) { return elt.dataset.value || $(elt).data('value') || dsf.value(name || dsf.name(elt)); } }; /*** HTML class ops */ let klass = globals.klass = { reVar: /{([^}]+)}/, reVars: /{([^}]+)}/g, /* */ apply(fromTpl, toTpl, vals) { let env = this.extract(fromTpl, vals); return this.eval(toTpl, env); }, /* Replace vars with values */ eval(name, env, unbrace) { return name.replace( this.reVars, function (match, key) { if (key in env) { return env[key]; } if (unbrace) { return key; } else { return `{${key}}`; } } ) }, /* Get values from a class */ extract(tpl, vals) { let match = this.matches(tpl, vals); if (match) { return match.groups; } }, hasVars(name) { return name.match(this.reVar); }, matches(tpl, name) { return name.match(this.tplToRe(tpl)); }, matchesAny(tpls, name) { for (let tpl of tpls) { if (this.matches(tpl, name)) { return tpl; } } }, name(tpl, name) { let matches = name.match(this.tplToRe(tpl)); if (matches) { return matches[0]; } }, /* Get a named parameter ('name_value'). */ param(elt, param) { let matches = elt.className.match(new RegExp(`\\b${param}_(?<value>\\S+)`)); if (matches) { return matches.groups.value } }, /* Get the part of a classname before template vars. */ prefix: memoize(function (name) { let matches = name.match(/^[^{]+/); if (matches) { return matches[0]; } return name; }), tplToRe(tpl, allowEmpty) { let quant = allowEmpty ? '*' : '+'; return new RegExp('\\b' + tpl.replace(this.reVars, `(?<$1>[^ _]${quant}?)`) + '\\b'); }, /* Get variable names from a templated class (e.g. 'item{i}') */ vars: memoize(function (name) { return name.match(/(?<={)[^}]+(?=})/g); /* let vars = name.match(this.reVars); return vars.map(v => v.replace(/^{|}$/g, '')); */ }), }; /*** * Reorder UDF list items by dragging. */ let reorder = globals.reorder = { init() { // doesn't work on mobile this.starter = this.started.bind(this); this.dragger = this.dragged.bind(this); this.stopper = this.stopped.bind(this); let $udf = $('.udf'); $udf.find('li').prop('draggable', true); $udf.on('dragstart', this.starter); $udf.on('dragenter', 'li', this.dragger); $udf.on('dragend', this.stopper); }, dragged(evt) { let draggable = $(evt.delegateTarget).data('payload'), target = reorder.target(evt.target); reorder.swapWith(draggable, target); }, makeDraggable(elt) { elt.draggable = true; }, started(evt) { let elt = reorder.target(evt.target); elt.style.opacity = 0.5; $(evt.delegateTarget).data('payload', elt); }, stopped(evt) { let elt = reorder.target(evt.target); elt.style.opacity = null; $(evt.delegateTarget).data('payload', null); /* As `reorder` is only to be used for UDFs, assume that calling * `udfs.renumberList` is appropriate. Alternatively, reorder could * support callbacks registered for reorderable lists. */ udfs.renumberList(evt.delegateTarget); }, swapWith(node, other) { if (node != other && node.parentNode == other.parentNode) { let parent = other.parentNode, iNode = nodeIndex(node), iOther = nodeIndex(other); if (iNode < iOther) { other = other.nextSibling; } parent.insertBefore(node, other); } }, target(elt) { return $(elt).closest('li')[0]; }, }; /*** */ function edit() { pips.start(); udfs.start(); } function noedit() { pips.stop(); udfs.stop(); } $.extend(globals, {edit, noedit}); /*** DS API */ let listeners = {}; listeners.PreLoad = function dataPreLoad(opts) { udfs.init(opts); }; listeners.PostLoad = function dataPostLoad(opts) { dsf.init(opts); pips.init(); // can't happen before .dsf are pipped reorder.init(); if (opts.isEditable) { edit(); } else { noedit(); } }; listeners.Change = function dataChange({containerId, fieldName, fieldValue}) { let dsfName = dsf.addPrefix(fieldName); if (dsf.linked.isExtra(fieldName)) { let base = dsf.addPrefix(dsf.linked.base(fieldName)), $base = $(`.${base}`); if (pips.is($base, fieldName, fieldValue)) { pips.reassemble($base); } else { } } }; listeners.PreSave = function dataPreSave({containerId, slug, isEditable}) { /* convert back to numeric fields */ dsf.each(function (elt, $elt, name, value) { if (pips.is(elt, name, value)) { pips.unpippify($elt, {name, value}); } }); udfs.reCountAll(); }; /* ${slug}_dataPreLoad, at least, must be present before the dynamic sheet framework loads DSFs, so might as well do them all. */ function registerListeners() { for (let evt in listeners) { window[`${slug}_data${evt}`] = listeners[evt]; } } if (slug) { registerListeners(); } else { $(function () { if (! slug) { $slug = $('.dst_slug'); slug = $slug.text(); registerListeners(); } }); } // for debugging window.mll_sheet = $.extend(window.mll_sheet || {}, globals); })(jQuery);
Submit Notes
A sheet for the shadow players of Wraith: The Oblivion. The style is plain. It has a few nice interactive features (WoD style pips, user-defined fields). At some point, I hope to add built-in help for the features.
Back
I'm sorry, but we no longer support this web browser. Please
upgrade your browser
or install
Chrome
or
Firefox
to enjoy the full functionality of this site.