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, '&') 115 .replace(/</g, '<') 116 .replace(/>/g, '>') 117 .replace(/"/g, '"') 118 .replace(/'/g, '''); 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.