1(function () {
2 'use strict';
3
4 var DEFAULT_TOOLBAR = [
5 'bold,italic,underline,strike',
6 'size,color,removeformat',
7 'left,center,right',
8 'bulletlist,orderedlist',
9 'quote,code',
10 'link,unlink,image,youtube',
11 'undo,redo,source'
12 ].join('|');
13
14 function boot() {
15 if (typeof sceditor === 'undefined') {
16 return;
17 }
18
19 var boxes = document.querySelectorAll('textarea.znote-editor');
20 if (!boxes.length) {
21 return;
22 }
23
24 Array.prototype.forEach.call(boxes, function (box) {
25 var base = box.getAttribute('data-asset-base') || 'assets/sceditor/';
26
27 sceditor.create(box, {
28 format: 'bbcode',
29 icons: 'material',
30 style: base + (box.getAttribute('data-content-css') || 'znote-content.css'),
31 plugins: 'undo,autoyoutube',
32 toolbar: box.getAttribute('data-toolbar') || DEFAULT_TOOLBAR,
33 emoticonsEnabled: false,
34 resizeEnabled: true,
35 resizeWidth: false,
36 autoUpdate: true,
37 height: box.getAttribute('data-height') || 260
38 });
39
40 mirrorTheme(box);
41 mirrorColors(box);
42 bindDraft(box);
43
44 var limit = parseInt(box.getAttribute('data-maxlength'), 10);
45 if (!isNaN(limit) && limit > 0) {
46 startCounter(box, limit);
47 }
48 });
49
50 bindForms();
51 bindQuoteButtons();
52 }
53
54 /* The editing area is an iframe, so it cannot see the panel's data-acp-theme
55 or its stylesheet. Copy the attribute across and let the content CSS key
56 off it, so the editor is dark when the admin panel is dark. */
57 function mirrorTheme(box) {
58 var theme = document.documentElement.getAttribute('data-acp-theme');
59 if (!theme) {
60 return;
61 }
62
63 var instance = sceditor.instance(box);
64 var apply = function () {
65 var body = instance.getBody();
66 if (body && body.ownerDocument) {
67 body.ownerDocument.documentElement.setAttribute('data-acp-theme', theme);
68 }
69 };
70
71 apply();
72 instance.bind('ready', apply);
73 }
74
75 function cssVar(styles, name) {
76 return (styles.getPropertyValue(name) || '').trim();
77 }
78
79 function parseRgb(value) {
80 var match = String(value || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
81 if (!match) {
82 return null;
83 }
84
85 return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
86 }
87
88 function isDark(value) {
89 var rgb = parseRgb(value);
90 if (!rgb) {
91 return true;
92 }
93
94 return ((rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000) < 128;
95 }
96
97 function mirrorColors(box) {
98 var rootStyles = window.getComputedStyle(document.documentElement);
99 var bodyStyles = window.getComputedStyle(document.body);
100 var bg = cssVar(rootStyles, '--secondary') || cssVar(rootStyles, '--primary') || bodyStyles.backgroundColor || '#191c21';
101 var color = cssVar(rootStyles, '--font-color') || bodyStyles.color || (isDark(bg) ? '#dfe4ec' : '#1c1f27');
102 var link = cssVar(rootStyles, '--anchor') || cssVar(rootStyles, '--anchor-hover') || (isDark(bg) ? '#d9a441' : '#b9762a');
103 var scheme = isDark(bg) ? 'dark' : 'light';
104
105 var instance = sceditor.instance(box);
106 var apply = function () {
107 var body = instance.getBody();
108 if (!body || !body.ownerDocument || !body.ownerDocument.documentElement) {
109 return;
110 }
111
112 var html = body.ownerDocument.documentElement;
113 html.setAttribute('data-znote-editor-scheme', scheme);
114 html.style.setProperty('--znote-editor-bg', 'transparent');
115 html.style.setProperty('--znote-editor-color', color);
116 html.style.setProperty('--znote-editor-link', link);
117 html.style.setProperty('--znote-editor-selection-bg', scheme === 'dark' ? '#0b65c2' : '#bcd7ff');
118 html.style.setProperty('--znote-editor-selection-color', scheme === 'dark' ? '#ffffff' : '#111827');
119 };
120
121 apply();
122 instance.bind('ready', apply);
123 instance.bind('focus', apply);
124 }
125
126 function instanced(form) {
127 return Array.prototype.filter.call(
128 form.querySelectorAll('textarea.znote-editor'),
129 function (box) { return !!sceditor.instance(box); }
130 );
131 }
132
133 /* The changelog column is varchar(255), and BBCode tags count toward it -
134 so the limit is shown live rather than discovered on save. */
135 function startCounter(box, limit) {
136 var instance = sceditor.instance(box);
137 var note = document.createElement('p');
138 note.className = 'znote-editor-count';
139 box.parentNode.appendChild(note);
140
141 var tick = function () {
142 var used = instance.val().length;
143 note.textContent = used + ' / ' + limit + ' characters';
144 note.classList.toggle('is-over', used > limit);
145 };
146
147 instance.bind('valuechanged', tick);
148 tick();
149 }
150
151 function bindForms() {
152 var seen = [];
153
154 Array.prototype.forEach.call(document.querySelectorAll('textarea.znote-editor'), function (box) {
155 var form = box.form;
156 if (!form || seen.indexOf(form) !== -1) {
157 return;
158 }
159 seen.push(form);
160
161 form.addEventListener('submit', function (event) {
162 var blocked = false;
163
164 instanced(form).forEach(function (box) {
165 var value = sceditor.instance(box).val();
166
167 // SCEditor syncs on submit already; doing it here too keeps
168 // this correct if a theme submits the form from script.
169 box.value = value;
170
171 var problem = check(box, value);
172 warn(box, problem);
173 if (problem) {
174 blocked = true;
175 }
176 });
177
178 if (blocked) {
179 event.preventDefault();
180 } else {
181 instanced(form).forEach(clearDraft);
182 }
183 });
184 });
185 }
186
187 function draftKey(box) {
188 return 'znoteEditorDraft:' + location.pathname + location.search + ':' + (box.name || '');
189 }
190
191 function clearDraft(box) {
192 try {
193 localStorage.removeItem(draftKey(box));
194 } catch (e) {}
195 }
196
197 function bindDraft(box) {
198 var key = draftKey(box);
199 var instance = sceditor.instance(box);
200 var saved = null;
201
202 try {
203 saved = localStorage.getItem(key);
204 } catch (e) {}
205
206 if (saved && !box.value) {
207 instance.val(saved);
208 }
209
210 instance.bind('valuechanged', function () {
211 try {
212 var value = instance.val();
213 if (value) {
214 localStorage.setItem(key, value);
215 } else {
216 localStorage.removeItem(key);
217 }
218 } catch (e) {}
219 });
220 }
221
222 function bindQuoteButtons() {
223 Array.prototype.forEach.call(document.querySelectorAll('.znf-quote-btn'), function (btn) {
224 btn.addEventListener('click', function () {
225 var article = btn.closest('article');
226 var target = document.querySelector('.znf-reply textarea.znote-editor');
227 if (!article || !target || typeof sceditor === 'undefined') {
228 return;
229 }
230
231 var source = article.querySelector('.znf-quote-source');
232 var instance = sceditor.instance(target);
233 if (!source || !instance) {
234 return;
235 }
236
237 var author = btn.getAttribute('data-author') || '';
238 var quoted = '[quote=' + author + ']' + source.content.textContent + '[/quote]\n\n';
239 instance.val(quoted + instance.val());
240 instance.focus();
241 target.scrollIntoView({ behavior: 'smooth', block: 'center' });
242 });
243 });
244 }
245
246 function check(box, value) {
247 var max = parseInt(box.getAttribute('data-max-images'), 10);
248 if (!isNaN(max) && max >= 0) {
249 var used = (value.match(/\[img(=[^\]]*)?\]/gi) || []).length;
250 if (used > max) {
251 return max === 0
252 ? 'Images are not allowed here.'
253 : 'Only ' + max + ' image' + (max === 1 ? '' : 's') + ' allowed — you have ' +
254 used + '. Remove ' + (used - max) + '.';
255 }
256 }
257
258 var limit = parseInt(box.getAttribute('data-maxlength'), 10);
259 if (!isNaN(limit) && limit > 0 && value.length > limit) {
260 return 'Too long: ' + value.length + ' of ' + limit +
261 ' characters. BBCode tags count too — remove ' + (value.length - limit) + '.';
262 }
263
264 return '';
265 }
266
267 function warn(box, message) {
268 var container = box.parentNode;
269 var note = container.querySelector('.znote-editor-warning');
270
271 if (!message) {
272 if (note) note.remove();
273 return;
274 }
275
276 if (!note) {
277 note = document.createElement('p');
278 note.className = 'znote-editor-warning';
279 container.appendChild(note);
280 }
281
282 note.textContent = message;
283 }
284
285 if (document.readyState === 'loading') {
286 document.addEventListener('DOMContentLoaded', boot);
287 } else {
288 boot();
289 }
290})();
291