PageSourceSearch

https://portal.miira.app/manage/field-tokens.js?v=20260922-token-atomic-1-props-copy-1-residence-street-1

js miira.app collected 2026-10-02 15:59:29 UTC 36,374 bytes, 954 lines download raw bytes

1/*
2 * Shared {{field}} merge-token registry + picker for the /manage portal.
3 *
4 * ONE source of truth for every field/"wildcard" dropdown in the portal — the playbook
5 * step editors, the email templates modal, the 1:1 compose modal and the residence
6 * sold-notify modal all mount the same component off this file.
7 *
8 * MIRROR OF substitutePlaybookTokens() in src/services/playbookEventWorker.ts — the token
9 * ids, the lowercased-key lookup, the "unknown tokens are left intact" rule and the
10 * "field.* with no definition collapses to ''" rule must all stay in sync with it. That
11 * server function stays authoritative for playbook sends; the tabs.js surfaces resolve
12 * client-side against the same vocabulary so the two agree.
13 *
14 * Two halves:
15 *   1. The registry + pure string helpers (substitute/highlight/serialize/...), which are
16 *      DOM-free and unit-tested in field-tokens.test.js.
17 *   2. attach(), which mounts a Fields dropdown + inline "{{" autocomplete onto any
18 *      input / textarea / contenteditable. Browser only.
19 *
20 * Dual-export shim: in the browser this defines window.MiiraFieldTokens (loaded via a
21 * <script> tag on both index.html and playbooks.html — they do NOT share a bundle);
22 * under Jest/Node it is a CommonJS module.
23 */
24(function (root, factory) {
25  var api = factory();
26  if (typeof module !== 'undefined' && module.exports) {
27    module.exports = api;
28  } else {
29    root.MiiraFieldTokens = api;
30  }
31})(typeof self !== 'undefined' ? self : this, function () {
32  'use strict';
33
34  // ------------------------------------------------------------------ registry
35
36  /**
37   * The fixed tokens. `group` is the heading they sit under in every menu; `only`
38   * restricts a token to the listed surfaces (omitted = available everywhere).
39   *
40   * Order within a group is the order shown.
41   */
42  var TOKENS = [
43    { id: 'contactName', label: 'Contact name', group: 'Contact' },
44    { id: 'contactFirstName', label: 'First name', group: 'Contact' },
45    { id: 'contactEmail', label: 'Contact email', group: 'Contact' },
46    { id: 'contactPhone', label: 'Contact phone', group: 'Contact' },
47    { id: 'contactCompany', label: 'Company', group: 'Contact' },
48    { id: 'contactStreet', label: 'Street', group: 'Contact' },
49    { id: 'contactSuburb', label: 'Suburb', group: 'Contact' },
50    { id: 'contactPostcode', label: 'Postcode', group: 'Contact' },
51    // Every note on the contact, newest first. Notes are the team's private commentary on a
52    // lead, so this must never sit one click away in a customer-facing composer — and the
53    // value only exists server-side, so no tabs.js surface could resolve it anyway.
54    // The two surfaces that do get it are the ones that never reach the contact: the internal
55    // email step, and the webhook step, which POSTs to the org's OWN endpoint.
56    {
57      id: 'contactNotes',
58      label: 'All notes',
59      group: 'Contact',
60      only: ['playbook.internalEmail', 'playbook.webhook'],
61    },
62    { id: 'leadSource', label: 'Lead source', group: 'Enquiry' },
63    { id: 'contactBudget', label: 'Budget', group: 'Enquiry' },
64    { id: 'contactUnits', label: 'Units of interest', group: 'Enquiry' },
65    { id: 'contactStage', label: 'Pipeline stage', group: 'Enquiry' },
66    { id: 'enquiryDate', label: 'Enquiry date', group: 'Enquiry' },
67    { id: 'contactLink', label: 'Link to contact', group: 'Enquiry' },
68    { id: 'projectName', label: 'Project name', group: 'Project' },
69    { id: 'projectAddress', label: 'Project address', group: 'Project' },
70    { id: 'organizationName', label: 'Organisation', group: 'Project' },
71    // The sold residence being announced. Only the sold-notify modal knows which unit it is.
72    { id: 'residence', label: 'Property', group: 'Project', only: ['soldNotify'] },
73    { id: 'agentName', label: 'Agent name', group: 'Agent' },
74    { id: 'agentEmail', label: 'Agent email', group: 'Agent' },
75    { id: 'agentPhone', label: 'Agent number', group: 'Agent' },
76  ];
77
78  var GROUPS = ['Contact', 'Enquiry', 'Project', 'Agent', 'Lead answers'];
79
80  /** The org's custom contact fields ("Lead answers") become one token each: {{field.<id>}}. */
81  var CUSTOM_PREFIX = 'field.';
82
83  /**
84   * Legacy spellings kept resolvable so older copy keeps working. The sold-notify modal
85   * shipped its own vocabulary before this module existed; nothing persists those strings,
86   * but a user with the modal open mid-migration would otherwise see raw braces.
87   * Aliases resolve on substitute() only — they are never offered in a menu.
88   */
89  var ALIASES = {
90    firstname: 'contactfirstname',
91    first_name: 'contactfirstname',
92    unit: 'residence',
93    project: 'projectname',
94  };
95
96  // Token grammar, shared with the server: letters for the fixed tokens, plus digits, `_`
97  // and `.` after the first letter for `field.<slug>`. Kept as a source string so the
98  // autocomplete can test a partial ("{{field.po") with the same alphabet.
99  var TOKEN_NAME = '[a-zA-Z][a-zA-Z0-9_.]*';
100  var TOKEN_RE_SOURCE = '\\{\\{\\s*(' + TOKEN_NAME + ')\\s*\\}\\}';
101  var TOKEN_PARTIAL_RE = new RegExp('^(?:' + TOKEN_NAME + ')?$');
102
103  /** A fresh global regex every call — a shared one carries `lastIndex` between callers. */
104  function tokenRe() {
105    return new RegExp(TOKEN_RE_SOURCE, 'g');
106  }
107
108  function tokenText(id) {
109    return '{{' + id + '}}';
110  }
111
112  function escapeHtml(str) {
113    return String(str == null ? '' : str)
114      .replace(/&/g, '&amp;')
115      .replace(/</g, '&lt;')
116      .replace(/>/g, '&gt;')
117      .replace(/"/g, '&quot;')
118      .replace(/'/g, '&#39;');
119  }
120
121  /** Canonical map key for a token id, following aliases. */
122  function normalizeId(id) {
123    var key = String(id == null ? '' : id).toLowerCase();
124    return Object.prototype.hasOwnProperty.call(ALIASES, key) ? ALIASES[key] : key;
125  }
126
127  // ------------------------------------------------------- custom field tokens
128
129  /**
130   * One token per live org contact-field definition. Archived definitions are skipped (the
131   * server drops them too), so their tokens fall back to plain text in the editor and to ''
132   * at send time.
133   */
134  function customTokens(defs) {
135    var list = Array.isArray(defs) ? defs : [];
136    var seen = {};
137    var out = [];
138    list.forEach(function (d) {
139      if (!d || typeof d.id !== 'string' || !d.id || d.archived || seen[d.id]) return;
140      seen[d.id] = true;
141      out.push({
142        id: CUSTOM_PREFIX + d.id,
143        label: String(d.label || d.id),
144        group: 'Lead answers',
145        def: d,
146      });
147    });
148    return out;
149  }
150
151  function opts(o) {
152    return o || {};
153  }
154
155  /** Every token that exists right now, ignoring surface restrictions. */
156  function all(o) {
157    return TOKENS.concat(customTokens(opts(o).customFieldDefs));
158  }
159
160  /**
161   * The tokens OFFERED on a surface. Lookup (find/deserialize/highlight) deliberately still
162   * walks the full list, so a restricted token already saved on a step keeps resolving to
163   * its pill rather than decaying to raw braces.
164   */
165  function list(o) {
166    var surface = opts(o).surface || '';
167    return all(o).filter(function (t) {
168      return !t.only || t.only.indexOf(surface) !== -1;
169    });
170  }
171
172  /**
173   * Lookup WITHOUT alias resolution. Pills are built from canonical ids only, so the editor
174   * round trip must not quietly rewrite `{{unit}}` into a `{{residence}}` pill — aliases stay a
175   * substitute()-time courtesy for copy written before this registry existed.
176   */
177  function findExact(id, o) {
178    var key = String(id == null ? '' : id).toLowerCase();
179    var found = null;
180    all(o).some(function (t) {
181      if (t.id.toLowerCase() === key) {
182        found = t;
183        return true;
184      }
185      return false;
186    });
187    return found;
188  }
189
190  function find(id, o) {
191    return findExact(normalizeId(id), o);
192  }
193
194  function isKnown(id, o) {
195    return !!find(id, o);
196  }
197
198  /** Case-insensitive substring match on the id, or on the label with whitespace stripped. */
199  function filter(query, o) {
200    var q = String(query || '').toLowerCase();
201    var offered = list(o);
202    if (!q) return offered;
203    return offered.filter(function (t) {
204      return (
205        t.id.toLowerCase().indexOf(q) !== -1 ||
206        t.label.toLowerCase().replace(/\s+/g, '').indexOf(q) !== -1
207      );
208    });
209  }
210
211  /**
212   * Example value for a custom field in a preview or test send. Mirrors
213   * samplePlaybookCustomFieldValue() on the server so previews and real sends agree.
214   */
215  function sampleValue(def) {
216    if (!def) return '';
217    if (def.kind === 'select') return (Array.isArray(def.options) && def.options[0]) || String(def.label || '');
218    if (def.kind === 'boolean') return 'Yes';
219    if (def.kind === 'number') return def.unit ? '3 ' + String(def.unit).trim() : '3';
220    if (def.kind === 'date') return '17 Sep 2026';
221    if (def.kind === 'phone') return '0412 345 678';
222    if (def.kind === 'address') return '12 Smith Street, Carlton, VIC 3053, Australia';
223    return ('Example ' + String(def.label || '').toLowerCase()).trim();
224  }
225
226  // ------------------------------------------------------------- substitution
227
228  /**
229   * Replace {{token}} with values from `map` (keys lowercased). Mirrors
230   * substitutePlaybookTokens on the server:
231   *   - a known-but-empty value collapses to '' ("Budget: " can render blank)
232   *   - an unknown token is left intact, so a stray {{foo}} is visible rather than silent
233   *   - EXCEPT a `field.*` token with no definition, which collapses to '' — the prefix
234   *     marks it as ours, so raw braces never reach a recipient.
235   */
236  function substitute(text, map, escapeValues) {
237    if (!text) return text || '';
238    var m = map || {};
239    return String(text).replace(tokenRe(), function (full, name) {
240      var key = normalizeId(name);
241      if (!Object.prototype.hasOwnProperty.call(m, key)) {
242        return key.indexOf(CUSTOM_PREFIX) === 0 ? '' : full;
243      }
244      var v = m[key] == null ? '' : String(m[key]);
245      return escapeValues ? escapeHtml(v) : v;
246    });
247  }
248
249  /** Escaped HTML for a subject overlay: known {{tokens}} wrapped in a highlight span. */
250  function highlight(text, o) {
251    var s = String(text || '');
252    var out = '';
253    var last = 0;
254    var re = tokenRe();
255    var m;
256    while ((m = re.exec(s))) {
257      out += escapeHtml(s.slice(last, m.index));
258      out += isKnown(m[1], o)
259        ? '<span class="mft-hl">' + escapeHtml(m[0]) + '</span>'
260        : escapeHtml(m[0]);
261      last = m.index + m[0].length;
262    }
263    out += escapeHtml(s.slice(last));
264    return out;
265  }
266
267  // -------------------------------------------------------------------- pills
268
269  function pillHtml(t) {
270    return (
271      '<span class="mft-pill" contenteditable="false" data-token="' +
272      escapeHtml(tokenText(t.id)) +
273      '">' +
274      escapeHtml(t.label) +
275      '</span>'
276    );
277  }
278
279  function normalizePlainText(text) {
280    return String(text || '')
281      .replace(/ /g, ' ')
282      .replace(/&(nbsp|#160|#xA0);/gi, ' ');
283  }
284
285  /**
286   * Editor HTML (pills) -> storage string ({{token}} text, formatting kept).
287   *
288   * Matches `[data-token]`, not `.mft-pill`: the class is decoration that a sanitiser or a paste
289   * can strip, and a pill missed here does NOT degrade to its token — it persists the pill's human
290   * LABEL in place of the merge field, which is unrecoverable on the next load.
291   */
292  function serialize(editor) {
293    if (!editor) return '';
294    var clone = editor.cloneNode(true);
295    Array.prototype.forEach.call(clone.querySelectorAll('[data-token]'), function (p) {
296      p.replaceWith(editor.ownerDocument.createTextNode(p.getAttribute('data-token') || ''));
297    });
298    var html = clone.innerHTML.trim();
299    if (html === '<br>' || html === '<div><br></div>' || html === '<br/>') html = '';
300    return html;
301  }
302
303  /**
304   * A pill element found in STORED html -> a canonical pill.
305   *
306   * Nothing should ever store one (serialize turns every pill back into text), but bodies saved
307   * while pills were losing their class carry `<span data-token="{{x}}">Label</span>`, whose
308   * visible text is the label. Rebuilding from the surviving `data-token` restores the pill and
309   * keeps the token text inside that attribute away from the text pass below. Content is matched
310   * as `[^<]*` so anything unexpected is left untouched rather than mis-paired.
311   */
312  function restorePills(html, o) {
313    return html.replace(/<span\b[^>]*\bdata-token="([^"]*)"[^>]*>[^<]*<\/span>/gi, function (full, attr) {
314      var m = /^\s*\{\{\s*(.+?)\s*\}\}\s*$/.exec(attr);
315      if (!m) return full;
316      var t = findExact(m[1], o);
317      return t ? pillHtml(t) : escapeHtml(attr);
318    });
319  }
320
321  /**
322   * Replace {{token}} with pills in TEXT only, never inside a tag.
323   *
324   * A pill carries its own token in `data-token="{{x}}"`, so a pass over the whole string would
325   * re-expand that attribute into nested markup on every round trip — the mangling that appeared
326   * after editing and saving the same template twice. One pass, so a pill is never re-entered.
327   */
328  function pillifyText(html, o) {
329    return html
330      .split(/(<[^>]*>)/)
331      .map(function (seg, i) {
332        if (i % 2 === 1) return seg; // a tag
333        return seg.replace(tokenRe(), function (full, name) {
334          var t = findExact(name, o);
335          return t ? pillHtml(t) : full;
336        });
337      })
338      .join('');
339  }
340
341  /** Storage string -> editor/summary HTML (known {{token}} -> pill). */
342  function deserialize(raw, o) {
343    var html = String(raw || '');
344    var looksHtml = /<[a-z!\/]/i.test(html);
345    if (!looksHtml) html = escapeHtml(normalizePlainText(html)).replace(/\n/g, '<br>');
346    else html = restorePills(html, o);
347    return pillifyText(html, o);
348  }
349
350  // ============================================================ picker (browser)
351
352  var hasDom = typeof document !== 'undefined' && !!document.createElement;
353
354  // --------------------------------------------------------- the pill as one character
355
356  /*
357   * A pill is a `contenteditable="false"` span, which Chromium only half-treats as an
358   * atomic object. Three things have to be added by hand before it edits like a single
359   * character, and all three are shared with playbooks.js — that file draws its own
360   * `.pb-wild-pill` from its own registry, but the object has to behave identically.
361   */
362
363  /** The pill an event or a selection landed on, or null. */
364  function pillAt(node) {
365    if (!node) return null;
366    var el = node.nodeType === 1 ? node : node.parentNode;
367    return el && el.closest ? el.closest('[data-token]') : null;
368  }
369
370  /** Collapse the selection to one edge of `pill` — never inside it. */
371  function collapseTo(pill, after) {
372    var r = document.createRange();
373    if (after) r.setStartAfter(pill);
374    else r.setStartBefore(pill);
375    r.collapse(true);
376    var sel = window.getSelection();
377    sel.removeAllRanges();
378    sel.addRange(r);
379    return r;
380  }
381
382  /**
383   * Insert one pill at `range`, leaving the caret immediately AFTER it.
384   *
385   * NOT `execCommand('insertHTML', pillHtml + ' ')`, which is what every insert site used
386   * to do. That appended a plain space to give the caret somewhere to land, and Chromium
387   * drops a trailing collapsible space at the END of a block — which is exactly where a
388   * token usually goes. The pill became the last node, `insertHTML` left the selection in
389   * FRONT of it, and from then on everything typed went in before the token while the pill
390   * rode along at the end of the paragraph until it wrapped onto a line of its own. That is
391   * the "it gets stuck on a different line" report, and the reason a Backspace straight
392   * after inserting ate the space before the token rather than the token.
393   *
394   * Nothing is inserted after the pill now either: a token is one character, so the caret
395   * lands hard against it and the author types their own comma or space. The old auto-space
396   * wrote "Hi Eleanor , thanks" whenever the next thing typed was punctuation.
397   */
398  function insertPillAt(field, range, html) {
399    if (!field || !range) return null;
400    range.deleteContents();
401    var holder = document.createElement('div');
402    holder.innerHTML = html;
403    var pill = holder.firstChild;
404    if (!pill) return null;
405    range.insertNode(pill);
406    collapseTo(pill, true);
407    return pill;
408  }
409
410  /*
411   * Editors that asked for atomic pills. ONE document-level `selectionchange` listener
412   * serves all of them: it is the backstop behind the per-editor pointer handling, and the
413   * guarantee that the caret is never inside a pill however it got there — a click, a drag,
414   * Home/End, a restored range, a paste.
415   */
416  var atomicEditors = [];
417  var caretGuardBound = false;
418  var fixingCaret = false;
419
420  function bindCaretGuard() {
421    if (caretGuardBound) return;
422    caretGuardBound = true;
423    document.addEventListener('selectionchange', function () {
424      if (fixingCaret || !atomicEditors.length) return;
425      var sel = window.getSelection();
426      if (!sel || !sel.rangeCount || !sel.isCollapsed) return;
427      var node = sel.anchorNode;
428      if (!node) return;
429      var owner = null;
430      for (var i = 0; i < atomicEditors.length; i++) {
431        if (atomicEditors[i].contains(node)) { owner = atomicEditors[i]; break; }
432      }
433      if (!owner) return;
434      var pill = pillAt(node);
435      if (!pill || !owner.contains(pill)) return;
436      // Inside the pill: leave by the edge it was entered from. Offset 0 means the caret
437      // arrived at the pill's leading edge, anything further in means it came from the right.
438      fixingCaret = true;
439      try { collapseTo(pill, sel.anchorOffset !== 0); }
440      finally { fixingCaret = false; }
441    });
442  }
443
444  /**
445   * Make every `[data-token]` pill inside `editor` edit like one character.
446   *
447   * - a click anywhere on a pill puts the caret at the NEAR EDGE of it, never inside it and
448   *   never anywhere else. Chromium gets this wrong in both directions unaided: with
449   *   `user-select: none` a click resolved to offset 0 of the whole editor, so the next
450   *   keystroke appeared at the top of the message, and without it the caret landed inside
451   *   the non-editable span, where typing is silently swallowed;
452   * - a drag that STARTS on a pill is left alone, so a selection can still be pulled out of
453   *   one;
454   * - the shared caret guard above catches every other route in.
455   *
456   * Arrow keys and Backspace already treat the pill as one unit — Chromium steps over a
457   * `contenteditable="false"` child in a single press and deletes it whole — so nothing here
458   * touches the keyboard. What made those two feel broken was the caret starting in the
459   * wrong place, which is `insertPillAt`'s job.
460   */
461  function makeAtomic(editor) {
462    if (!hasDom || !editor) return { destroy: function () {} };
463    // Ref-counted: two attach() calls can name the same editor (one trigger per toolbar on
464    // a modal that is rebuilt), and the first one's destroy must not leave the second
465    // holding a live handle over an editor that is no longer guarded.
466    if (editor.__mftAtomic) {
467      var shared = editor.__mftAtomic;
468      shared.refs += 1;
469      return { destroy: function () { shared.release(); } };
470    }
471    var downPill = null;
472    var downX = 0;
473
474    function onDown(e) {
475      downPill = pillAt(e.target);
476      downX = e.clientX;
477    }
478    function onUp(e) {
479      var pill = downPill;
480      downPill = null;
481      // Only a press that began AND ended on the same pill is a click on the token; a drag
482      // off it is a real selection gesture and keeps whatever the engine decided.
483      if (!pill || pillAt(e.target) !== pill) return;
484      var rect = pill.getBoundingClientRect();
485      collapseTo(pill, downX >= rect.left + rect.width / 2);
486    }
487
488    editor.addEventListener('mousedown', onDown);
489    editor.addEventListener('mouseup', onUp);
490    atomicEditors.push(editor);
491    bindCaretGuard();
492
493    var rec = {
494      refs: 1,
495      release: function () {
496        rec.refs -= 1;
497        if (rec.refs > 0) return;
498        editor.removeEventListener('mousedown', onDown);
499        editor.removeEventListener('mouseup', onUp);
500        var i = atomicEditors.indexOf(editor);
501        if (i !== -1) atomicEditors.splice(i, 1);
502        try { delete editor.__mftAtomic; } catch (_) { editor.__mftAtomic = null; }
503      },
504    };
505    editor.__mftAtomic = rec;
506
507    return { destroy: function () { rec.release(); } };
508  }
509
510
511  var instances = [];
512  var outsideBound = false;
513  // The inline "{{" autocomplete is a single body-level menu — only one field can own it.
514  var ac = { owner: null, matches: [], active: 0, el: null };
515
516  function menuItemHtml(t, extraClass, selected) {
517    return (
518      '<button type="button" class="mft-item' + (extraClass || '') + '" data-token-id="' +
519      escapeHtml(t.id) + '"' + (selected == null ? '' : ' role="option" aria-selected="' + (selected ? 'true' : 'false') + '"') + '>' +
520      '<span class="mft-pill mft-pill--menu">' + escapeHtml(t.label) + '</span>' +
521      '<code>' + escapeHtml(tokenText(t.id)) + '</code>' +
522      '</button>'
523    );
524  }
525
526  /** Grouped list for a Fields dropdown. Re-rendered on every open so custom fields stay live. */
527  function renderMenu(menu, tokens) {
528    var byGroup = {};
529    tokens.forEach(function (t) {
530      (byGroup[t.group] = byGroup[t.group] || []).push(t);
531    });
532    menu.innerHTML = GROUPS.filter(function (g) {
533      return byGroup[g] && byGroup[g].length;
534    })
535      .map(function (g) {
536        return (
537          '<div class="mft-group" role="presentation">' + escapeHtml(g) + '</div>' +
538          byGroup[g].map(function (t) { return menuItemHtml(t); }).join('')
539        );
540      })
541      .join('');
542  }
543
544  function acEl() {
545    if (ac.el && ac.el.isConnected) return ac.el;
546    var el = document.getElementById('mftAcMenu');
547    if (!el) {
548      el = document.createElement('div');
549      el.id = 'mftAcMenu';
550      el.className = 'mft-ac-menu is-hidden';
551      el.setAttribute('role', 'listbox');
552      document.body.appendChild(el);
553    }
554    ac.el = el;
555    return el;
556  }
557
558  function acHide() {
559    ac.owner = null;
560    ac.matches = [];
561    ac.active = 0;
562    if (ac.el) ac.el.classList.add('is-hidden');
563  }
564
565  function acRender() {
566    if (!ac.matches.length) return acHide();
567    var menu = acEl();
568    menu.innerHTML = ac.matches
569      .map(function (t, i) {
570        return menuItemHtml(t, i === ac.active ? ' is-active' : '', i === ac.active);
571      })
572      .join('');
573    menu.classList.remove('is-hidden');
574  }
575
576  function acPositionAt(left, bottom) {
577    var menu = acEl();
578    var width = menu.offsetWidth || 240;
579    menu.style.left = Math.max(8, Math.min(left, window.innerWidth - width - 8)) + 'px';
580    menu.style.top = Math.min(bottom + 4, window.innerHeight - 8) + 'px';
581  }
582
583  function caretRect(el) {
584    var sel = window.getSelection();
585    if (!sel || !sel.rangeCount) return el.getBoundingClientRect();
586    var rect = sel.getRangeAt(0).cloneRange().getBoundingClientRect();
587    if (rect && (rect.left || rect.top)) return rect;
588    return el.getBoundingClientRect();
589  }
590
591  /** Find an open "{{partial" immediately before the caret in a contenteditable. */
592  function bodyOpenToken(field) {
593    var sel = window.getSelection();
594    if (!sel || !sel.rangeCount) return null;
595    var r = sel.getRangeAt(0);
596    if (!r.collapsed || r.startContainer.nodeType !== 3) return null;
597    if (!field.contains(r.startContainer)) return null;
598    var before = r.startContainer.textContent.slice(0, r.startOffset);
599    var idx = before.lastIndexOf('{{');
600    if (idx === -1) return null;
601    var between = before.slice(idx + 2);
602    if (!TOKEN_PARTIAL_RE.test(between)) return null;
603    return { node: r.startContainer, start: idx, end: r.startOffset, query: between };
604  }
605
606  /** The same, for a plain input/textarea. */
607  function textOpenToken(field) {
608    var pos = field.selectionStart;
609    if (pos == null) return null;
610    var before = String(field.value || '').slice(0, pos);
611    var idx = before.lastIndexOf('{{');
612    if (idx === -1) return null;
613    var between = before.slice(idx + 2);
614    if (!TOKEN_PARTIAL_RE.test(between)) return null;
615    return { start: idx, end: pos, query: between };
616  }
617
618  function bindOutside() {
619    if (outsideBound) return;
620    outsideBound = true;
621    document.addEventListener('click', function (e) {
622      instances.forEach(function (inst) {
623        if (!inst.menu || inst.menu.classList.contains('is-hidden')) return;
624        if (inst.menu.contains(e.target) || (inst.trigger && inst.trigger.contains(e.target))) return;
625        inst.closeMenu();
626      });
627    });
628  }
629
630  /**
631   * Mount a Fields dropdown and/or a "{{" autocomplete onto one field.
632   *
633   * config:
634   *   field      HTMLElement | () => HTMLElement   input, textarea or contenteditable
635   *   trigger    HTMLElement?                      the "Fields" button
636   *   menu       HTMLElement?                      its panel (created after the trigger if absent)
637   *   highlight  HTMLElement?                      overlay element for a subject input
638   *   mode       'pill' | 'text'                   defaults per field: pill for contenteditable
639   *   surface    string                            drives `only:` filtering
640   *   tokens     () => token[]                     defaults to list({ surface })
641   *   onChange   () => void                        e.g. commitStepEditsLive
642   *
643   * Everything is held as element references taken from `config` — never looked up by id at
644   * call time. Several of these modals are built once and LEFT IN THE DOM after close, so an
645   * id lookup silently targets the wrong (stale) field the moment a second one exists.
646   */
647  function attach(config) {
648    if (!hasDom || !config || !config.field) return { destroy: function () {}, refresh: function () {} };
649
650    var cfg = config;
651    var getField = typeof cfg.field === 'function' ? cfg.field : function () { return cfg.field; };
652    var probe = getField();
653    if (!probe) return { destroy: function () {}, refresh: function () {} };
654
655    // Resolved per insert, not once: with a `field` resolver one trigger can serve a plain
656    // subject input and a rich body, which need different insert modes.
657    var modeFor = function (field) {
658        return cfg.mode || (field && field.isContentEditable ? 'pill' : 'text');
659    };
660    var mode = modeFor(probe);
661    var surface = cfg.surface || '';
662    var getTokens = cfg.tokens || function () { return list({ surface: surface }); };
663    var onChange = cfg.onChange || function () {};
664    // Custom fields are recovered from the live tokens() provider so find()/highlight() can
665    // resolve {{field.<id>}} without a second source.
666    var lookup = function () { return { surface: surface, customFieldDefs: customDefsFrom(getTokens()) }; };
667    var savedRange = null;
668    var atomic = null;
669    var listeners = [];
670    var inst = { menu: cfg.menu || null, trigger: cfg.trigger || null, closeMenu: closeMenu };
671
672    function on(el, ev, fn) {
673      if (!el) return;
674      el.addEventListener(ev, fn);
675      listeners.push([el, ev, fn]);
676    }
677
678    // ---- insertion
679
680    function insertText(field, t) {
681      var text = tokenText(t.id);
682      var start = field.selectionStart != null ? field.selectionStart : field.value.length;
683      var end = field.selectionEnd != null ? field.selectionEnd : field.value.length;
684      field.value = field.value.slice(0, start) + text + field.value.slice(end);
685      var pos = start + text.length;
686      field.focus();
687      try { field.setSelectionRange(pos, pos); } catch (_) { /* unsupported on some inputs */ }
688      renderHighlight();
689      onChange();
690    }
691
692    function insertPill(field, t) {
693      field.focus();
694      var sel = window.getSelection();
695      if (savedRange && sel) {
696        sel.removeAllRanges();
697        sel.addRange(savedRange);
698      }
699      if (!sel) return;
700      if (!sel.rangeCount || !field.contains(sel.anchorNode)) {
701        // No usable caret — the field was never focused, or the saved range went stale with
702        // the node it pointed into. Append rather than dropping the pick on the floor.
703        var end = document.createRange();
704        end.selectNodeContents(field);
705        end.collapse(false);
706        sel.removeAllRanges();
707        sel.addRange(end);
708      }
709      insertPillAt(field, sel.getRangeAt(0), pillHtml(t));
710      if (sel.rangeCount && field.contains(sel.anchorNode)) {
711        savedRange = sel.getRangeAt(0).cloneRange();
712      }
713      onChange();
714    }
715
716    function insert(t) {
717      var field = getField();
718      if (!field || !t) return;
719      if (modeFor(field) === 'pill') insertPill(field, t);
720      else insertText(field, t);
721    }
722
723    // ---- subject highlight overlay
724
725    function renderHighlight() {
726      var field = getField();
727      if (!cfg.highlight || !field) return;
728      cfg.highlight.innerHTML = highlight(field.value || '', lookup());
729      cfg.highlight.scrollLeft = field.scrollLeft;
730    }
731
732    // ---- Fields dropdown
733
734    function closeMenu() {
735      if (!inst.menu) return;
736      inst.menu.classList.add('is-hidden');
737      if (inst.trigger) inst.trigger.setAttribute('aria-expanded', 'false');
738    }
739
740    function openMenu() {
741      if (!inst.menu) return;
742      instances.forEach(function (other) { if (other !== inst) other.closeMenu(); });
743      renderMenu(inst.menu, getTokens());
744      inst.menu.classList.remove('is-hidden');
745      if (inst.trigger) inst.trigger.setAttribute('aria-expanded', 'true');
746    }
747
748    if (inst.trigger && !inst.menu) {
749      inst.menu = document.createElement('div');
750      inst.menu.className = 'mft-menu is-hidden';
751      inst.menu.setAttribute('role', 'menu');
752      inst.trigger.insertAdjacentElement('afterend', inst.menu);
753    }
754
755    if (inst.trigger) {
756      on(inst.trigger, 'click', function (e) {
757        e.preventDefault();
758        e.stopPropagation();
759        if (inst.menu.classList.contains('is-hidden')) openMenu();
760        else closeMenu();
761      });
762    }
763
764    if (inst.menu) {
765      // mousedown + preventDefault preserves the caret in the field that had focus.
766      on(inst.menu, 'mousedown', function (e) {
767        var item = e.target && e.target.closest ? e.target.closest('.mft-item') : null;
768        if (!item || !inst.menu.contains(item)) return;
769        e.preventDefault();
770        var t = find(item.getAttribute('data-token-id'), lookup());
771        if (t) insert(t);
772        closeMenu();
773      });
774    }
775
776    // ---- inline "{{" autocomplete
777
778    function acUpdate() {
779      var field = getField();
780      if (!field) return acHide();
781      var ctx = mode === 'pill' ? bodyOpenToken(field) : textOpenToken(field);
782      if (!ctx) return acHide();
783      var matches = filter(ctx.query, lookup());
784      if (!matches.length) return acHide();
785      ac.owner = inst;
786      ac.matches = matches;
787      ac.active = 0;
788      acRender();
789      if (mode === 'pill') {
790        var rect = caretRect(field);
791        acPositionAt(rect.left, rect.bottom);
792      } else {
793        var r = field.getBoundingClientRect();
794        acPositionAt(r.left + 6, r.bottom);
795      }
796    }
797
798    function acAccept(t) {
799      var field = getField();
800      if (!t || !field) return acHide();
801      if (mode === 'pill') {
802        var ctx = bodyOpenToken(field);
803        if (!ctx) return acHide();
804        var sel = window.getSelection();
805        var r = document.createRange();
806        r.setStart(ctx.node, ctx.start);
807        r.setEnd(ctx.node, ctx.end);
808        sel.removeAllRanges();
809        sel.addRange(r);
810        insertPillAt(field, r, pillHtml(t));
811        if (sel.rangeCount && field.contains(sel.anchorNode)) {
812          savedRange = sel.getRangeAt(0).cloneRange();
813        }
814        onChange();
815      } else {
816        var tctx = textOpenToken(field);
817        if (!tctx) return acHide();
818        var text = tokenText(t.id);
819        field.value = field.value.slice(0, tctx.start) + text + field.value.slice(tctx.end);
820        var caret = tctx.start + text.length;
821        field.setSelectionRange(caret, caret);
822        field.focus();
823        renderHighlight();
824        onChange();
825      }
826      acHide();
827    }
828
829    function acKeydown(e) {
830      if (ac.owner !== inst || !ac.matches.length) return;
831      var k = e.key;
832      if (k === 'ArrowDown') {
833        ac.active = (ac.active + 1) % ac.matches.length;
834        acRender();
835        e.preventDefault();
836      } else if (k === 'ArrowUp') {
837        ac.active = (ac.active - 1 + ac.matches.length) % ac.matches.length;
838        acRender();
839        e.preventDefault();
840      } else if (k === 'Enter' || k === 'Tab') {
841        acAccept(ac.matches[ac.active]);
842        e.preventDefault();
843      } else if (k === 'Escape') {
844        acHide();
845        e.preventDefault();
846        e.stopPropagation(); // don't let the host modal close too
847      }
848    }
849
850    // The autocomplete menu is shared, so its picks are routed to whichever instance owns it.
851    if (!acEl().dataset.mftBound) {
852      acEl().dataset.mftBound = '1';
853      acEl().addEventListener('mousedown', function (e) {
854        var item = e.target && e.target.closest ? e.target.closest('.mft-item') : null;
855        if (!item) return;
856        e.preventDefault();
857        var owner = ac.owner;
858        var picked = null;
859        ac.matches.some(function (x) {
860          if (x.id === item.getAttribute('data-token-id')) { picked = x; return true; }
861          return false;
862        });
863        if (owner && picked) owner.acAccept(picked);
864      });
865    }
866    inst.acAccept = acAccept;
867
868    // ---- field wiring
869
870    on(probe, 'keydown', acKeydown);
871    on(probe, 'blur', function () { setTimeout(acHide, 120); });
872
873    if (mode === 'pill') {
874      // Remember the caret so a Fields pick lands where the user left off even though
875      // clicking the trigger steals focus.
876      var saveRange = function () {
877        var field = getField();
878        var sel = window.getSelection();
879        if (field && sel && sel.rangeCount && field.contains(sel.anchorNode)) {
880          savedRange = sel.getRangeAt(0).cloneRange();
881        }
882      };
883      ['keyup', 'mouseup', 'blur'].forEach(function (ev) { on(probe, ev, saveRange); });
884      on(probe, 'input', acUpdate);
885      // Every pill in this editor — the ones deserialize() loaded and the ones inserted
886      // later — edits as one character.
887      atomic = makeAtomic(probe);
888    } else {
889      on(probe, 'input', function () { renderHighlight(); acUpdate(); });
890      on(probe, 'click', acUpdate);
891      on(probe, 'keyup', function (e) {
892        if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].indexOf(e.key) !== -1) acUpdate();
893      });
894      if (cfg.highlight) {
895        on(probe, 'scroll', function () { cfg.highlight.scrollLeft = probe.scrollLeft; });
896        renderHighlight();
897      }
898    }
899
900    instances.push(inst);
901    bindOutside();
902
903    return {
904      insert: insert,
905      refresh: renderHighlight,
906      destroy: function () {
907        listeners.forEach(function (l) { l[0].removeEventListener(l[1], l[2]); });
908        listeners = [];
909        if (atomic) { atomic.destroy(); atomic = null; }
910        if (ac.owner === inst) acHide();
911        var i = instances.indexOf(inst);
912        if (i !== -1) instances.splice(i, 1);
913      },
914    };
915  }
916
917  /** Recover the custom-field defs a tokens() provider baked in, so filter() sees them too. */
918  function customDefsFrom(tokens) {
919    return (Array.isArray(tokens) ? tokens : [])
920      .filter(function (t) { return t && t.def; })
921      .map(function (t) { return t.def; });
922  }
923
924  return {
925    TOKENS: TOKENS,
926    GROUPS: GROUPS,
927    CUSTOM_PREFIX: CUSTOM_PREFIX,
928    ALIASES: ALIASES,
929    TOKEN_NAME: TOKEN_NAME,
930    TOKEN_RE_SOURCE: TOKEN_RE_SOURCE,
931    TOKEN_PARTIAL_RE: TOKEN_PARTIAL_RE,
932    tokenRe: tokenRe,
933    tokenText: tokenText,
934    escapeHtml: escapeHtml,
935    normalizeId: normalizeId,
936    customTokens: customTokens,
937    all: all,
938    list: list,
939    find: find,
940    findExact: findExact,
941    isKnown: isKnown,
942    filter: filter,
943    sampleValue: sampleValue,
944    substitute: substitute,
945    highlight: highlight,
946    pillHtml: pillHtml,
947    serialize: serialize,
948    deserialize: deserialize,
949    attach: attach,
950    // Shared with playbooks.js: a second pill registry, the same object behaviour.
951    insertPillAt: insertPillAt,
952    makeAtomic: makeAtomic,
953  };
954});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.