1jQuery(function ($) { 2 3 'use strict'; 4 5 /* ----------------------------------------------------------------------- 6 * Range sliders 7 * -------------------------------------------------------------------- */ 8 9 function initRangeSliders(inputs) { 10 inputs.each(function () { 11 const input = $(this); 12 input.prev('.hf-range-slider').slider({ 13 value: input.val(), 14 min: parseFloat(input.attr('min')), 15 max: parseFloat(input.attr('max')), 16 step: parseFloat(input.attr('step')), 17 range: 'min', 18 slide: function (e, ui) { 19 $(this).next().val(ui.value); 20 } 21 }); 22 }); 23 } 24 25 initRangeSliders($('.hf-range-input-selector')); 26 27 // Update the slider when the input loses focus, as it has most likely changed. 28 $('.hf-range-input-selector').on('blur', function () { 29 const input = $(this); 30 let value = isNaN(input.val()) ? '' : input.val(); 31 32 // Keep a manually typed value within the min/max bounds. 33 if (value) { 34 const min = parseFloat(input.attr('min')); 35 const max = parseFloat(input.attr('max')); 36 if (value < min) { 37 value = min; 38 } 39 if (value > max) { 40 value = max; 41 } 42 } 43 44 input.val(value); 45 input.prev('.hf-range-slider').slider('value', value); 46 }); 47 48 /* ----------------------------------------------------------------------- 49 * Form submission 50 * -------------------------------------------------------------------- */ 51 52 // Scroll to the field holding the first validation error. 53 function firstErrorField(errors) { 54 const firstError = Object.keys(errors)[0]; 55 const separator = firstError.indexOf('-'); 56 57 if (separator > 0) { 58 const fieldId = firstError.slice(0, separator).replace('field', ''); 59 const subField = firstError.slice(separator + 1); 60 return $('#hf-subfield-container-' + subField + '-' + fieldId); 61 } 62 63 return $('#hf-field-container-' + firstError.replace('field', '')); 64 } 65 66 /** 67 * Put a captcha back to its unanswered state. 68 * 69 * Each service's script is loaded whenever a field of its kind is on the 70 * form, whether or not a site key was set, so the script can be present 71 * with nothing rendered â grecaptcha.reset() throws "No reCAPTCHA clients 72 * exist" in that case. That used to abandon the rest of the failure 73 * handler, which is why an hCaptcha widget kept its spent token and the 74 * visitor's second attempt failed on a token already used up. 75 */ 76 function resetCaptcha(name) { 77 const api = window[name]; 78 79 if (!api || typeof api.reset !== 'function') { 80 return; 81 } 82 83 try { 84 api.reset(); 85 } catch (e) { 86 // Nothing rendered to reset. 87 } 88 } 89 90 function resetRecaptcha() { 91 resetCaptcha('grecaptcha'); 92 resetCaptcha('hcaptcha'); 93 resetCaptcha('turnstile'); 94 } 95 96 /** 97 * Never render an empty failure notice. 98 * 99 * A form whose Error Message setting is blank was appending an empty span, 100 * which reads to the visitor exactly like the submission having been ignored. 101 */ 102 function failureText(message) { 103 return (typeof message === 'string' && message.trim()) 104 ? message 105 : hashform_vars.generic_error; 106 } 107 108 /** 109 * Tie an error message to the control it belongs to. 110 * 111 * The message used to be appended as raw html with no relationship to the 112 * input at all, so a screen reader never announced it: the field stayed 113 * "valid" as far as assistive technology was concerned and the only sign 114 * anything had gone wrong was a red line someone had to see. The message 115 * now gets an id, the control points at it and is marked invalid, and the 116 * message is announced when it appears. 117 */ 118 function attachError(container, key, message) { 119 const errorId = 'hf-error-' + key; 120 const msg = $('<span/>', { 121 'class': 'hf-error-msg', 122 id: errorId, 123 role: 'alert' 124 }).text(message); 125 126 container.addClass('hashform-error-container').append(msg); 127 128 // Every control the field owns - a composite field has several. 129 const inputs = container.find('input, select, textarea') 130 .not('[type="hidden"], [type="submit"], [type="button"]'); 131 132 inputs.attr('aria-invalid', 'true'); 133 134 // Keep any describedby the field already had; the help text is still 135 // relevant when the answer is wrong. 136 inputs.each(function () { 137 const input = $(this); 138 const described = (input.attr('aria-describedby') || '').split(/\s+/).filter(
138Boolean); 139 140 if (described.indexOf(errorId) === -1) { 141 described.push(errorId); 142 input.attr('aria-describedby', described.join(' ')); 143 } 144 }); 145 146 return inputs.first(); 147 } 148 149 function showValidationErrors(errors) { 150 let firstInput = $(); 151 152 $.each(errors, function (key, message) { 153 const container = $('#hf-field-container-' + key.replace('field', '')); 154 155 if (!container.length) { 156 return; 157 } 158 159 const input = attachError(container, key, message); 160 161 if (!firstInput.length) { 162 firstInput = input; 163 } 164 }); 165 166 resetRecaptcha(); 167 168 const target = firstErrorField(errors); 169 170 if (!target || !target.length) { 171 return; 172 } 173 174 $('html, body').animate({ 175 scrollTop: target.offset().top - 300 176 }, 300, function () { 177 // Put the caret where the problem is, so a keyboard user is not 178 // dropped back at the top of the form to hunt for it. 179 if (firstInput.length && firstInput.is(':visible')) { 180 firstInput.trigger('focus'); 181 } 182 }); 183 } 184 185 /** 186 * Clear the previous attempt's error state, markers included. 187 */ 188 function clearErrors(form) { 189 const scope = form && form.length ? form : $(document); 190 191 scope.find('.hf-error-msg, .hf-success-msg, .hf-failed-msg').remove(); 192 scope.find('.hashform-error-container').removeClass('hashform-error-container'); 193 scope.find('[aria-invalid]').removeAttr('aria-invalid'); 194 195 // Drop only the ids this script added, leaving the field's own help 196 // text still linked. 197 scope.find('[aria-describedby]').each(function () { 198 const input = $(this); 199 const kept = (input.attr('aria-describedby') || '') 200 .split(/\s+/) 201 .filter(function (id) { 202 return id && id.indexOf('hf-error-') !== 0; 203 }); 204 205 if (kept.length) { 206 input.attr('aria-describedby', kept.join(' ')); 207 } else { 208 input.removeAttr('aria-describedby'); 209 } 210 }); 211 } 212 213 /** 214 * A whole-form notice, announced rather than merely drawn. 215 */ 216 function appendNotice(form, cls, message, assertive) { 217 $('<span/>', { 218 'class': cls, 219 role: assertive ? 'alert' : 'status', 220 'aria-live': assertive ? 'assertive' : 'polite' 221 }).text(message).appendTo(form); 222 } 223 224 function resetForm(form) { 225 form.trigger('reset'); 226 form.find('.hf-star-rating').removeClass('hf-star-checked'); 227 initRangeSliders(form.find('.hf-range-input-selector')); 228 $('body').find('.hf-preview-remove').trigger('click'); 229 } 230 231 /** 232 * A reCAPTCHA v3 token for this form, or '' when there is nothing to fetch. 233 * 234 * Scoped to the form being submitted. The widget used to be looked up as 235 * $('.g-recaptcha') â the first one in the document â so with two forms on 236 * a page one of them read the other's settings and took a path meant for a 237 * captcha it did not have. 238 */ 239 function captchaToken(form) { 240 const captcha = form.find('.g-recaptcha'); 241 const siteKey = captcha.attr('data-sitekey'); 242 const isV3 = captcha.attr('data-size') === 'invisible'; 243 244 if (!isV3 || !siteKey || typeof grecaptcha === 'undefined') { 245 return $.Deferred().resolve('').promise(); 246 } 247 248 const pending = $.Deferred(); 249 250 grecaptcha.ready(function () { 251 grecaptcha.execute(siteKey, { action: 'hashform' }).then( 252 (token) => pending.resolve(token), 253 // Let the server say what went wrong rather than stalling here. 254 () => pending.resolve('') 255 ); 256 }); 257 258 return pending.promise(); 259 } 260 261 $(document).on('submit.hashform-form', '.hashform-form', function (e) { 262 e.preventDefault(); 263 264 const form = $(this); 265 const submitButton = form.find('button.hf-submit-button'); 266 267 // Ignore repeat submits while a request is in flight. 268 if (submitButton.hasClass('hf-button-loading')) { 269 return; 270 } 271 submitButton.addClass('hf-button-loading'); 272 273 clearErrors(form); 274 275 /* 276 * Wait for the token itself rather than for a second on the clock. 277 * 278 * A fixed one-second setTimeout used to wrap this whole block, so 279 * every submission of every form waited a second whether a captcha was 280 * on the page or not, and a token that took longer than that was 281 * dropped: the form posted an empty response and the visitor was told 282 * the reCAPTCHA was not entered correctly, with no way to get past it. 283 */ 284 captchaToken(form).then((token) => { 285 const data = form.serializeArray(); 286 287 if (token) { 288 const existing = data.find((item) => item.name === 'g-recaptcha-response'); 289 290 if (existing) { 291 existing.value = existing.value || token; 292 } else { 293 data.push({ name: 'g-recaptcha-response', value: token }); 294 } 295 } 296 297 $.ajax({ 298 type: 'POST', 299 url: hashform_vars.ajaxurl, 300 dataType: 'json', 301 data: { 302 action: 'hashform_process_entry', 303 data: $.param(data), 304 location: window.location.href 305 }, 306 success: function (response) { 307 submitButton.removeClass('hf-button-loading'); 308 309 if (!response || typeof response !== 'object') { 310 resetRecaptcha(); 311 form.trigger('hashform:failed'); 312 appendNotice(form, 'hf-failed-msg', failureText(''), true); 313 return; 314 } 315 316 /* 317 * Announce the outcome on the form itself. An add-on - 318 * the payment script is the one that needs this - can 319 * then follow a submission it does not own, instead of 320 * having to wrap or replace this handler. 321 */ 322 form.trigger('hashform:' + response.status, [response]); 323 324 if (response.status === 'redirect') { 325 window.location.replace(response.message); 326 } else if (response.status === 'success') { 327 resetForm(form); 328 329 // An empty confirmation means the server already said 330 // its piece; do not draw an empty box for it. 331 if (typeof response.message === 'string' && response.message.trim()) { 332 appendNotice(form, 'hf-success-msg', response.message, false); 333 } 334 } else if (response.status === 'failed') { 335 resetRecaptcha(); 336 appendNotice(form, 'hf-failed-msg', failureText(response.message), true); 337 } else if (response.message && typeof response.message === 'object') { 338 /* 339 * A captcha that was not passed comes back as a field 340 * error like any other, and this branch never reset the 341 * widget â so the token stayed in the form, already 342 * spent, and the visitor's next attempt failed on the 343 * same token however carefully they answered it. 344 */ 345 resetRecaptcha(); 346 showValidationErrors(response.message); 347 } else { 348 // status:'error' normally carries an object of per-field 349 // errors, but the spam checks reject a whole submission
350 // with a plain string. $.each on a string throws, which 351 // left the visitor looking at a form that appeared to do 352 // nothing at all. 353 resetRecaptcha(); 354 appendNotice(form, 'hf-failed-msg', failureText(response.message), true); 355 } 356 }, 357 /* 358 * There was no error handler at all, so a request that never 359 * came back - a 500, a dropped connection, a security plugin 360 * eating the post - left the button spinning for good with no 361 * explanation and no way to try again. 362 */ 363 error: function () { 364 submitButton.removeClass('hf-button-loading'); 365 resetRecaptcha(); 366 form.trigger('hashform:failed'); 367 appendNotice(form, 'hf-failed-msg', failureText(''), true); 368 } 369 }); 370 }); 371 }); 372 373 /* ----------------------------------------------------------------------- 374 * Spinner field 375 * -------------------------------------------------------------------- */ 376 377 $('.hashform-field-type-spinner .hf-quantity .mdi-plus').on('click', function () { 378 const input = $(this).closest('.hashform-field-type-spinner').find('input'); 379 const max = input.attr('max'); 380 const value = Number(input.val()); 381 input.val(value < max ? value + 1 : max); 382 }); 383 384 $('.hashform-field-type-spinner .hf-quantity .mdi-minus').on('click', function () { 385 const input = $(this).closest('.hashform-field-type-spinner').find('input'); 386 const min = input.attr('min'); 387 const value = Number(input.val()); 388 input.val(value > min ? value - 1 : min); 389 }); 390 391 /* ----------------------------------------------------------------------- 392 * Star rating field 393 * -------------------------------------------------------------------- */ 394 395 $(document).on('click', '.hashform-star-group input', function () { 396 $(this).closest('.hashform-star-group').find('.hf-star-rating').removeClass('hf-star-checked'); 397 $(this).parent('.hf-star-rating').prevAll('.hf-star-rating').addBack().addClass('hf-star-checked'); 398 }); 399 400 $(document).on('mouseenter', '.hashform-star-group .hf-star-rating:not(.hf-star-rating-readonly)', function () { 401 $(this).prevAll('.hf-star-rating').addBack().addClass('hf-star-hovered'); 402 $(this).nextAll('.hf-star-rating').addClass('hf-star-non-hovered'); 403 }); 404 405 $(document).on('mouseleave', '.hashform-star-group .hf-star-rating:not(.hf-star-rating-readonly)', function () { 406 $(this).closest('.hashform-star-group').find('.hf-star-rating').removeClass('hf-star-hovered hf-star-non-hovered'); 407 }); 408 409 /* ----------------------------------------------------------------------- 410 * Date and time fields 411 * -------------------------------------------------------------------- */ 412 413 /** 414 * Carry a form's style tokens onto a picker panel. 415 * 416 * The per form --hf-* properties are declared on #hf-container-{id}, but 417 * both pickers move their panel to <body> as they open, so neither is a 418 * descendant of the form and neither inherits any of them. Copying the few 419 * that matter across on open is what lets a picker match the form that was 420 * clicked, which also keeps two differently styled forms on one page from 421 * sharing a palette. Anything the form leaves unset is skipped so the 422 * stylesheet fallback stays in play. 423 */ 424 function bridgePickerStyles(input, panel) { 425 const container = input.closest('[id^="hf-container-"]'); 426 if (!container.length || !panel || !panel.length) { 427 return; 428 } 429 430 const from = window.getComputedStyle(container[0]); 431 const accent = from.getPropertyValue('--hf-field-border-color-focus').trim() 432 || from.getPropertyValue('--hf-button-bg-color-normal').trim(); 433 434 const tokens = {
435 '--hf-pick-accent': accent, 436 '--hf-pick-text': from.getPropertyValue('--hf-field-color-normal').trim(), 437 '--hf-pick-font': from.getPropertyValue('--hf-field-typo-font-family').trim() 438 }; 439 440 Object.keys(tokens).forEach(function (name) { 441 if (tokens[name]) { 442 panel[0].style.setProperty(name, tokens[name]); 443 } else { 444 panel[0].style.removeProperty(name); 445 } 446 }); 447 } 448 449 $('.hashform-field-type-date input').each(function () { 450 const input = $(this); 451 const format = input.attr('data-format'); 452 const value = input.val(); 453 454 if (value) { 455 const date = new Date(value); 456 const momentFormat = format.replace('dd', 'DD').replace('MM', 'MMMM').replace('mm', 'MM'); 457 input.val(Number.isNaN(date.getTime()) ? '' : moment(date).format(momentFormat)); 458 } 459 460 input.datepicker({ 461 changeMonth: true, 462 dateFormat: format, 463 beforeShow: function () { 464 bridgePickerStyles(input, $('#ui-datepicker-div')); 465 } 466 }); 467 }); 468 469 $('.hashform-field-type-time .hf-timepicker').each(function () { 470 const input = $(this); 471 472 // Step, Min Time and Max Time are set per field in the builder and 473 // printed as data attributes. The library does not read those itself, 474 // so they have to be passed in or the field silently lists every hour 475 // of the day whatever the user configured. 476 const step = parseInt(input.attr('data-step'), 10); 477 const minTime = input.attr('data-min-time'); 478 const maxTime = input.attr('data-max-time'); 479 480 const options = { 481 showDuration: false, 482 timeFormat: 'g:ia' 483 }; 484 485 // The library reads step as minutes, which is the unit the stored 486 // default of 60 already assumes: one slot per hour. 487 if (step > 0) { 488 options.step = step; 489 } 490 if (minTime) { 491 options.minTime = minTime; 492 } 493 if (maxTime) { 494 options.maxTime = maxTime; 495 } 496 497 input.timepicker(options); 498 499 // The library has no beforeShow hook, but it does fire showTimepicker 500 // on the input, and only one list is ever open at a time. 501 input.on('showTimepicker', function () { 502 bridgePickerStyles(input, $('.ui-timepicker-wrapper:visible').first()); 503 }); 504 }); 505 506 /* ----------------------------------------------------------------------- 507 * Conditional logic 508 * -------------------------------------------------------------------- */ 509 510 // Compare a single configured value against the set of checked values. 511 function arrayValsCompare(compareValue, arrayVals, condition) { 512 switch (condition) { 513 case 'equal': 514 return arrayVals.includes(compareValue); 515 case 'less_than': 516 return arrayVals.length > 0 && arrayVals.every((val) => compareValue > val); 517 case 'less_than_or_equal': 518 return arrayVals.length > 0 && arrayVals.every((val) => compareValue >= val); 519 case 'greater_than': 520 return arrayVals.length > 0 && arrayVals.every((val) => compareValue < val); 521 case 'greater_than_or_equal': 522 return arrayVals.length > 0 && arrayVals.every((val) => compareValue <= val); 523 case 'is_like': 524 return arrayVals.some((val) => val.indexOf(compareValue) >= 0); 525 default: 526 return false; 527 } 528 } 529 530 // Returns whether the condition holds, or null when the condition is unknown. 531 function conditionMatches(condition, value, compareValue, arrayVals, isArrayVals) { 532 switch (condition) { 533 case 'equal': 534 case 'not_equal': { 535 const equal = isArrayVals 536 ? arrayValsCompare(compareValue, arrayVals, 'equal') 537 : arrayValsCompare(value, compareValue.split(/\s*,\s*/), 'equal'); 538 return condition === 'equal' ? equal : !equal; 539 } 540 541 case 'less_than': 542 case 'less_than_or_equal': 543 case 'greater_than': 544 case 'greater_than_or_equal': { 545 if (isArrayVals) { 546 return arrayValsCompare(compareValue, arrayVals, condition); 547 } 548 const number = (value == '') ? 0 : parseInt(value, 10); 549 if (condition === 'less_than') { 550 return number < compareValue; 551 } 552 if (condition === 'less_than_or_equal') { 553 return number <= compareValue; 554 } 555 if (condition === 'greater_than') { 556 return number > compareValue; 557 } 558 return number >= compareValue; 559 } 560 561 case 'is_like': 562 case 'is_not_like': { 563 const like = isArrayVals 564 ? arrayValsCompare(compareValue, arrayVals, 'is_like') 565 : value.indexOf(compareValue) >= 0;
566 return condition === 'is_like' ? like : !like; 567 } 568 569 default: 570 return null; 571 } 572 } 573 574 $('.hashform-form-conditions').each(function () { 575 const parentForm = $(this).closest('form'); 576 577 $.each(JSON.parse($(this).val()), function (index, val) { 578 const actionField = parentForm.find('#hf-field-container-' + val.compare_from); 579 const showOnMatch = val.condition_action === 'show'; 580 581 // Fields that accept several values post as item_meta[id][]. 582 let conditionTrigger = parentForm.find('[name="item_meta[' + val.compare_to + ']'); 583 let isArrayVals = false; 584 if (!(conditionTrigger.length > 0)) { 585 conditionTrigger = parentForm.find('[name="item_meta[' + val.compare_to + '][]'); 586 isArrayVals = true; 587 } 588 589 conditionTrigger.on('change', function () { 590 const trigger = $(this); 591 let value = trigger.val(); 592 let arrayVals = []; 593 594 if (isArrayVals) { 595 arrayVals = conditionTrigger.map(function () { 596 return $(this).is(':checked') ? $(this).val() : null; 597 }).toArray(); 598 } 599 600 // An unchecked checkbox posts nothing, so treat it as empty. 601 if (trigger.attr('type') === 'checkbox' && !trigger.is(':checked')) { 602 value = ''; 603 } 604 605 const matched = conditionMatches(val.compare_condition, value, val.compare_value, arrayVals, isArrayVals); 606 607 if (matched !== null && actionField.length) { 608 actionField.toggle(matched === showOnMatch); 609 } 610 }).trigger('change'); 611 }); 612 }); 613 614 /* ----------------------------------------------------------------------- 615 * Field focus styling 616 * -------------------------------------------------------------------- */ 617 618 $('.hf-field-content input, .hf-field-content select, .hf-field-content textarea').on('focus', function () { 619 $(this).parent().addClass('hf-field-focussed'); 620 }).on('focusout', function () { 621 $(this).parent().removeClass('hf-field-focussed'); 622 }); 623 624 /* ----------------------------------------------------------------------- 625 * File uploads 626 * -------------------------------------------------------------------- */ 627 628 // Which uploads get a thumbnail preview rather than just a filename. Kept 629 // in step with the formats the server accepts. 630 const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp']; 631 632 // Drawn with currentColor so it follows whatever the dropzone text is. 633 const UPLOAD_ICON = '<svg class="hf-upload-dropzone-icon" width="28" height="28" viewBox="0 0 24 24" fill="none"' 634 + ' stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"' 635 + ' aria-hidden="true" focusable="false">' 636 + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>' 637 + '<polyline points="17 8 12 3 7 8"/>' 638 + '<line x1="12" y1="3" x2="12" y2="15"/>' 639 + '</svg>'; 640 641 // The label and extension list are author supplied, and they are being 642 // concatenated into a template string rather than set as text. 643 function escapeHtml(value) { 644 return String(value).replace(/[&<>"']/g, function (char) { 645 return { 646 '&': '&', 647 '<': '<', 648 '>': '>', 649 '"': '"', 650 "'": ''' 651 }[char]; 652 }); 653 } 654 655 // Bytes to something a person reads. The library has its own _formatSize, 656 // but it is a private method on the instance and this runs before one 657 // exists. 658 function formatUploadSize(bytes) { 659 const size = Number(bytes); 660 661 if (!size || size < 0) { 662 return ''; 663 } 664 if (size >= 1024 * 1024 * 1024) { 665 return Math.round(size / (1024 * 1024 * 1024) * 10) / 10 + ' GB'; 666 } 667 if (size >= 1024 * 1024) { 668 return Math.round(size / (1024 * 1024)) + ' MB'; 669 } 670 return Math.max(1, Math.round(size / 1024)) + ' KB'; 671 } 672 673 // Holds each uploader instance for the lifetime of the page. 674 const uploaders = []; 675 676 $('.hf-file-uploader').each(function () { 677 const element = $(this); 678 const elementId = element.attr('id'); 679 680 /* 681 * The builder canvas and the style preview draw a static copy of the 682 * uploader to show what it looks like, without an id or any of the 683 * settings. There is nothing to start up on one of those â and reading 684 * its extensions threw, which stopped every uploader after it on the 685 * page from being set up at all. 686 */ 687 if (!elementId) { 688 return; 689 } 690 691 const sizeLimit = element.attr('data-max-upload-size'); 692 const minSizeLimit = Number(element.attr('data-min-upload-size')) || 0; 693 const uploaderLabel = element.attr('data-upload-label'); 694 const multipleUpload = element.attr('data-multiple-uploads') === 'true'; 695 const extensions = (element.attr('data-extensions') || '').split(',').filter(
695Boolean); 696 const extensionErrorMessage = element.attr('data-extensions-error-message'); 697 698 let uploadLimit = element.attr('data-multiple-uploads-limit'); 699 let uploadLimitMessage = element.attr('data-multiple-uploads-error-message'); 700 uploadLimit = uploadLimit < 1 ? 1 : uploadLimit; 701 702 const wrapper = () => $('#' + elementId).closest('.hf-file-uploader-wrapper'); 703 704 // The constraints are already enforced, but nowhere on the page said 705 // what they were, so the first a visitor heard about a limit was an 706 // alert() after picking a file. Spelling them out on the dropzone is 707 // the whole reason it is worth having one. 708 const constraints = []; 709 710 if (extensions.length && extensions[0] !== '') { 711 constraints.push(extensions.map((ext) => ext.trim().toUpperCase()).join(', ')); 712 } 713 if (minSizeLimit > 0) { 714 constraints.push(formatUploadSize(minSizeLimit) + ' to ' + formatUploadSize(sizeLimit)); 715 } else if (sizeLimit > 0) { 716 constraints.push('up to ' + formatUploadSize(sizeLimit)); 717 } 718 if (multipleUpload && uploadLimit > 0) { 719 constraints.push(uploadLimit + ' files max'); 720 } 721 722 // Filters the operating system's own file dialog. Without it a JPG only 723 // field still offers every file on the machine and the rule is only 724 // discovered after picking one. 725 const acceptFiles = extensions 726 .map((ext) => ext.trim()) 727 .filter(Boolean) 728 .map((ext) => '.' + ext.toLowerCase()) 729 .join(','); 730 731 // One place for every rejection, so a failure reads on the form instead 732 // of in a browser dialog the visitor has to dismiss before continuing. 733 const showUploadError = (message) => { 734 const box = wrapper().find('.hf-upload-error'); 735 736 if (!box.length) { 737 window.alert(message); 738 return; 739 } 740 741 box.text(message).addClass('hf-upload-error-visible'); 742 }; 743 744 const clearUploadError = () => { 745 wrapper().find('.hf-upload-error').text('').removeClass('hf-upload-error-visible'); 746 }; 747 748 const dropTitle = multipleUpload 749 ? 'Drag and drop your files here' 750 : 'Drag and drop your file here'; 751 752 uploaders.push(new qq.FileUploader({ 753 element: document.getElementById(elementId), 754 action: hashform_vars.ajaxurl, 755 params: { 756 action: 'hashform_file_upload_action', 757 file_uploader_nonce: hashform_vars.ajax_nounce, 758 allowedExtensions: extensions, 759 sizeLimit: sizeLimit 760 }, 761 allowedExtensions: extensions, 762 sizeLimit: sizeLimit, 763 // 50 bytes is the old hardcoded floor, kept as the default so a 764 // field with no minimum set behaves exactly as it did. 765 minSizeLimit: minSizeLimit > 0 ? minSizeLimit : 50, 766 acceptFiles: acceptFiles, 767 uploadButtonText: uploaderLabel, 768 multiple: multipleUpload, 769 770 // The stock template was a bare grey button with a 300px drop area 771 // that only existed mid-drag. .qq-upload-button is kept as a real 772 // button inside the card rather than becoming the card itself, so 773 // the --hf-upload-* settings a site has already configured in the 774 // styler keep applying to exactly what they were configured for. 775 template: '<div class="qq-uploader">' + 776 '<div class="hf-upload-dropzone">' + 777 UPLOAD_ICON + 778 '<span class="hf-upload-dropzone-title">' + escapeHtml(dropTitle) + '</span>' + 779 '<span class="hf-upload-dropzone-or">or</span>' + 780 '<div class="qq-upload-button">{uploadButtonText}</div>' + 781 (constraints.length 782 ? '<span class="hf-upload-dropzone-hint">' + escapeHtml(constraints.join(' · ')) + '</span>' 783 : '') + 784 '<div class="qq-upload-drop-area"><span>{dragText}</span></div>' + 785 '</div>' + 786 '<div class="hf-upload-error" role="alert" aria-live="polite"></div>' + 787 '<ul class="qq-upload-list"></ul>' + 788 '</div>', 789 790 // The progress bar is wrapped in a track so it has something to run 791 // against. _find() resolves by class at any depth, so nesting is 792 // safe and the library still drives the width. 793 fileTemplate: '<li>' + 794 '<span class="hf-file-row">' + 795 '<span class="qq-upload-file"></span>' + 796 '<span class="qq-upload-spinner"></span>' + 797 '<span class="qq-upload-size"></span>' + 798 '<span class="qq-upload-failed-text">{failUploadtext}</span>' + 799 '<a class="qq-upload-cancel" href="#">{cancelButtonText}</a>' + 800 '</span>' + 801 '<span class="hf-progress-track"><span class="qq-progress-bar"></span></span>' + 802 '</li>', 803 804 onSubmit: function (id, fileName) { 805 clearUploadError(); 806 807 if (!multipleUpload || uploadLimit == -1) { 808 return; 809 } 810 811 const counter = element.parent().find('.hf-multiple-upload-limit'); 812 const used = Number(counter.val()) + 1; 813 counter.val(used); 814 815 if (used > uploadLimit) { 816 uploadLimitMessage = uploadLimitMessage !== '' 817 ? uploadLimitMessage 818 : 'Maximum number of files allowed is ' + uploadLimit; 819 showUploadError(uploadLimitMessage); 820 counter.val(uploadLimit); 821 return false;
822 } 823 }, 824 825 onComplete: function (id, fileName, responseJSON) { 826 if (!responseJSON.success) { 827 // The server rejected it. Its own message is surfaced by 828 // showMessage; this only guards against a silent failure. 829 if (!responseJSON.error) { 830 showUploadError('That file could not be uploaded. Please try again.'); 831 } 832 return; 833 } 834 835 clearUploadError(); 836 837 const extension = fileName.split('.').pop(); 838 const previewImage = IMAGE_EXTENSIONS.includes(extension.toLowerCase()) ? responseJSON.url : ''; 839 840 let previewHtml = '<div class="hf-prev-holder" id="hf-uploaded-' + id + '">'; 841 if (previewImage) { 842 previewHtml += '<img src="' + previewImage + '" />'; 843 } 844 previewHtml += '<span class="hf-prev-name">' + fileName + '</span></div>'; 845 846 const uploadedFiles = wrapper().find('.hf-uploaded-files'); 847 848 if (multipleUpload) { 849 const existing = uploadedFiles.val(); 850 uploadedFiles.val(existing === '' ? responseJSON.url : existing + ',' + responseJSON.url); 851 wrapper().find('.hf-file-preview').append(previewHtml); 852 } else { 853 uploadedFiles.val(responseJSON.url); 854 wrapper().find('.hf-file-preview').html(previewHtml); 855 } 856 }, 857 858 messages: { 859 typeError: extensionErrorMessage, 860 sizeError: '{file} is too large, maximum file size is {sizeLimit}.', 861 minSizeError: '{file} is too small, minimum file size is {minSizeLimit}.', 862 emptyError: '{file} is empty, please select files again without it.', 863 onLeave: 'The files are being uploaded, if you leave now the upload will be cancelled.' 864 }, 865 866 showMessage: function (message) { 867 showUploadError(message); 868 } 869 })); 870 }); 871 872 $('body').on('click', '.hf-preview-remove', function () { 873 const button = $(this); 874 875 $.ajax({ 876 url: hashform_vars.ajaxurl, 877 type: 'post', 878 data: 'action=hashform_file_delete_action&path=' + button.data('path') + '&_wpnonce=' + hashform_vars.ajax_nounce, 879 success: function (res) { 880 if (res !== 'success') { 881 return; 882 } 883 884 const wrapper = button.closest('.hf-file-uploader-wrapper'); 885 const uploadedFiles = wrapper.find('.hf-uploaded-files'); 886 uploadedFiles.val(uploadedFiles.val().replace(button.data('url'), '').replace(',,', ',')); 887 888 const counter = wrapper.find('.hf-multiple-upload-limit'); 889 counter.val(Math.max(0, Number(counter.val()) - 1)); 890 891 button.parent().fadeOut('1500', function () { 892 button.parent().remove(); 893 wrapper.find('#' + button.attr('data-remove-id')).remove(); 894 }); 895 } 896 }); 897 }); 898 899});
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.