PageSourceSearch

https://pixelclerks.com/assets/js/wysibb/editor-tools.1789931642.js

js pixelclerks.com collected 2026-09-26 18:18:36 UTC 53,663 bytes, 1,473 lines download raw bytes

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('&times;'));
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">&times;</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.