1/* global jQuery, _logged_user_id, _editor_csfr */ 2(function($) { 3 'use strict'; 4 5 if (!$.fn.wysibb) return; 6 7 var originalWysibb = $.fn.wysibb; 8 // justifyleft/justifyright float the selection (an image, usually) so the 9 // text wraps around it; justifycenter was the only one on the bar before. 10 var defaultButtons = 'bold,italic,underline,strike,|,justifyleft,justifycenter,justifyright,|,img,video,link,|,bullist,numlist,outdent,indent,|,fontcolor,fontsize,fontfamily,|,quote,code,table,removeFormat'; 11 var proofreadStorageKey = 'wysibb-ai-proofread-enabled'; 12 var activeEditor = null; 13 var modalRequest = null; 14 var proofreadPopover = null; 15 var proofreadPopoverMarker = null; 16 var proofreadPopoverEditor = null; 17 var proofreadPopoverHideTimer = null; 18 var templateValueId = 0; 19 20 function language(key, fallback) { 21 return window.__lang && typeof window.__lang[key] !== 'undefined' 22 ? window.__lang[key] 23 : fallback; 24 } 25 26 function updateSharedAIQuota(count, limit) { 27 limit = parseInt(typeof limit === 'undefined' ? window._user_aiQueryLimit : limit, 10); 28 count = parseInt(count, 10); 29 if (isNaN(count)) return; 30 31 window._user_aiQueryCount = count; 32 if (!isNaN(limit)) window._user_aiQueryLimit = limit; 33 $('.ai-queries-left').text('(' + count + ' / ' + (isNaN(limit) ? 0 : limit) + ' ' + language(65, 'used') + ')'); 34 if (isNaN(limit) || limit <= 0 || count < limit) { 35 $('.ai-queries-exhausted').remove(); 36 $('.ai-help-btn.ai-quota-disabled').removeClass('ai-quota-disabled').show(); 37 return; 38 } 39 40 $('.ai-help-btn').each(function() { 41 var button = $(this); 42 if (!button.next('.ai-queries-exhausted').length) { 43 $('<span class="ai-queries-exhausted text-danger" style="margin-left: 10px; font-size: 12px;"></span>') 44 .append('<i class="fas fa-exclamation mr-2"></i>') 45 .append(document.createTextNode(language('HELP_ME_WRITE_NO_QUERIES_LEFT', 'No AI queries left'))) 46 .insertAfter(button); 47 } 48 button.addClass('ai-quota-disabled').hide(); 49 }); 50 } 51 52 function recordSharedAIQuery(response) { 53 if (typeof window.applyAIQueryQuota === 'function') { 54 window.applyAIQueryQuota(response); 55 } 56 } 57 58 $(document).on('aiQueryQuotaUpdated.wysibbEditorTools', function(event, count, limit) { 59 updateSharedAIQuota(count, limit); 60 }); 61 62 function sharedAIQuotaExhausted() { 63 var count = parseInt(window._user_aiQueryCount, 10); 64 var limit = parseInt(window._user_aiQueryLimit, 10); 65 return !isNaN(count) && !isNaN(limit) && limit > 0 && count >= limit; 66 } 67 68 function refreshSharedAIQuota(editor) { 69 if (editor.proofreadQuotaRequest || !csrfToken(editor)) return; 70 71 updateProofreadButton(editor, 'busy'); 72 editor.proofreadQuotaRequest = $.ajax({ 73 url: endpoint('/editor/quota/', editor), 74 method: 'GET', 75 dataType: 'json', 76 success: function(response) { 77 recordSharedAIQuery(response); 78 if (editor.proofreadEnabled && !editor.options.bbmode && !sharedAIQuotaExhausted()) { 79 scheduleProofread(editor); 80 } 81 }, 82 error: function(xhr) { 83 var message = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ''; 84 updateProofreadButton(editor, 'error', message); 85 }, 86 complete: function() { 87 editor.proofreadQuotaRequest = null; 88 if (editor.proofreadEnabled && !editor.$toolbar.find('.wbb-proofread-error').length) { 89 updateProofreadButton(editor); 90 } 91 } 92 }); 93 } 94 95 function isLoggedIn() { 96 return typeof _logged_user_id !== 'undefined' && parseInt(_logged_user_id, 10) > 0; 97 } 98 99 function csrfToken(editor) { 100 if (typeof _editor_csfr !== 'undefined' && _editor_csfr) return _editor_csfr; 101 102 var token = editor.$txtArea.closest('form').find('input[name="__cr"]').first().val(); 103 return token || $('input[name="__cr"]').first().val() || ''; 104 } 105 106 function endpoint(path, editor) { 107 return path + encodeURIComponent(csrfToken(editor)); 108 } 109 110 function debounce(callback, wait) { 111 var timeout; 112 return function() { 113 var context = this; 114 var args = arguments; 115 clearTimeout(timeout); 116 timeout = setTimeout(function() { 117 callback.apply(context, args); 118 }, wait); 119 }; 120 } 121 122 function proofreadPreference() { 123 try { 124 var saved = window.localStorage.getItem(proofreadStorageKey); 125 return saved === null ? true : saved === '1'; 126 } catch (error) { 127 return true; 128 } 129 } 130 131 function saveProofreadPreference(enabled) { 132 try { 133 window.localStorage.setItem(proofreadStorageKey, enabled ? '1' : '0'); 134 } catch (error) {
135 // Storage can be unavailable in private browsing; the current editor 136 // still keeps the selected state for the remainder of the page. 137 } 138 } 139 140 function selectionOffsets(root) { 141 var selection = window.getSelection ? window.getSelection() : null; 142 if (!selection || !selection.rangeCount) return null; 143 144 var range = selection.getRangeAt(0); 145 if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null; 146 147 var before = range.cloneRange(); 148 before.selectNodeContents(root); 149 before.setEnd(range.startContainer, range.startOffset); 150 151 return { 152 start: before.toString().length, 153 end: before.toString().length + range.toString().length 154 }; 155 } 156 157 function restoreSelection(root, offsets) { 158 if (!offsets || !window.getSelection) return; 159 160 var walker = document.createTreeWalker(root, window.NodeFilter ? window.NodeFilter.SHOW_TEXT : 4, null, false); 161 var range = document.createRange(); 162 var position = 0; 163 var startNode = null; 164 var endNode = null; 165 var startOffset = 0; 166 var endOffset = 0; 167 var node; 168 169 while ((node = walker.nextNode())) { 170 var nextPosition = position + node.nodeValue.length; 171 if (!startNode && offsets.start <= nextPosition) { 172 startNode = node; 173 startOffset = Math.max(0, offsets.start - position); 174 } 175 if (!endNode && offsets.end <= nextPosition) { 176 endNode = node; 177 endOffset = Math.max(0, offsets.end - position); 178 break; 179 } 180 position = nextPosition; 181 } 182 183 if (!startNode) { 184 range.selectNodeContents(root); 185 range.collapse(false); 186 } else { 187 range.setStart(startNode, Math.min(startOffset, startNode.nodeValue.length)); 188 range.setEnd(endNode || startNode, Math.min(endNode ? endOffset : startOffset, (endNode || startNode).nodeValue.length)); 189 } 190 191 var selection = window.getSelection(); 192 selection.removeAllRanges(); 193 selection.addRange(range); 194 } 195 196 function removeProofreadMarks(editor, preserveSelection) { 197 var root = editor.$body[0]; 198 var offsets = preserveSelection ? selectionOffsets(root) : null; 199 if (proofreadPopoverEditor === editor) hideProofreadPopover(); 200 201 editor.$body.find('.wbb-proofread-issue').each(function() { 202 $(this).replaceWith(document.createTextNode($(this).text())); 203 }); 204 root.normalize(); 205 restoreSelection(root, offsets); 206 } 207 208 function proofreadSnapshot(editor) { 209 var rawText = ''; 210 var segments = []; 211 212 function appendSynthetic(text) { 213 rawText += text; 214 } 215 216 function visit(node) { 217 if (node.nodeType === 3) { 218 var value = ''; 219 var nodeOffsets = []; 220 for (var characterIndex = 0; characterIndex < node.nodeValue.length; characterIndex++) { 221 var character = node.nodeValue.charAt(characterIndex); 222 if (character === '\uFEFF') continue; // WysiBB cursor placeholder. 223 nodeOffsets.push(characterIndex); 224 value += character === '\u00a0' ? ' ' : character; 225 } 226 segments.push({ node: node, nodeOffsets: nodeOffsets, start: rawText.length, end: rawText.length + value.length }); 227 rawText += value; 228 return; 229 } 230 if (node.nodeType !== 1) return; 231 232 var element = $(node); 233 if (element.is('.text-muted')) return; 234 if (element.is('code, pre, .wbb-code')) { 235 appendSynthetic('\n'); 236 return; 237 } 238 if (element.is('br')) { 239 appendSynthetic('\n'); 240 return; 241 } 242 243 $.each(node.childNodes, function(index, child) { visit(child); }); 244 if (element.is('p, div, li')) appendSynthetic('\n'); 245 } 246 247 $.each(editor.$body[0].childNodes, function(index, child) { visit(child); }); 248 var text = $.trim(rawText); 249 return { 250 text: text, 251 segments: segments, 252 trimStart: text === '' ? 0 : rawText.indexOf(text) 253 }; 254 } 255 256 function proofreadText(editor) { 257 return proofreadSnapshot(editor).text; 258 } 259 260 function proofreadIssueTarget(editor, issue) { 261 var occurrence = parseInt(issue.occurrence, 10); 262 if (!issue.quote || isNaN(occurrence) || occurrence < 0) return null; 263 264 var snapshot = proofreadSnapshot(editor); 265 var searchFrom = 0; 266 var position = -1; 267 for (var index = 0; index <= occurrence; index++) { 268 position = snapshot.text.indexOf(issue.quote, searchFrom); 269 if (position === -1) return null; 270 searchFrom = position + issue.quote.length; 271 } 272 273 var rawStart = position + snapshot.trimStart; 274 var rawEnd = rawStart + issue.quote.length; 275 for (var segmentIndex = 0; segmentIndex < snapshot.segments.length; segmentIndex++) { 276 var segment = snapshot.segments[segmentIndex]; 277 if (rawStart < segment.start || rawEnd >
277 segment.end) continue; 278 279 return { 280 node: segment.node, 281 start: segment.nodeOffsets[rawStart - segment.start], 282 end: segment.nodeOffsets[rawEnd - segment.start - 1] + 1 283 }; 284 } 285 286 // The occurrence is still counted when it crosses formatted nodes, but 287 // only a target wholly inside one text node can be wrapped without 288 // changing the user's formatting. 289 return null; 290 } 291 292 function proofreadExplanationLanguage() { 293 var browserLanguage = window.navigator && (window.navigator.language || window.navigator.userLanguage); 294 var pageLanguage = document.documentElement.getAttribute('lang'); 295 var languageCode = String(browserLanguage || pageLanguage || 'en').replace('_', '-'); 296 297 return /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(languageCode) ? languageCode : 'en'; 298 } 299 300 function updateProofreadButton(editor, state, detail) { 301 var button = editor.$toolbar.find('.wbb-proofread'); 302 var title = editor.proofreadEnabled 303 ? language('EDITOR_PROOFREAD_ON', 'AI spelling and grammar is on') 304 : language('EDITOR_PROOFREAD_OFF', 'AI spelling and grammar is off'); 305 if (detail) title += ' â ' + detail; 306 307 button.toggleClass('on', !!editor.proofreadEnabled); 308 button.toggleClass('wbb-proofread-busy', state === 'busy'); 309 button.toggleClass('wbb-proofread-error', state === 'error'); 310 // WysiBB already renders .btn-tooltip; a title creates a second tooltip. 311 button.removeAttr('title').attr('aria-label', title).attr('aria-pressed', editor.proofreadEnabled ? 'true' : 'false'); 312 button.find('.btn-tooltip').empty().text(title).append('<ins></ins>'); 313 button.find('.btn-inner i').attr('class', state === 'busy' ? 'fas fa-spinner fa-spin' : 'fas fa-spell-check'); 314 } 315 316 function hideProofreadPopover() { 317 clearTimeout(proofreadPopoverHideTimer); 318 if (proofreadPopover) proofreadPopover.remove(); 319 proofreadPopover = null; 320 proofreadPopoverMarker = null; 321 proofreadPopoverEditor = null; 322 } 323 324 function scheduleProofreadPopoverHide() { 325 clearTimeout(proofreadPopoverHideTimer); 326 proofreadPopoverHideTimer = setTimeout(hideProofreadPopover, 180); 327 } 328 329 function positionProofreadPopover(popover, marker) { 330 var rect = marker.getBoundingClientRect(); 331 var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft || 0; 332 var scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; 333 var viewportWidth = document.documentElement.clientWidth; 334 var viewportHeight = document.documentElement.clientHeight; 335 var width = popover.outerWidth(); 336 var height = popover.outerHeight(); 337 var left = rect.left + scrollLeft; 338 var top = rect.bottom + scrollTop + 10; 339 var opensAbove = rect.bottom + height + 18 > viewportHeight && rect.top > height + 18; 340 341 left = Math.max(scrollLeft + 12, Math.min(left, scrollLeft + viewportWidth - width - 12)); 342 if (opensAbove) top = rect.top + scrollTop - height - 10; 343 344 popover 345 .toggleClass('wbb-proofread-popover-above', opensAbove) 346 .css({ left: Math.round(left), top: Math.round(top) }); 347 popover.find('.wbb-proofread-popover-arrow').css( 348 'left', 349 Math.max(18, Math.min(width - 28, rect.left + scrollLeft + Math.min(rect.width / 2, 24) - left)) 350 ); 351 } 352 353 function replaceProofreadIssue(editor, marker) { 354 if (!marker || !marker.parentNode) return; 355 356 var replacement = $(marker).attr('data-suggestion') || ''; 357 var replacementNode = document.createTextNode(replacement); 358 hideProofreadPopover(); 359 $(marker).replaceWith(replacementNode); 360 361 if (window.getSelection) { 362 var range = document.createRange(); 363 range.setStart(replacementNode, replacementNode.nodeValue.length); 364 range.collapse(true); 365 var selection = window.getSelection(); 366 selection.removeAllRanges(); 367 selection.addRange(range); 368 editor.$body.trigger('focus'); 369 } 370 371 editor.sync(); 372 editor.$txtArea.trigger('change'); 373 editor.proofreadLastText = ''; 374 scheduleProofread(editor); 375 } 376 377 function ignoreProofreadIssue(editor, marker) { 378 if (!marker || !marker.parentNode) return; 379 380 var textNode = document.createTextNode($(marker).text()); 381 hideProofreadPopover(); 382 $(marker).replaceWith(textNode); 383 editor.sync(); 384 editor.proofreadLastText = proofreadText(editor); 385 386 if (window.getSelection) { 387 var range = document.createRange(); 388 range.setStart(textNode, textNode.nodeValue.length); 389 range.collapse(true); 390 var selection = window.getSelection(); 391 selection.removeAllRanges(); 392 selection.addRange(range); 393 editor.$body.trigger('focus'); 394 } 395 } 396 397 function showProofreadPopover(editor, marker, focusAction) { 398 clearTimeout(proofreadPopoverHideTimer); 399 if (!marker || !marker.parentNode) return; 400 if (proofreadPopover && proofreadPopoverMarker === marker) { 401 if (focusAction) proofreadPopover.find('.wbb-proofread-apply').trigger('focus'); 402 return; 403 } 404 405 hideProofreadPopover(); 406 var issue = $(marker); 407 var type = issue.attr('data-type') === 'grammar' ? 'grammar' : 'spelling'; 408 var typeLabel = type === 'grammar' 409 ? language('EDITOR_PROOFREAD_GRAMMAR', 'Grammar') 410 : language('EDITOR_PROOFREAD_SPELLING', 'Spelling'); 411 var suggestion = issue.attr('data-suggestion') || ''; 412 var explanation = issue.attr('data-explanation') || ''; 413 var closeLabel = language('EDITOR_PROOFREAD_CLOSE', 'Close'); 414 var fixLabel = type === 'grammar' 415 ? language('EDITOR_PROOFREAD_FIX_GRAMMAR', 'Fix Grammar') 416 : language('EDITOR_PROOFREAD_FIX_SPELLING', 'Fix Spelling'); 417
418 proofreadPopover = $('<div class="wbb-proofread-popover" role="dialog"></div>') 419 .attr('aria-label', typeLabel) 420 .append('<span class="wbb-proofread-popover-arrow" aria-hidden="true"></span>'); 421 var header = $('<div class="wbb-proofread-popover-header"></div>'); 422 header.append($('<span class="wbb-proofread-popover-title"></span>') 423 .append('<i class="fas fa-spell-check" aria-hidden="true"></i> ') 424 .append(document.createTextNode(typeLabel))); 425 header.append($('<button type="button" class="wbb-proofread-close"></button>') 426 .attr('aria-label', closeLabel) 427 .attr('title', closeLabel) 428 .html('×')); 429 proofreadPopover.append(header); 430 431 var content = $('<div class="wbb-proofread-popover-content"></div>'); 432 content.append($('<div class="wbb-proofread-change"></div>') 433 .append($('<span class="wbb-proofread-original"></span>').text(issue.text())) 434 .append('<i class="fas fa-long-arrow-alt-right" aria-hidden="true"></i>') 435 .append($('<strong class="wbb-proofread-suggestion"></strong>').text(suggestion))); 436 if (explanation) content.append($('<p class="wbb-proofread-explanation"></p>').text(explanation)); 437 content.append($('<div class="wbb-proofread-actions"></div>') 438 .append($('<button type="button" class="btn btn-primary wbb-proofread-apply"></button>') 439 .attr('title', language('EDITOR_PROOFREAD_REPLACE', 'Use suggestion: %s').replace('%s', suggestion)) 440 .text(fixLabel))
441 .append($('<button type="button" class="btn btn-secondary wbb-proofread-ignore"></button>') 442 .text(language('EDITOR_PROOFREAD_IGNORE', 'Ignore')))); 443 proofreadPopover.append(content).appendTo('body'); 444 445 proofreadPopoverMarker = marker; 446 proofreadPopoverEditor = editor; 447 positionProofreadPopover(proofreadPopover, marker); 448 proofreadPopover 449 .on('mouseenter', function() { clearTimeout(proofreadPopoverHideTimer); }) 450 .on('mouseleave', scheduleProofreadPopoverHide) 451 .on('focusin', function() { clearTimeout(proofreadPopoverHideTimer); }) 452 .on('focusout', scheduleProofreadPopoverHide) 453 .on('click', '.wbb-proofread-close', function() { 454 $(marker).trigger('focus'); 455 hideProofreadPopover(); 456 }) 457 .on('click', '.wbb-proofread-apply', function() { replaceProofreadIssue(editor, marker); }) 458 .on('click', '.wbb-proofread-ignore', function() { ignoreProofreadIssue(editor, marker); }) 459 .on('keydown', function(event) { 460 if (event.which !== 27) return; 461 event.preventDefault(); 462 $(marker).trigger('focus'); 463 hideProofreadPopover(); 464 }); 465 466 if (focusAction) proofreadPopover.find('.wbb-proofread-apply').trigger('focus'); 467 } 468 469 function wrapIssue(editor, issue) { 470 if (!issue.quote || issue.quote.indexOf('{{') !== -1) return false; 471 var target = proofreadIssueTarget(editor, issue); 472 if (!target || $(target.node.parentNode).closest('.wbb-proofread-issue').length) return false; 473 474 var issueNode = target.node.splitText(target.start); 475 issueNode.splitText(target.end - target.start); 476 var marker = document.createElement('span'); 477 marker.className = 'wbb-proofread-issue wbb-proofread-' + issue.type; 478 marker.setAttribute('role', 'button'); 479 marker.setAttribute('tabindex', '0'); 480 marker.setAttribute('aria-label', language('EDITOR_PROOFREAD_REPLACE', 'Use suggestion: %s').replace('%s', issue.suggestion)); 481 marker.setAttribute('data-type', issue.type); 482 marker.setAttribute('data-suggestion', issue.suggestion); 483 marker.setAttribute('data-explanation', issue.explanation || ''); 484 marker.textContent = issueNode.nodeValue; 485 issueNode.parentNode.replaceChild(marker, issueNode); 486 return true; 487 } 488 489 function applyProofreadIssues(editor, issues) { 490 var offsets = selectionOffsets(editor.$body[0]); 491 removeProofreadMarks(editor, false); 492 493 issues.sort(function(a, b) { 494 return b.quote.length - a.quote.length; 495 }); 496 $.each(issues, function(index, issue) { 497 wrapIssue(editor, issue); 498 }); 499 500 restoreSelection(editor.$body[0], offsets); 501 editor.sync(); 502 } 503 504 function runProofread(editor) { 505 if (!editor.proofreadEnabled || editor.options.bbmode || !csrfToken(editor)) return; 506 if (sharedAIQuotaExhausted()) { 507 updateProofreadButton(editor, 'error', language('HELP_ME_WRITE_NO_QUERIES_LEFT', 'No AI queries left')); 508 return; 509 } 510 if (editor.proofreadRequest) { 511 editor.proofreadQueued = true; 512 return; 513 } 514 515 var text = proofreadText(editor); 516 if (text.length < 8 || text === editor.proofreadLastText) { 517 if (text.length < 8) removeProofreadMarks(editor, true); 518 return; 519 } 520 521 editor.proofreadQueued = false; 522 updateProofreadButton(editor, 'busy'); 523 524 editor.proofreadRequest = $.ajax({ 525 url: endpoint('/editor/proofread/', editor), 526 method: 'POST', 527 dataType: 'json', 528 data: { 529 text: text, 530 explanation_language: proofreadExplanationLanguage() 531 }, 532 success: function(response) { 533 recordSharedAIQuery(response); 534 if (!editor.proofreadEnabled || editor.options.bbmode || text !== proofreadText(editor)) return; 535 if (response.error) { 536 updateProofreadButton(editor, 'error', response.message); 537 return; 538 } 539 540 editor.proofreadLastText = text; 541 applyProofreadIssues(editor, response.issues || []); 542 updateProofreadButton(editor); 543 }, 544 error: function(xhr, status) { 545 if (status === 'abort') return; 546 if (xhr.responseJSON && typeof xhr.responseJSON.ai_query_count !== 'undefined') { 547 recordSharedAIQuery(xhr.responseJSON); 548 } 549 if (xhr.status === 429) { 550 updateProofreadButton(editor); 551 scheduleProofread(editor); 552 return; 553 } 554 if (xhr.status === 403 && xhr.responseJSON && xhr.responseJSON.c
554ode === 'ai_quota_exceeded') { 555 updateProofreadButton(editor, 'error', xhr.responseJSON.message); 556 return; 557 } 558 var message = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ''; 559 updateProofreadButton(editor, 'error', message); 560 }, 561 complete: function() { 562 var runQueued = editor.proofreadQueued && editor.proofreadEnabled && !editor.options.bbmode; 563 editor.proofreadRequest = null; 564 editor.proofreadQueued = false; 565 if (editor.proofreadEnabled && !editor.$toolbar.find('.wbb-proofread-error').length) { 566 updateProofreadButton(editor); 567 } 568 if (runQueued) scheduleProofread(editor); 569 } 570 }); 571 } 572 573 function scheduleProofread(editor) { 574 clearTimeout(editor.proofreadTimer); 575 if (editor.proofreadRequest) { 576 editor.proofreadQueued = true; 577 return; 578 } 579 editor.proofreadTimer = setTimeout(function() { 580 runProofread(editor); 581 }, 1500); 582 } 583 584 function setProofreadEnabled(editor, enabled, checkNow) { 585 editor.proofreadEnabled = enabled; 586 editor.$body.attr('spellcheck', enabled ? 'true' : 'false'); 587 editor.$txtArea.attr('spellcheck', enabled ? 'true' : 'false'); 588 updateProofreadButton(editor); 589 590 if (enabled) { 591 editor.proofreadLastText = ''; 592 if (checkNow && !editor.options.bbmode) { 593 if (sharedAIQuotaExhausted()) refreshSharedAIQuota(editor); 594 else scheduleProofread(editor); 595 } 596 return; 597 } 598 599 clearTimeout(editor.proofreadTimer); 600 editor.proofreadQueued = false; 601 removeProofreadMarks(editor, true); 602 editor.sync(); 603 } 604 605 function toggleProofread(editor) { 606 var enabled = !editor.proofreadEnabled; 607 saveProofreadPreference(enabled); 608 609 $('.wysibb-texarea').each(function() { 610 var instance = $(this).data('wbb'); 611 if (instance) setProofreadEnabled(instance, enabled, instance === editor); 612 }); 613 } 614 615 function editorContext(editor) { 616 var form = editor.$txtArea.closest('form'); 617 var source = form.find('input[name="from"][type="hidden"]').first().val() || ''; 618 var recipientField = form.find('input[name="msgto"], input[name="recipient"], input[name="recipient[]"]').first(); 619 var recipientValue = recipientField.val() || ''; 620 var recipientId = parseInt(recipientValue, 10) || 0; 621 var recipientUsername = form.find('input[name="from"]:not([type="hidden"])').first().val() || ''; 622 if (!recipientId && recipientField.length) recipientUsername = recipientValue; 623 return { 624 recipient_id: recipientId, 625 recipient_username: recipientUsername, 626 order_id: parseInt(form.find('input[name="oid"]').first().val(), 10) || 0, 627 conversation_id: parseInt(form.find('input[name="cid"], input[name="masterID"]').first().val(), 10) || 0, 628 service_id: parseInt(form.find('input[name="aboutid"], input[name="about"]').first().val(), 10) || 0, 629 source: source 630 }; 631 } 632 633 function templateContextRequestData(editor) { 634 var context = editorContext(editor); 635 return { 636 recipient_id: context.recipient_id, 637 recipient_username: context.recipient_username, 638 order_id: context.order_id, 639 conversation_id: context.conversation_id, 640 service_id: context.service_id, 641 source: context.source 642 }; 643 } 644 645 function ensureModal() { 646 var modal = $('#wysibbEditorToolsModal'); 647 if (modal.length) return modal; 648 649 modal = $('<div class="modal fade wysibb-editor-tools-modal" id="wysibbEditorToolsModal" tabindex="-1" role="dialog" aria-hidden="true">' + 650 '<div class="modal-dialog modal-lg" role="document"><div class="modal-content">' + 651 '<div class="modal-header"><h5 class="modal-title"></h5>' + 652 '<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button></div>' + 653 '<div class="modal-body"></div></div></div></div>'); 654 $('body').append(modal); 655 modal.on('hidden.bs.modal', function() { 656 if (modalRequest) modalRequest.abort(); 657 modalRequest = null; 658 activeEditor = null; 659 }); 660 661 return modal; 662 } 663 664 function modalFeedback(container, message, error) { 665 container.empty().append( 666 $('<div class="editor-tools-feedback text-center py-4"></div>') 667 .toggleClass('text-danger', !!error) 668 .text(message) 669 ); 670 } 671 672 function insertHtml(editor, html) { 673 editor.insertAtCursor(html); 674 editor.sync(); 675 editor.$txtArea.trigger('change'); 676 scheduleProofread(editor); 677 } 678 679 function escapeHtmlText(value) { 680 return $('<div></div>').text(String(value || '')).html(); 681 } 682 683 function safeContextText(value) { 684 return String(value || '') 685 // Entity-like ampersands and markup delimiters can become active after 686 // a visual/source round trip, so use stable display characters. 687 .replace(/&(?=#(?:x[0-9a-f]+|\d+);|[a-z][a-z0-9]+;)/gi, 'ï¼') 688 .replace(/\[/g, 'ï¼»') 689 .replace(/\]/g, 'ï¼½') 690 .replace(/</g, 'ï¼') 691 .replace(/>/g, 'ï¼'); 692 } 693 694 function editorPlainText(editor, value) { 695 var safeValue = safeContextText(value); 696 return editor.options.bbmode ? safeValue : escapeHtmlText(safeValue); 697 } 698 699 function openServicePicker(editor) { 700 activeEditor = editor; 701 editor.saveRange(); 702 var modal = ensureModal();
703 var body = modal.find('.modal-body').empty(); 704 var search = $('<input type="search" class="form-control mb-3" autocomplete="off">') 705 .attr('placeholder', language('EDITOR_SERVICE_SEARCH', 'Search your services')); 706 var results = $('<div class="list-group editor-tool-results"></div>'); 707 body.append(search, results); 708 modal.find('.modal-title').text(language('EDITOR_SERVICE_LINK', 'Insert a service link')); 709 modal.modal('show'); 710 711 function loadServices() { 712 if (modalRequest) modalRequest.abort(); 713 modalFeedback(results, language('EDITOR_LOADING', 'Loading...')); 714 modalRequest = $.ajax({ 715 url: endpoint('/editor/services/', editor), 716 method: 'GET', 717 dataType: 'json', 718 data: { q: search.val() }, 719 success: function(response) { 720 results.empty(); 721 if (response.error || !response.services || !response.services.length) { 722 modalFeedback(results, response.message || language('EDITOR_NO_SERVICES', 'No matching services found.'), !!response.error); 723 return; 724 } 725 726 $.each(response.services, function(index, service) { 727 var item = $('<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"></button>'); 728 var title = $('<span class="text-truncate mr-3"></span>') 729 .text(service.display_title) 730 .attr('title', service.title) 731 .attr('data-toggle', 'tooltip'); 732 item.append(title, $('<i class="far fa-link text-muted"></i>')); 733 item.on('click', function() { 734 var html = editor.getCodeByCommand('link', { 735 url: service.url, 736 seltext: editorPlainText(editor, service.title) 737 }); 738 if ($.isFunction(editor.selectLastRange)) editor.selectLastRange(); 739 insertHtml(editor, html); 740 modal.modal('hide'); 741 }); 742 results.append(item); 743 }); 744 results.find('[data-toggle="tooltip"]').tooltip({ container: 'body' }); 745 }, 746 error: function(xhr, status) { 747 if (status !== 'abort') modalFeedback(results, language('REQUEST_FAILED_GENERIC', 'The request failed. Please try again.'), true); 748 } 749 }); 750 } 751 752 search.on('input', debounce(loadServices, 250)); 753 modal.one('shown.bs.modal', function() { search.trigger('focus'); }); 754 loadServices(); 755 } 756 757 function replaceTemplateVariables(body, context) { 758 var missing = []; 759 var values = []; 760 var text = body.replace(/\{\{\s*([a-z_]+)\s*\}\}/gi, function(match, name) { 761 var key = name.toLowerCase(); 762 if (Object.prototype.hasOwnProperty.call(context, key) && context[key] !== '') { 763 var token; 764 do { 765 token = 'WBBTemplateValue' + (++templateValueId) + 'X'; 766 } while (body.indexOf(token) !== -1); 767 values.push({ token: token, key: key, text: safeContextText(context[key]) }); 768 return token; 769 } 770 if (missing.indexOf('{{' + key + '}}') === -1) missing.push('{{' + key + '}}'); 771 return match; 772 }); 773 774 return { text: text, missing: missing, values: values }; 775 } 776 777 function applyTemplateValues(text, values) { 778 var replacements = {}; 779 $.each(values, function(index, value) { 780 replacements[value.token] = value.text; 781 }); 782 var tokens = Object.keys(replacements); 783 if (!tokens.length) return text; 784 785 // Replace only tokens that were present in the parsed template. A single 786 // pass prevents the value inserted for one variable from being treated as 787 // another generated token. 788 return text.replace(new RegExp(tokens.join('|'), 'g'), function(token) { 789 return replacements[token]; 790 }); 791 } 792 793 function templateHtml(editor, resolved) { 794 var wrapper = $('<div></div>').html(editor.getHTML(resolved.text, true)); 795 var walker = document.createTreeWalker(wrapper[0], window.NodeFilter ? window.NodeFilter.SHOW_TEXT : 4, null, false); 796 var node; 797 while ((node = walker.nextNode())) { 798 node.nodeValue = applyTemplateValues(node.nodeValue, resolved.values); 799 } 800 801 // URL variables are also useful as the target of a template-authored link. 802 // Set those through the DOM and accept only normal web URLs. 803 wrapper.find('a[href]').each(function() { 804 var link = $(this); 805 var href = link.attr('href') || ''; 806 $.each(resolved.values, function(index, value) { 807 if (value.key !== 'order_url' && value.key !== 'service_url') return; 808 if (href.indexOf(value.token) === -1) return; 809 var candidate = href.split(value.token).join(value.text); 810 if (/^https?:\/\//i.test(candidate)) href = candidate; 811 }); 812 link.attr('href', href); 813 }); 814 815 return wrapper.html(); 816 } 817 818 function insertTemplate(editor, body, context) { 819 var resolved = replaceTemplateVariables(body, context || {}); 820 if (resolved.missing.length) return resolved.missing; 821 822 var html = editor.options.bbmode 823 ? applyTemplateValues(resolved.text, resolved.values) 824 : templateHtml(editor, resolved); 825 if ($.isFunction(editor.selectLastRange)) editor.selectLastRange(); 826 insertHtml(editor, html); 827 return []; 828 } 829 830 function unavailableVariablesMessage(variables) { 831 return language( 832 'EDITOR_TEMPLATE_CONTEXT_MISSING', 833 'This template needs context that is not available here: %s' 834 ).replace('%s', variables.join(', ')); 835 } 836 837 function showQuickReplyFeedback(link, message) { 838 var existing = $(link).siblings('.editor-template-quick-feedback'); 839 if (existing.length) existing.remove(); 840 841 var feedback = $('<span class="editor-template-quick-feedback d-block small text-danger mt-1" role="alert"></span>') 842 .text(message) 843 .insertAfter(link); 844 setTimeout(function() { feedback.fadeOut(200, function() { feedback.remove(); }); }, 6000); 845 } 846 847 function addVariableAtCursor(textarea, variable) { 848 var element = textarea[0]; 849 var start = typeof element.selectionStart === 'number' ? element.selectionStart : textarea.val().length; 850 var end = typeof element.selectionEnd === 'number' ? element.selectionEnd : start; 851 var value = textarea.val(); 852 textarea.val(value.substring(0, start) + variable + value.substring(end)); 853 element.focus(); 854 element.selectionStart = element.selectionEnd = start + variable.length; 855 } 856 857 function templateTextLength(text) { 858 return window.editorTemplateTextLength(text); 859 } 860 861 function showTemplateCreate(formContainer, listContainer, search, loadTemplates) { 862 listContainer.hide(); 863 formContainer.empty().show(); 864 865 var title = $('<input type="text" class="form-control mb-3">') 866 .attr('placeholder', language('EDITOR_TEMPLATE_TITLE', 'Template title')); 867 var body = $('<textarea rows="6" class="form-control mb-3"></textarea>') 868 .attr('placeholder', language('EDITOR_TEMPLATE_BODY', 'Template message')); 869 var variables = [ 870 '{{username}}', 871 '{{my_username}}', 872 '{{buyer_username}}', 873 '{{seller_username}}', 874 '{{order_id}}', 875 '{{order_url}}', 876 '{{service_title}}', 877 '{{service_url}}' 878 ]; 879 var variableWrap = $('<div class="editor-template-variables mb-3"></div>'); 880 variableWrap.append($('<small class="d-block text-muted mb-2"></small>').text(language('EDITOR_TEMPLATE_VARIABLES', 'Available variables'))); 881 $.each(variables, function(index, variable) { 882 $('<button type="button" class="btn btn-sm btn-outline-secondary mr-1 mb-1"></button>') 883 .text(variable) 884 .on('click', function() { addVariableAtCursor(body, variable); }) 885 .appendTo(variableWrap); 886 }); 887 variableWrap.append( 888 $('<small class="d-block text-muted mt-1"></small>').text(language( 889 'EDITOR_TEMPLATE_CONTEXT_HELP', 890 'Username variables use the current recipient. Order variables require an order, and service variables require a related service or order.' 891 )) 892 ); 893 894 var feedback = $('<div class="small text-danger mb-2" aria-live="polite"></div>'); 895 var actions = $('<div class="d-flex justify-content-end"></div>'); 896 var cancel = $('<button type="button" class="btn btn-outline-secondary mr-2"></button>')
897 .text(language('EDITOR_CANCEL', 'Cancel')); 898 var save = $('<button type="button" class="btn btn-primary"></button>').text(language('EDITOR_TEMPLATE_CREATE', 'Create a template')); 899 actions.append(cancel, save); 900 formContainer.append(title, body, variableWrap, feedback, actions); 901 902 cancel.on('click', function() { 903 formContainer.hide(); 904 listContainer.show(); 905 search.trigger('focus'); 906 }); 907 908 save.on('click', function() { 909 feedback.empty(); 910 if (templateTextLength($.trim(title.val())) < 5 || templateTextLength($.trim(body.val())) < 5) { 911 feedback.text(language('EDITOR_TEMPLATE_REQUIRED', 'Enter a title and message with at least 5 characters each.')); 912 title.trigger('focus'); 913 return; 914 } 915 if (templateTextLength($.trim(title.val())) > 30 || templateTextLength(body.val()) > 500) { 916 feedback.text(language('EDITOR_TEMPLATE_LENGTH', 'Use no more than 30 characters for the title and 500 for the message.')); 917 return; 918 } 919 save.prop('disabled', true); 920 $.ajax({ 921 url: endpoint('/editor/templates/create/', activeEditor), 922 method: 'POST', 923 dataType: 'json', 924 data: { title: title.val(), body: body.val() }, 925 success: function(response) { 926 if (response.error) { 927 feedback.text(response.message || language('REQUEST_FAILED_GENERIC', 'The request failed. Please try again.')); 928 return; 929 } 930 formContainer.hide(); 931 listContainer.show(); 932 search.val(''); 933 loadTemplates(); 934 }, 935 error: function(xhr) { 936 var message = xhr.responseJSON && xhr.responseJSON.message 937 ? xhr.responseJSON.message 938 : language('REQUEST_FAILED_GENERIC', 'The request failed. Please try again.'); 939 feedback.text(message); 940 }, 941 complete: function() { save.prop('disabled', false); } 942 }); 943 }); 944 945 title.trigger('focus'); 946 } 947 948 function openTemplatePicker(editor) { 949 activeEditor = editor; 950 editor.saveRange(); 951 var modal = ensureModal(); 952 var modalBody = modal.find('.modal-body').empty(); 953 var toolbar = $('<div class="d-flex mb-3"></div>'); 954 var search = $('<input type="search" class="form-control mr-2" autocomplete="off">') 955 .attr('placeholder', language('EDITOR_TEMPLATE_SEARCH', 'Search templates')); 956 var create = $('<button type="button" class="btn btn-outline-primary text-nowrap"></button>') 957 .text(language('EDITOR_TEMPLATE_CREATE', 'Create a template'));
958 var listContainer = $('<div class="editor-template-list"></div>'); 959 var results = $('<div class="list-group editor-tool-results"></div>'); 960 var createForm = $('<div class="editor-template-create" style="display:none"></div>'); 961 var contextFeedback = $('<div class="alert alert-warning py-2 mb-3" role="alert" style="display:none"></div>'); 962 listContainer.append(results); 963 toolbar.append(search, create); 964 modalBody.append(toolbar, contextFeedback, listContainer, createForm); 965 modal.find('.modal-title').text(language('EDITOR_CHAT_TEMPLATES', 'Chat templates')); 966 modal.modal('show'); 967 968 function loadTemplates() { 969 if (modalRequest) modalRequest.abort(); 970 contextFeedback.hide().empty(); 971 modalFeedback(results, language('EDITOR_LOADING', 'Loading...')); 972 modalRequest = $.ajax({ 973 url: endpoint('/editor/templates/', editor), 974 method: 'GET', 975 dataType: 'json', 976 data: $.extend({ q: search.val() }, templateContextRequestData(editor)), 977 success: function(response) { 978 results.empty(); 979 if (response.error || !response.templates || !response.templates.length) { 980 modalFeedback(results, response.message || language('EDITOR_TEMPLATE_EMPTY', 'No matching templates found.'), !!response.error); 981 return; 982 } 983 984 $.each(response.templates, function(index, template) { 985 var item = $('<button type="button" class="list-group-item list-group-item-action text-left"></button>'); 986 item.append($('<strong class="d-block"></strong>').text(template.title)); 987 item.append($('<span class="d-block small text-muted editor-template-preview"></span>').text(template.body)); 988 item.on('click', function() { 989 var missing = insertTemplate(editor, template.body, response.context); 990 if (missing.length) { 991 contextFeedback.text(unavailableVariablesMessage(missing)).show(); 992 return; 993 } 994 modal.modal('hide'); 995 }); 996 results.append(item); 997 }); 998 }, 999 error: function(xhr, status) { 1000 if (status !== 'abort') modalFeedback(results, language('REQUEST_FAILED_GENERIC', 'The request failed. Please try again.'), true); 1001 } 1002 }); 1003 } 1004 1005 search.on('input', debounce(loadTemplates, 250)); 1006 create.on('click', function() { 1007 showTemplateCreate(createForm, listContainer, search, loadTemplates); 1008 }); 1009 modal.one('shown.bs.modal', function() { search.trigger('focus'); }); 1010 loadTemplates(); 1011 } 1012 1013 function quickReplyEditor(link) { 1014 var editor = null; 1015 $(link).closest('form').find('textarea.wysibb').each(function() { 1016 var instance = $(this).data('wbb'); 1017 if (instance && !editor) editor = instance; 1018 }); 1019 return editor; 1020 } 1021 1022 function insertQuickReply(link, editor) { 1023 var body = $(link).attr('data-desc') || ''; 1024 if (body === '') return; 1025 1026 // Plain quick replies do not need a request and remain instant. 1027 if (!/\{\{\s*[a-z_]+\s*\}\}/i.test(body)) { 1028 insertTemplate(editor, body, {}); 1029 return; 1030 } 1031 1032 var request = $(link).data('editor-template-request'); 1033 if (request) request.abort(); 1034 $(link).attr('aria-busy', 'true'); 1035 1036 request = $.ajax({ 1037 url: endpoint('/editor/templates/context/', editor), 1038 method: 'GET', 1039 dataType: 'json', 1040 data: templateContextRequestData(editor), 1041 success: function(response) { 1042 if (response.error || !response.context) { 1043 showQuickReplyFeedback(link, response.message || language('REQUEST_FAILED_GENERIC', 'The request failed. Please try again.')); 1044 return; 1045 } 1046 1047 var missing = insertTemplate(editor, body, response.context); 1048 if (missing.length) showQuickReplyFeedback(link, unavailableVariablesMessage(missing)); 1049 }, 1050 error: function(xhr, status) { 1051 if (status !== 'abort') showQuickReplyFeedback(link, language('REQUEST_FAILED_GENERIC', 'The request failed. Please try again.')); 1052 }, 1053 complete: function() { 1054 $(link).removeAttr('aria-busy').removeData('editor-template-request'); 1055 } 1056 }); 1057 $(link).data('editor-template-request', request); 1058 } 1059 1060 // The legacy quick-reply handler inserts raw text into every editor. Capture 1061 // the click first so quick replies and chat templates share one context-aware 1062 // insertion path without requiring a rebuild of the production JS bundle. 1063 if (document.addEventListener) { 1064 document.addEventListener('click', function(event) { 1065 var link = $(event.target).closest('.tag-reply')[0]; 1066 if (!link) return; 1067 1068 var editor = quickReplyEditor(link); 1069 if (!editor) return; 1070 1071 event.preventDefault(); 1072 event.stopPropagation(); 1073 if (event.stopImmediatePropagation) event.stopImmediatePropagation(); 1074 insertQuickReply(link, editor); 1075 }, true); 1076 } 1077 1078 function setupResponsiveToolbar(editor) { 1079 var toolbar = editor.$toolbar; 1080 if (!toolbar.children('.wysibb-toolbar-scroll').length) { 1081 toolbar.children('.wysibb-toolbar-container').wrapAll('<div class="wysibb-toolbar-scroll"></div>'); 1082 } 1083 1084 toolbar.find('.wbb-proofread').closest('.wysibb-toolbar-container').addClass('wysibb-toolbar-primary-tools'); 1085 } 1086 1087 function setupToolButtonKeyboard(editor) { 1088 var controls = [ 1089 { selector: '.wbb-proofread', label: language('EDITOR_PROOFREAD', 'AI spelling and grammar') }, 1090 { selector: '.wbb-servicelink', label: language('EDITOR_SERVICE_LINK', 'Insert a service link'), popup: true }, 1091 { selector: '.wbb-chattemplate', label: language('EDITOR_CHAT_TEMPLATES', 'Chat templates'), popup: true } 1092 ]; 1093 1094 $.each(controls, function(index, control) { 1095 var button = editor.$toolbar.find(control.selector); 1096 button.attr({ role: 'button', tabindex: '0', 'aria-label': control.label }); 1097 if (control.popup) button.attr('aria-haspopup', 'dialog'); 1098 button.on('keydown.wysibbEditorTools', function(event) { 1099 if (event.which !== 13 && event.which !== 32) return; 1100 event.preventDefault(); 1101 event.stopPropagation(); 1102 $(this).trigger('mousedown'); 1103 }); 1104 }); 1105 } 1106 1107 function setupProofreadModeSwitch(editor) { 1108 var originalModeSwitch = editor.modeSwitch; 1109 editor.modeSwitch = function() { 1110 var enteringSourceMode = !this.options.bbmode; 1111 clearTimeout(this.proofreadTimer); 1112 1113 if (enteringSourceMode) { 1114 this.proofreadQueued = false;
1115 removeProofreadMarks(this, true); 1116 this.sync(); 1117 } 1118 1119 var result = originalModeSwitch.apply(this, arguments); 1120 this.proofreadLastText = ''; 1121 if (!this.options.bbmode && this.proofreadEnabled) scheduleProofread(this); 1122 return result; 1123 }; 1124 } 1125 1126 function setupEditor(editor) { 1127 setupResponsiveToolbar(editor); 1128 setupToolButtonKeyboard(editor); 1129 setupProofreadModeSwitch(editor); 1130 editor.proofreadEnabled = proofreadPreference(); 1131 editor.proofreadLastText = ''; 1132 editor.proofreadQueued = false; 1133 editor.$body.attr('spellcheck', editor.proofreadEnabled ? 'true' : 'false'); 1134 editor.$txtArea.attr('spellcheck', editor.proofreadEnabled ? 'true' : 'false'); 1135 updateProofreadButton(editor); 1136 1137 editor.$body.on('input.wysibbProofread', function() { 1138 if (!editor.proofreadEnabled) return; 1139 editor.proofreadLastText = ''; 1140 removeProofreadMarks(editor, true); 1141 updateProofreadButton(editor, editor.proofreadRequest ? 'busy' : null); 1142 scheduleProofread(editor); 1143 }); 1144 editor.$txtArea.on('input.wysibbProofread', function() { 1145 if (!editor.options.bbmode) return; 1146 clearTimeout(editor.proofreadTimer); 1147 editor.proofreadQueued = false; 1148 editor.proofreadLastText = ''; 1149 }); 1150 editor.$body 1151 .on('mouseenter.wysibbProofread', '.wbb-proofread-issue', function() { 1152 showProofreadPopover(editor, this, false); 1153 }) 1154 .on('mouseleave.wysibbProofread', '.wbb-proofread-issue', scheduleProofreadPopoverHide) 1155 .on('focusin.wysibbProofread', '.wbb-proofread-issue', function() { 1156 showProofreadPopover(editor, this, false); 1157 }) 1158 .on('focusout.wysibbProofread', '.wbb-proofread-issue', scheduleProofreadPopoverHide) 1159 .on('click.wysibbProofread keydown.wysibbProofread', '.wbb-proofread-issue', function(event) { 1160 if (event.type === 'keydown' && event.which === 27) { 1161 hideProofreadPopover(); 1162 return; 1163 } 1164 if (event.type === 'keydown' && event.which !== 13 && event.which !== 32) return; 1165 event.preventDefault(); 1166 event.stopPropagation(); 1167 showProofreadPopover(editor, this, true); 1168 }); 1169 1170 } 1171 1172 /* 1173 * The alignment buttons. 1174 * 1175 * wysibb applies a button that only has a transform by handing the 1176 * transform's HTML to document.execCommand('insertHTML'). That works for 1177 * [quote], whose blockquote the browser is willing to split the current 1178 * block for, but not for these three: a div or a <center> dropped inside 1179 * the block the caret is in gets unwrapped on the way in, so the marker 1180 * span wysibb then looks for is not there, selectNode() is handed 1181 * undefined and throws, and the click ends with the selection gone and 1182 * nothing applied. Alignment has never worked in this editor. 1183 * 1184 * So these wrap the selection themselves. The markup each one builds is 1185 * exactly what its transform in jquery.wysibb.js maps back to BBCode, 1186 * and rootSelector already tells wysibb when the caret is inside one, so 1187 * the button lights up and queryState drives the toggle. 1188 * 1189 * 2026-09-20 1190 */ 1191 var alignCommands = { 1192 justifyleft: { tag: 'div', style: 'float:left', selector: 'div[style*="float:left"]' }, 1193 justifycenter: { tag: 'center', style: '', selector: 'center' }, 1194 justifyright: { tag: 'div', style: 'float:right', selector: 'div[style*="float:right"]' } 1195 }; 1196 1197 // Any of the three, for "am I already aligned, and as what". 1198 var alignSelector = 'div[style*="float:left"],center,div[style*="float:right"]'; 1199 1200 // What a caret with nothing highlighted aligns. 1201 var alignBlockSelector = 'img,p,div,center,blockquote,li,td,th,h1,h2,h3,h4,h5,h6'; 1202 1203 // Holds the content but must not itself be wrapped: <ul><div><li> and 1204 // <tr><div><td> are not documents. Align what is inside instead. 1205 var alignContentsOnlySelector = 'li,td,th'; 1206 1207 // Its children are structural, so a wrapper cannot go between them. The 1208 // list or the table gets wrapped as a whole. 1209 var alignStructuralSelector = 'ul,ol,table,thead,tbody,tfoot,tr'; 1210 1211 function alignElement(spec) { 1212 var el = document.createElement(spec.tag); 1213 if (spec.style) el.setAttribute('style', spec.style); 1214 return el; 1215 } 1216 1217 // Move the children out and drop the wrapper. Unless it is being stripped 1218 // from inside another one, what was inside stays selected, so a second 1219 // click on a different alignment lands on the same text. 1220 function alignUnwrap(editor, wrapper, quiet) { 1221 var parent = wrapper.parentNode; 1222 if (!parent) return; 1223 1224 var first = wrapper.firstChild; 1225 var last = wrapper.lastChild; 1226 1227 while (wrapper.firstChild) parent.insertBefore(wrapper.firstChild, wrapper); 1228 parent.removeChild(wrapper); 1229 1230 if (quiet || !first || !last) return; 1231 1232 var range = document.createRange(); 1233 range.setStartBefore(first); 1234 range.setEndAfter(last); 1235 editor.selectRange(range); 1236 } 1237 1238 function alignMoveChildren(from, to) { 1239 while (from.firstChild) to.appendChild(from.firstChild); 1240 } 1241 1242 /* 1243 * What a caret with nothing highlighted should align: the block it sits 1244 * in, or the picture it is on. Wrapping an empty range would leave a 1245 * stray element behind with the caret inside it. 1246 */ 1247 function alignCaretRange(editor, range) { 1248 var block = editor.isContain(editor.getSelectNode(range), alignBlockSelector); 1249 if (!block || block === editor.body) return null; 1250 1251 var caret = document.createRange(); 1252 1253 if ($(block).is(alignContentsOnlySelector)) caret.selectNodeContents(block); 1254 else caret.selectNode(block); 1255 1256 return caret; 1257 } 1258 1259 /* 1260 * A selection across two cells shares their row, and a wrapper is no 1261 * more allowed around a tr than between two tds. Climb to the outermost 1262 * structural element - the table, the list - and wrap that, which is 1263 * somewhere a div can go. 1264 * 1265 * This is settled before anything is wrapped rather than as a fallback: 1266 * a range whose ends sit exactly on node boundaries, which is what
1267 * selecting whole cells gives, is one surroundContents() accepts, and 1268 * the DOM will happily build <tr><div><td> out of it. 1269 */ 1270 function alignStructuralRange(editor, range) { 1271 var common = range.commonAncestorContainer; 1272 if (common.nodeType === 3) common = common.parentNode; 1273 if (!common || !$(common).is(alignStructuralSelector)) return null; 1274 1275 var whole = common; 1276 1277 while (whole.parentNode 1278 && whole.parentNode !== editor.body 1279 && $(whole.parentNode).is(alignStructuralSelector)) { 1280 whole = whole.parentNode; 1281 } 1282 1283 if (whole === editor.body || !whole.parentNode) return null; 1284 1285 var outer = document.createRange(); 1286 outer.selectNode(whole); 1287 return outer; 1288 } 1289 1290 /* 1291 * surroundContents() refuses a range that starts and ends inside 1292 * different elements. Growing it out to whole children of the element 1293 * they share gives it something it will take, and - unlike lifting the 1294 * contents out and putting them back - it cannot pull the tail of one 1295 * block into another. 1296 */ 1297 function alignGrownRange(editor, range) { 1298 var common = range.commonAncestorContainer; 1299 if (common.nodeType === 3) common = common.parentNode; 1300 if (!common) return null; 1301 1302 var start = range.startContainer; 1303 var end = range.endContainer; 1304 1305 while (start && start.parentNode && start.parentNode !== common) start = start.parentNode; 1306 while (end && end.parentNode && end.parentNode !== common) end = end.parentNode; 1307 1308 if (!start || !end || start.parentNode !== common || end.parentNode !== common) return null; 1309 1310 var grown = document.createRange(); 1311 grown.setStartBefore(start); 1312 grown.setEndAfter(end); 1313 return grown; 1314 } 1315 1316 // surroundContents() throws before it touches the document, so a failed 1317 // attempt leaves nothing behind and the next one starts clean. 1318 function alignWrapRange(range, spec) { 1319 var wrapper = alignElement(spec); 1320 1321 try { 1322 range.surroundContents(wrapper); 1323 return wrapper; 1324 } catch (e) { 1325 return null; 1326 } 1327 } 1328 1329 /* 1330 * The alignment buttons. 1331 * 1332 * wysibb applies a button that only has a transform by handing the 1333 * transform's HTML to document.execCommand('insertHTML'). That works for 1334 * [quote], whose blockquote the browser is willing to split the current 1335 * block for, but not for these three: a div or a <center> dropped inside 1336 * the block the caret is in gets unwrapped on the way in, so the marker 1337 * span wysibb then looks for is not there, selectNode() is handed 1338 * undefined and throws, and the click ends with the selection gone and 1339 * nothing applied. Alignment has never worked in this editor. 1340 * 1341 * So these wrap the selection themselves. The markup each one builds is 1342 * exactly what its transform in jquery.wysibb.js maps back to BBCode, 1343 * and rootSelector already tells wysibb when the caret is inside one, so 1344 * the button lights up and queryState drives the toggle. 1345 * 1346 * 2026-09-20 1347 */ 1348 function applyAlign(editor, command, queryState) { 1349 var spec = alignCommands[command]; 1350 if (!spec) return; 1351 1352 var range = editor.getRange(); 1353 if (!range) return; 1354 1355 var current = editor.isContain(editor.getSelectNode(range), alignSelector); 1356 1357 if (current && current !== editor.body) { 1358 if (queryState) { 1359 // The alignment that is on, clicked again: take it off. 1360 alignUnwrap(editor, current); 1361 } else { 1362 // A different one: swap the wrapper so the three stay mutually 1363 // exclusive, which is what groupkey does for the buttons wysibb 1364 // applies itself. 1365 var replacement = alignElement(spec); 1366 alignMoveChildren(current, replacement); 1367 current.parentNode.replaceChild(replacement, current); 1368 editor.selectNode(replacement); 1369 } 1370 1371 editor.sync(); 1372 return; 1373 } 1374 1375 var target = range.collapsed 1376 ? alignCaretRange(editor, range) 1377 : (alignStructuralRange(editor, range) || range); 1378 1379 if (!target) return; 1380 1381 var wrapper = alignWrapRange(target, spec); 1382 1383 if (!wrapper) { 1384 var grown = alignGrownRange(editor, target); 1385 if (grown) wrapper = alignWrapRange(grown, spec); 1386 } 1387 1388 // Nothing this command can wrap without breaking the document. 1389 if (!wrapper) return; 1390 1391 /* 1392 * A selection can run from inside one alignment out into the text 1393 * after it, which puts that whole wrapper inside the new one. Left 1394 * alone it renders as [right][center]...[/center][/right]. 1395 */ 1396 $(wrapper).find(alignSelector).each(function() { 1397 alignUnwrap(editor, this, true); 1398 }); 1399 1400 editor.selectNode(wrapper); 1401 editor.sync(); 1402 } 1403 1404 $.fn.wysibb = function(settings) { 1405 if (!isLoggedIn()) return originalWysibb.call(this, settings); 1406 1407 return this.each(function() { 1408 var textarea = $(this); 1409 if (textarea.data('wbb')) return; 1410 1411 var editorSettings = $.extend(true, {}, settings || {}); 1412 var buttons = editorSettings.buttons || defaultButtons; 1413 if (buttons.indexOf('proofread') === -1) buttons += ',|,proofread,servicelink,chattemplate'; 1414 editorSettings.buttons = buttons; 1415 1416 var existingInit = editorSettings.initCallback; 1417 editorSettings.initCallback = function() { 1418 if ($.isFunction(existingInit)) existingInit.call(this); 1419 setupEditor(this); 1420 }; 1421 editorSettings.allButtons = $.extend(true, {}, editorSettings.allButtons || {}, { 1422 proofread: { 1423 title: language('EDITOR_PROOFREAD', 'AI spelling and grammar'), 1424 buttonHTML: '<i class="fas fa-spell-check"></i>', 1425 cmd: function() { toggleProofread(this); }, 1426 queryState: function() { return !!this.proofreadEnabled; } 1427 }, 1428 servicelink: { 1429 title: language('EDITOR_SERVICE_LINK', 'Insert a service link'), 1430 buttonHTML: '<i class="far fa-link"></i>', 1431 cmd: function() { openServicePicker(this); } 1432 }, 1433 chattemplate: { 1434 title: language('EDITOR_CHAT_TEMPLATES', 'Chat templates'), 1435 buttonHTML: '<i class="far fa-comment-alt"></i>', 1436 cmd: function() { openTemplatePicker(this); } 1437 } 1438 }); 1439 1440 /* 1441 * Only cmd is supplied, so each button keeps the transform, 1442 * rootSelector and bbSelector jquery.wysibb.js defines for it - 1443 * wysibb drops a default transform only when the settings 1444 * override carries one of its own. 1445 */ 1446 $.each(alignCommands, function(command) { 1447 editorSettings.allButtons[command] = $.extend( 1448 true, 1449 editorSettings.allButtons[command] || {}, 1450 { 1451 cmd: function(cmdName, value, queryState) { 1452 /* 1453 * Source mode edits the textarea, where there is 1454 * no DOM to wrap and nothing inserts HTML, so 1455 * wysibb's own handler is already right - it puts 1456 * the BBCode around the selected text. Only the 1457 * WYSIWYG side needs taking over. 1458 */ 1459 if (this.options.bbmode) { 1460 this.wbbExecCommand(cmdName, value, queryState); 1461 return; 1462 } 1463 1464 applyAlign(this, cmdName, queryState); 1465 } 1466 } 1467 ); 1468 }); 1469 1470 originalWysibb.call(textarea, editorSettings); 1471 }); 1472 }; 1473})(jQuery);
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.