@alfacapital-tech/c4builder 0.9.0-rc.1 → 0.9.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,39 @@
1
+ import { UI_STRINGS } from "./site.i18n.js";
1
2
  export const COMMENTS_PATH = '/__c4builder/comments';
3
+ export const FEEDBACK_PATH = '/__c4builder/feedback';
4
+ export const FEEDBACK_HEADER = 'X-C4builder-Feedback';
2
5
  const CSS = [
3
- '.c4b-cbtn{position:absolute;z-index:1001;padding:4px 10px;border:1px solid #42b983;border-radius:4px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.15)}',
6
+ '.c4b-cbtns{position:absolute;z-index:1001;white-space:nowrap}',
7
+ '.c4b-cbtns button{margin-right:4px;padding:4px 10px;border:1px solid #42b983;border-radius:4px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.15)}',
8
+ '.c4b-cbtns button.c4b-fb{border-color:#3b82f6}',
4
9
  '.c4b-cpop{position:absolute;z-index:1002;width:380px;max-width:calc(100vw - 32px);padding:10px;border:1px solid #ddd;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.2)}',
5
10
  '.c4b-cpop blockquote{margin:0 0 8px;padding:4px 8px;border-left:3px solid #42b983;background:#f8f8f8;max-height:80px;overflow:auto;font-size:12px;white-space:pre-wrap}',
6
- '.c4b-cpop textarea{width:100%;box-sizing:border-box;min-height:70px;padding:6px;border:1px solid #ccc;border-radius:4px;font:13px/1.4 sans-serif;resize:vertical}',
11
+ '.c4b-cpop textarea,.c4b-review textarea{width:100%;box-sizing:border-box;min-height:70px;padding:6px;border:1px solid #ccc;border-radius:4px;font:13px/1.4 sans-serif;resize:vertical}',
7
12
  '.c4b-cpop .c4b-row{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:8px}',
8
13
  '.c4b-cpop small{color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
9
- '.c4b-cpop button{padding:4px 12px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;cursor:pointer;font:13px sans-serif}',
10
- '.c4b-cpop button.c4b-primary{border-color:#42b983;background:#42b983;color:#fff}',
11
- '.c4b-cpop button:disabled{opacity:.6;cursor:default}',
14
+ // Кнопки поповера не сжимаются: длинную подсказку обрезает многоточие, а не кнопки уходят
15
+ // на вторую строку (русские подписи и длинные названия разделов).
16
+ '.c4b-cpop .c4b-row>span{flex:none}',
17
+ '.c4b-cpop button,.c4b-review button{padding:4px 12px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;cursor:pointer;font:13px sans-serif}',
18
+ '.c4b-cpop button.c4b-primary,.c4b-review button.c4b-primary{border-color:#42b983;background:#42b983;color:#fff}',
19
+ '.c4b-cpop button:disabled,.c4b-review button:disabled{opacity:.6;cursor:default}',
20
+ // Панель review — ниже кнопок у выделения и поповера: она прибита к низу экрана и иначе
21
+ // перекрывает их у выделения в нижних строках.
22
+ '.c4b-review{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:1000;width:520px;max-width:calc(100vw - 32px);box-sizing:border-box;padding:8px 12px;border:1px solid #3b82f6;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.2)}',
23
+ '.c4b-review .c4b-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}',
24
+ '.c4b-review .c4b-row small{flex:1;color:#888;white-space:nowrap}',
25
+ // Кнопки панели переносятся одной группой: иначе при нехватке места часть уезжает на
26
+ // вторую строку, а Discard остаётся один в первой.
27
+ '.c4b-review .c4b-acts{display:flex;gap:6px;margin-left:auto}',
28
+ '.c4b-review button.c4b-link{padding:0;border:0;background:none;color:#2c3e50;font-weight:700;text-decoration:underline dotted}',
29
+ '.c4b-review ul{margin:6px 0;padding:0;list-style:none;max-height:40vh;overflow:auto}',
30
+ '.c4b-review li{padding:3px 0;border-top:1px solid #eee;overflow-wrap:anywhere}',
31
+ '.c4b-review textarea{margin-top:6px}',
32
+ '.c4b-review .c4b-del{opacity:1}',
12
33
  '.c4b-ctoast{position:fixed;left:16px;bottom:16px;z-index:1003;max-width:420px;padding:8px 14px;border-radius:6px;background:#2c3e50;color:#fff;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.3)}',
13
34
  '.c4b-ctoast.c4b-err{background:#c0392b}',
14
35
  'mark.c4b-hl{background:#fff3a0;color:inherit;padding:0;border-radius:2px}',
36
+ 'mark.c4b-dhl{background:#cfe3ff;color:inherit;padding:0;border-radius:2px}',
15
37
  'sup.c4b-ref{font-size:.7em;line-height:0;margin-left:1px;user-select:none}',
16
38
  'sup.c4b-ref a{color:#b58900;text-decoration:none;font-weight:700;cursor:pointer}',
17
39
  '.c4b-qrow{transition:background .3s}',
@@ -21,22 +43,47 @@ const CSS = [
21
43
  '.c4b-del:hover{color:#fff;background:#c0392b;border-color:#c0392b}',
22
44
  '.c4b-del:disabled{opacity:.5;cursor:default}'
23
45
  ].join('');
24
- const jsString = (s) => JSON.stringify(s)
46
+ const jsValue = (v) => JSON.stringify(v)
25
47
  .replace(/</g, '\\u003c')
26
48
  .replace(/[\u0080-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
27
- export const commentsSnippet = (heading) => `
49
+ // Начальное состояние агентского режима и ключ проекта вставляет сервер, дальше его обновляют
50
+ // события `c4builder:agent` от сниппета livereload (change site-agent-feedback, design D4, D8).
51
+ // Язык выбирает сам скрипт: при `auto` — по navigator.languages (change site-ui-language, D1).
52
+ export const commentsSnippet = (heading, { agent = false, listening = false, project = '', language = 'auto' } = {}) => `
28
53
  <script>
29
54
  (function () {
30
55
  if (window.__c4builderComments) return;
31
56
  window.__c4builderComments = true;
32
57
  var ENDPOINT = '${COMMENTS_PATH}';
33
- var HEADING = ${jsString(heading)};
58
+ var FEEDBACK = '${FEEDBACK_PATH}';
59
+ var HEADING = ${jsValue(heading)};
34
60
  var TOAST_KEY = '__c4builderCommentToast';
61
+ var DRAFT_KEY = '__c4builderReview:' + ${jsValue(project)};
62
+ var AGENT = { on: ${agent}, listening: ${listening} };
35
63
  var BLOCKS = 'p,li,td,th,h1,h2,h3,h4,h5,h6,pre,blockquote,dd,dt,div,section,details,summary';
36
64
  var style = document.createElement('style');
37
- style.textContent = ${jsString(CSS)};
65
+ style.textContent = ${jsValue(CSS)};
38
66
  document.head.appendChild(style);
39
67
 
68
+ var STRINGS = ${jsValue(UI_STRINGS)};
69
+ var has = function (lang) { return Object.prototype.hasOwnProperty.call(STRINGS, lang); };
70
+ var LANG = (function (pref) {
71
+ if (has(pref)) return pref;
72
+ var prefs = [].concat(navigator.languages || [], navigator.language || []);
73
+ for (var i = 0; i < prefs.length; i++) {
74
+ var tag = String(prefs[i]).toLowerCase().split('-')[0];
75
+ if (has(tag)) return tag;
76
+ }
77
+ return 'en';
78
+ })(${jsValue(language)});
79
+ var plural = new Intl.PluralRules(LANG);
80
+ var t = function (key, vars) {
81
+ vars = vars || {};
82
+ var s = STRINGS[LANG][key];
83
+ if (typeof s === 'object') s = s[plural.select(vars.n)];
84
+ return s.replace(/\\{(\\w+)\\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
85
+ };
86
+
40
87
  var collapse = function (s) { return String(s).replace(/\\s+/g, ' ').trim(); };
41
88
  var same = function (a, b) { return collapse(a).toLowerCase() === collapse(b).toLowerCase(); };
42
89
 
@@ -109,7 +156,7 @@ export const commentsSnippet = (heading) => `
109
156
  for (var r = 0; r < roots.length; r++) walk(roots[r]);
110
157
  return { text: chars.join(''), map: map };
111
158
  };
112
- var wrapRange = function (idx, from, to, title) {
159
+ var wrapRange = function (idx, from, to, title, cls) {
113
160
  var first = null, last = null, i;
114
161
  for (i = from; i < to && !first; i++) first = idx.map[i];
115
162
  for (i = to - 1; i >= from && !last; i--) last = idx.map[i];
@@ -125,9 +172,10 @@ export const commentsSnippet = (heading) => `
125
172
  var end = n === last.node ? last.offset + 1 : n.nodeValue.length;
126
173
  if (end < n.nodeValue.length) n.splitText(end);
127
174
  var mid = start > 0 ? n.splitText(start) : n;
128
- if (!mid.nodeValue.trim()) continue;
175
+ // Overlapping quotes: the number link of a question is not part of the text.
176
+ if (!mid.nodeValue.trim() || mid.parentNode.closest('sup.c4b-ref')) continue;
129
177
  var mark = document.createElement('mark');
130
- mark.className = 'c4b-hl';
178
+ mark.className = cls;
131
179
  mark.title = title;
132
180
  mid.parentNode.insertBefore(mark, mid);
133
181
  mark.appendChild(mid);
@@ -135,6 +183,20 @@ export const commentsSnippet = (heading) => `
135
183
  }
136
184
  return marks;
137
185
  };
186
+ var tryIn = function (idx, quote, cls, title) {
187
+ var at = idx.text.indexOf(quote);
188
+ var marks = at < 0 ? null : wrapRange(idx, at, at + quote.length, title, cls);
189
+ return marks && marks.length ? marks : null;
190
+ };
191
+ // The quote is searched in its own section first: the same words may repeat on the page.
192
+ var locate = function (root, q, skip, cls, title) {
193
+ var marks = null;
194
+ if (q.section) {
195
+ var sh = findHeading(root, q.section);
196
+ if (sh && skip.indexOf(sh) < 0) marks = tryIn(textIndex(sectionOf(sh), skip), q.quote, cls, title);
197
+ }
198
+ return marks || tryIn(textIndex([root], skip), q.quote, cls, title);
199
+ };
138
200
  var flash = function (row) {
139
201
  row.scrollIntoView({ block: 'center', behavior: 'smooth' });
140
202
  row.classList.add('c4b-qflash');
@@ -168,28 +230,23 @@ export const commentsSnippet = (heading) => `
168
230
  del.type = 'button';
169
231
  del.className = 'c4b-del';
170
232
  del.textContent = '\\u00d7';
171
- del.title = 'Remove this question';
233
+ del.title = t('removeQuestion');
172
234
  del.addEventListener('click', function (e) {
173
235
  e.preventDefault();
174
236
  e.stopPropagation();
175
237
  del.disabled = true;
176
238
  send({ page: pageFile(), number: Number(item.number) || null, text: collapse(item.text) }, 'DELETE').then(function (r) {
177
- var msg = 'Removed from ' + r.file;
239
+ var msg = t('removed', { file: r.file });
178
240
  try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
179
241
  toast(msg);
180
242
  }, function (err) {
181
243
  del.disabled = false;
182
- toast('Remove failed: ' + (err && err.message ? err.message : err), true);
244
+ failure('removeFailed')(err);
183
245
  });
184
246
  });
185
247
  item.cell.appendChild(del);
186
248
  };
187
249
  var skip = [h].concat(section);
188
- var tryIn = function (idx, q) {
189
- var at = idx.text.indexOf(q.quote);
190
- var marks = at < 0 ? null : wrapRange(idx, at, at + q.quote.length, q.comment);
191
- return marks && marks.length ? marks : null;
192
- };
193
250
  items.forEach(function (item) {
194
251
  item.row.classList.add('c4b-qrow');
195
252
  item.row.id = item.row.id || 'c4b-q-' + item.number;
@@ -197,11 +254,7 @@ export const commentsSnippet = (heading) => `
197
254
  // Index is rebuilt per try: wrapping splits text nodes and stales the previous map.
198
255
  var q = null, marks = null;
199
256
  parseQuestions(item.text).some(function (c) {
200
- if (c.section) {
201
- var sh = findHeading(root, c.section);
202
- if (sh && skip.indexOf(sh) < 0) marks = tryIn(textIndex(sectionOf(sh), skip), c);
203
- }
204
- marks = marks || tryIn(textIndex([root], skip), c);
257
+ marks = locate(root, c, skip, 'c4b-hl', c.comment);
205
258
  q = marks ? c : null;
206
259
  return !!marks;
207
260
  });
@@ -219,7 +272,12 @@ export const commentsSnippet = (heading) => `
219
272
  lastMark.parentNode.insertBefore(sup, lastMark.nextSibling);
220
273
  });
221
274
  };
222
- var schedule = function () { setTimeout(function () { try { highlight(); } catch (e) {} }, 50); };
275
+ var schedule = function () {
276
+ setTimeout(function () {
277
+ try { highlight(); } catch (e) {}
278
+ try { highlightDraft(); } catch (e) {}
279
+ }, 50);
280
+ };
223
281
  var docsifyVm = null;
224
282
  if (window.$docsify) {
225
283
  window.$docsify.plugins = [].concat(window.$docsify.plugins || [], function (hook, vm) {
@@ -230,12 +288,24 @@ export const commentsSnippet = (heading) => `
230
288
  window.addEventListener('hashchange', function () { setTimeout(schedule, 300); });
231
289
  window.addEventListener('load', function () { setTimeout(schedule, 500); });
232
290
 
233
- var btn = document.createElement('button');
234
- btn.type = 'button';
235
- btn.className = 'c4b-cbtn';
236
- btn.textContent = 'Comment';
237
- btn.hidden = true;
238
- document.body.appendChild(btn);
291
+ var button = function (label, onClick, cls) {
292
+ var b = document.createElement('button');
293
+ b.type = 'button';
294
+ b.textContent = label;
295
+ if (cls) b.className = cls;
296
+ if (onClick) b.addEventListener('click', onClick);
297
+ return b;
298
+ };
299
+ var btns = document.createElement('div');
300
+ btns.className = 'c4b-cbtns';
301
+ btns.hidden = true;
302
+ var fbBtn = button(t('feedback'), null, 'c4b-fb');
303
+ fbBtn.title = t('feedbackTitle');
304
+ var qBtn = button(t('question'));
305
+ qBtn.title = t('questionTitle', { heading: HEADING });
306
+ btns.appendChild(fbBtn);
307
+ btns.appendChild(qBtn);
308
+ document.body.appendChild(btns);
239
309
  var pop = null;
240
310
 
241
311
  var selection = function () {
@@ -273,9 +343,9 @@ export const commentsSnippet = (heading) => `
273
343
  var update = function () {
274
344
  if (pop) return;
275
345
  var s = selection();
276
- if (!s) { btn.hidden = true; return; }
277
- btn.hidden = false;
278
- place(btn, anchorRect(s.range));
346
+ if (!s) { btns.hidden = true; return; }
347
+ btns.hidden = false;
348
+ place(btns, anchorRect(s.range));
279
349
  };
280
350
  document.addEventListener('selectionchange', function () { setTimeout(update, 0); });
281
351
 
@@ -292,8 +362,8 @@ export const commentsSnippet = (heading) => `
292
362
  if (!p) return (window.$docsify && window.$docsify.homepage) || 'README.md';
293
363
  return /\\.md$/i.test(p) ? p : p + '.md';
294
364
  };
295
- var send = function (body, method) {
296
- return fetch(ENDPOINT, {
365
+ var send = function (body, method, url) {
366
+ return fetch(url || ENDPOINT, {
297
367
  method: method || 'POST',
298
368
  headers: { 'Content-Type': 'application/json' },
299
369
  body: JSON.stringify(body)
@@ -304,75 +374,246 @@ export const commentsSnippet = (heading) => `
304
374
  });
305
375
  });
306
376
  };
377
+ var failure = function (key) {
378
+ return function (err) { toast(t(key, { error: err && err.message ? err.message : err }), true); };
379
+ };
380
+
381
+ // Feedback to the agent: "Delivered" only after the server handed the batch to
382
+ // "c4builder feedback"; the event may outrun the POST response, hence both maps.
383
+ var delivered = {}, pending = {};
384
+ var sendBatch = function (decision, comments, general) {
385
+ return send({ decision: decision, comments: comments, general: general }, 'POST', FEEDBACK).then(function (r) {
386
+ if (delivered[r.id]) return toast(t('delivered'));
387
+ pending[r.id] = true;
388
+ toast(t(r.queued ? 'queued' : 'sent'));
389
+ });
390
+ };
391
+ window.addEventListener('c4builder:delivered', function (e) {
392
+ var id = e.detail && e.detail.id;
393
+ delivered[id] = true;
394
+ if (pending[id]) { delete pending[id]; toast(t('delivered')); }
395
+ });
396
+
397
+ // Review draft lives in localStorage per project: it survives livereload and a closed tab;
398
+ // without storage it lives until the page reloads.
399
+ var memDraft = [];
400
+ var draft = function () {
401
+ try {
402
+ var v = JSON.parse(localStorage.getItem(DRAFT_KEY) || '[]');
403
+ return Array.isArray(v) ? v : [];
404
+ } catch (e) { return memDraft; }
405
+ };
406
+ var saveDraft = function (items) {
407
+ memDraft = items;
408
+ try {
409
+ if (items.length) localStorage.setItem(DRAFT_KEY, JSON.stringify(items));
410
+ else localStorage.removeItem(DRAFT_KEY);
411
+ } catch (e) {}
412
+ renderPanel();
413
+ highlightDraft();
414
+ };
415
+ var highlightDraft = function () {
416
+ var root = content();
417
+ if (!root) return;
418
+ var marks = root.querySelectorAll('mark.c4b-dhl');
419
+ for (var i = 0; i < marks.length; i++) {
420
+ var m = marks[i], parent = m.parentNode;
421
+ while (m.firstChild) parent.insertBefore(m.firstChild, m);
422
+ parent.removeChild(m);
423
+ parent.normalize();
424
+ }
425
+ var page = pageFile();
426
+ var h = findHeading(root, HEADING);
427
+ var skip = h ? [h].concat(sectionOf(h)) : [];
428
+ draft().forEach(function (it) {
429
+ if (it.page === page) locate(root, { quote: collapse(it.quote), section: it.section }, skip, 'c4b-dhl', it.comment);
430
+ });
431
+ };
432
+
433
+ var panel = document.createElement('div');
434
+ panel.className = 'c4b-review';
435
+ var expanded = false, busy = false;
436
+ var count = button('', function () { expanded = !expanded; renderPanel(); }, 'c4b-link');
437
+ var listen = document.createElement('small');
438
+ listen.className = 'c4b-listen';
439
+ var list = document.createElement('ul');
440
+ var general = document.createElement('textarea');
441
+ general.placeholder = t('generalPlaceholder');
442
+ general.hidden = true;
443
+ var finish = function (decision) {
444
+ var items = draft(), ids = items.map(function (it) { return it.id; });
445
+ busy = true;
446
+ renderPanel();
447
+ sendBatch(decision, items, general.hidden ? '' : general.value.trim()).then(function () {
448
+ general.value = '';
449
+ general.hidden = true;
450
+ expanded = false;
451
+ saveDraft(draft().filter(function (it) { return ids.indexOf(it.id) < 0; }));
452
+ }, failure('reviewFailed')).then(function () { busy = false; renderPanel(); });
453
+ };
454
+ var discard = button(t('discard'), function () {
455
+ var n = draft().length;
456
+ if (n && !window.confirm(t('discardConfirm', { n: n }))) return;
457
+ general.value = '';
458
+ general.hidden = true;
459
+ saveDraft([]);
460
+ });
461
+ // One finishing action, two labels: on an empty draft "Approve" does not read as "finish".
462
+ var approve = button('', function () { finish('approved'); });
463
+ var submit = button(t('submitReview'), function () {
464
+ if (!general.hidden) return finish('submitted');
465
+ general.hidden = false;
466
+ general.focus();
467
+ }, 'c4b-primary');
468
+ general.addEventListener('keydown', function (e) {
469
+ if (e.key === 'Escape') { e.preventDefault(); general.hidden = true; }
470
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submit.click(); }
471
+ });
472
+ var head = document.createElement('div');
473
+ head.className = 'c4b-row';
474
+ var acts = document.createElement('span');
475
+ acts.className = 'c4b-acts';
476
+ [discard, approve, submit].forEach(function (el) { acts.appendChild(el); });
477
+ [count, listen, acts].forEach(function (el) { head.appendChild(el); });
478
+ panel.appendChild(head);
479
+ panel.appendChild(list);
480
+ panel.appendChild(general);
481
+ document.body.appendChild(panel);
482
+ var renderPanel = function () {
483
+ panel.hidden = !AGENT.on;
484
+ var items = draft(), pages = {}, empty = !items.length;
485
+ items.forEach(function (it) { pages[it.page] = true; });
486
+ var n = Object.keys(pages).length;
487
+ count.textContent = empty
488
+ ? t('reviewEmpty')
489
+ : t('reviewCount', { comments: t('comments', { n: items.length }), pages: t('pages', { n: n }) });
490
+ count.disabled = empty;
491
+ list.textContent = '';
492
+ items.forEach(function (it) {
493
+ var li = document.createElement('li');
494
+ var q = collapse(it.quote);
495
+ li.textContent = it.page + ' \\u2014 \\u00ab' + (q.length > 60 ? q.slice(0, 59) + '\\u2026' : q) + '\\u00bb: ' + it.comment;
496
+ var del = button('\\u00d7', function () {
497
+ saveDraft(draft().filter(function (x) { return x.id !== it.id; }));
498
+ }, 'c4b-del');
499
+ del.title = t('removeFromReview');
500
+ li.appendChild(del);
501
+ list.appendChild(li);
502
+ });
503
+ list.hidden = !expanded || empty;
504
+ approve.textContent = t(empty ? 'done' : 'approve');
505
+ approve.title = t(empty ? 'doneTitle' : 'approveTitle');
506
+ approve.className = empty ? 'c4b-primary' : '';
507
+ discard.hidden = submit.hidden = empty;
508
+ discard.disabled = busy;
509
+ approve.disabled = busy;
510
+ submit.disabled = busy || empty;
511
+ };
512
+ var renderAgent = function () {
513
+ fbBtn.hidden = !AGENT.on;
514
+ renderPanel();
515
+ var text = t(AGENT.listening ? 'listening' : 'notListening');
516
+ var els = document.querySelectorAll('.c4b-listen');
517
+ for (var i = 0; i < els.length; i++) { els[i].textContent = text; els[i].title = text; }
518
+ };
519
+ window.addEventListener('c4builder:agent', function (e) {
520
+ var d = e.detail || {};
521
+ AGENT.on = !!d.on;
522
+ AGENT.listening = !!d.listening;
523
+ renderAgent();
524
+ });
525
+ window.addEventListener('storage', function (e) {
526
+ if (e.key === DRAFT_KEY) { renderPanel(); highlightDraft(); }
527
+ });
307
528
 
308
529
  var close = function () {
309
530
  if (pop) { pop.remove(); pop = null; }
310
- btn.hidden = true;
531
+ btns.hidden = true;
311
532
  };
312
- var open = function (data, rect) {
533
+ var open = function (data, rect, feedback) {
313
534
  close();
314
535
  pop = document.createElement('div');
315
536
  pop.className = 'c4b-cpop';
316
537
  var quote = document.createElement('blockquote');
317
538
  quote.textContent = data.quote;
318
539
  var ta = document.createElement('textarea');
319
- ta.placeholder = 'Your question or comment (Ctrl+Enter to add, Esc to cancel)';
540
+ ta.placeholder = t(feedback ? 'feedbackPlaceholder' : 'questionPlaceholder');
320
541
  var row = document.createElement('div');
321
542
  row.className = 'c4b-row';
322
543
  var hint = document.createElement('small');
323
- hint.textContent = 'To: ' + HEADING + (data.section ? ' / ' + data.section : '');
324
- hint.title = hint.textContent;
544
+ if (feedback) hint.className = 'c4b-listen';
545
+ else hint.textContent = hint.title = t('questionTo', { target: HEADING + (data.section ? ' / ' + data.section : '') });
325
546
  var actions = document.createElement('span');
326
- var cancel = document.createElement('button');
327
- cancel.type = 'button';
328
- cancel.textContent = 'Cancel';
329
- var ok = document.createElement('button');
330
- ok.type = 'button';
331
- ok.className = 'c4b-primary';
332
- ok.textContent = 'Add';
333
- actions.appendChild(cancel);
334
- actions.appendChild(document.createTextNode(' '));
335
- actions.appendChild(ok);
547
+ var buttons = [button(t('cancel'), close)];
548
+ var run = function (action) {
549
+ return function () {
550
+ var comment = ta.value.trim();
551
+ if (!comment) { ta.focus(); return; }
552
+ buttons.forEach(function (b) { b.disabled = true; });
553
+ action(comment).then(close, function (err) {
554
+ buttons.forEach(function (b) { b.disabled = false; });
555
+ failure(feedback ? 'feedbackFailed' : 'questionFailed')(err);
556
+ });
557
+ };
558
+ };
559
+ var item = function (comment) {
560
+ return { page: pageFile(), quote: data.quote, section: data.section, comment: comment };
561
+ };
562
+ var primary;
563
+ if (feedback) {
564
+ var add = button(t('addToReview'), run(function (c) {
565
+ var it = item(c);
566
+ it.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
567
+ saveDraft(draft().concat([it]));
568
+ return Promise.resolve();
569
+ }));
570
+ var now = button(t('sendNow'), run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
571
+ buttons.push(add, now);
572
+ // As in a GitLab merge request: once a review is started, comments go into it.
573
+ primary = draft().length ? add : now;
574
+ } else {
575
+ primary = button(t('addQuestion'), run(function (c) {
576
+ return send(item(c)).then(function (r) {
577
+ var msg = t(r.number ? 'addedNumber' : 'added', { file: r.file, number: r.number });
578
+ try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
579
+ toast(msg);
580
+ });
581
+ }));
582
+ buttons.push(primary);
583
+ }
584
+ primary.className = 'c4b-primary';
585
+ buttons.forEach(function (b) {
586
+ actions.appendChild(b);
587
+ actions.appendChild(document.createTextNode(' '));
588
+ });
336
589
  row.appendChild(hint);
337
590
  row.appendChild(actions);
338
591
  pop.appendChild(quote);
339
592
  pop.appendChild(ta);
340
593
  pop.appendChild(row);
341
594
  document.body.appendChild(pop);
595
+ renderAgent();
342
596
  place(pop, rect);
343
597
  ta.focus();
344
- var submit = function () {
345
- var comment = ta.value.trim();
346
- if (!comment) { ta.focus(); return; }
347
- ok.disabled = true;
348
- cancel.disabled = true;
349
- send({ page: pageFile(), quote: data.quote, section: data.section, comment: comment }).then(function (r) {
350
- var msg = 'Added to ' + r.file + (r.number ? ' as #' + r.number : '');
351
- try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
352
- close();
353
- toast(msg);
354
- }, function (err) {
355
- ok.disabled = false;
356
- cancel.disabled = false;
357
- toast('Comment failed: ' + (err && err.message ? err.message : err), true);
358
- });
359
- };
360
- ok.addEventListener('click', submit);
361
- cancel.addEventListener('click', close);
362
598
  ta.addEventListener('keydown', function (e) {
363
599
  if (e.key === 'Escape') { e.preventDefault(); close(); }
364
- else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submit(); }
600
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); primary.click(); }
365
601
  });
366
602
  };
367
603
 
368
- btn.addEventListener('mousedown', function (e) { e.preventDefault(); });
369
- btn.addEventListener('click', function () {
370
- var s = selection();
371
- if (s) open({ quote: s.text, section: headingBefore(s.range) }, anchorRect(s.range));
372
- });
604
+ var openFor = function (feedback) {
605
+ return function () {
606
+ var s = selection();
607
+ if (s) open({ quote: s.text, section: headingBefore(s.range) }, anchorRect(s.range), feedback);
608
+ };
609
+ };
610
+ btns.addEventListener('mousedown', function (e) { e.preventDefault(); });
611
+ fbBtn.addEventListener('click', openFor(true));
612
+ qBtn.addEventListener('click', openFor(false));
373
613
  document.addEventListener('mousedown', function (e) {
374
- if (pop && !pop.contains(e.target) && e.target !== btn) close();
614
+ if (pop && !pop.contains(e.target) && !btns.contains(e.target)) close();
375
615
  });
376
616
  window.addEventListener('hashchange', close);
617
+ renderAgent();
377
618
  })();
378
619
  </script>`;
@@ -0,0 +1,88 @@
1
+ // Словарь интерфейса страницы `site -w` (change site-ui-language). Единственный файл `src/`, где
2
+ // тест cli-language разрешает кириллицу: это текст страницы в браузере, а не вывод CLI. Наборы
3
+ // ключей en и ru совпадают (проверяет тот же тест). `{name}` подставляет скрипт страницы; строка
4
+ // с числом — объект форм для Intl.PluralRules, число в ней — `{n}`.
5
+ export const UI_STRINGS = {
6
+ en: {
7
+ feedback: 'Feedback',
8
+ feedbackTitle: 'Tell the agent what to change',
9
+ question: 'Question',
10
+ questionTitle: 'Add a question to the {heading} section of the source file',
11
+ removeQuestion: 'Remove this question',
12
+ removed: 'Removed from {file}',
13
+ added: 'Added to {file}',
14
+ addedNumber: 'Added to {file} as #{number}',
15
+ sent: 'Sent to the agent',
16
+ delivered: 'Delivered to the agent',
17
+ queued: 'Queued — agent is not listening',
18
+ reviewFailed: 'Review failed: {error}',
19
+ feedbackFailed: 'Feedback failed: {error}',
20
+ questionFailed: 'Question failed: {error}',
21
+ removeFailed: 'Remove failed: {error}',
22
+ generalPlaceholder: 'Overall comment (optional, Ctrl+Enter to submit, Esc to hide)',
23
+ discard: 'Discard',
24
+ discardConfirm: { one: 'Discard {n} pending comment?', other: 'Discard {n} pending comments?' },
25
+ approve: 'Approve',
26
+ approveTitle: 'Tell the agent there is nothing more to change (pending comments go along)',
27
+ done: 'Done — no changes',
28
+ doneTitle: 'Tell the agent there is nothing to change',
29
+ submitReview: 'Submit review',
30
+ reviewEmpty: 'Review: nothing pending',
31
+ reviewCount: 'Review: {comments} ({pages})',
32
+ comments: { one: '{n} pending', other: '{n} pending' },
33
+ pages: { one: '{n} page', other: '{n} pages' },
34
+ removeFromReview: 'Remove from the review',
35
+ listening: 'agent: listening ●',
36
+ notListening: 'agent: not listening ○',
37
+ feedbackPlaceholder: 'What should the agent change? (Ctrl+Enter to send, Esc to cancel)',
38
+ questionPlaceholder: 'Your question (Ctrl+Enter to send, Esc to cancel)',
39
+ questionTo: 'To: {target}',
40
+ cancel: 'Cancel',
41
+ addToReview: 'Add to review',
42
+ sendNow: 'Send now',
43
+ addQuestion: 'Add question'
44
+ },
45
+ ru: {
46
+ feedback: 'Замечание',
47
+ feedbackTitle: 'Сказать агенту, что изменить',
48
+ question: 'Вопрос',
49
+ questionTitle: 'Добавить вопрос в раздел «{heading}» исходного файла',
50
+ removeQuestion: 'Удалить вопрос',
51
+ removed: 'Удалено из {file}',
52
+ added: 'Добавлено в {file}',
53
+ addedNumber: 'Добавлено в {file} под номером {number}',
54
+ sent: 'Отправлено агенту',
55
+ delivered: 'Доставлено агенту',
56
+ queued: 'В очереди: агент не слушает',
57
+ reviewFailed: 'Не удалось отправить ревью: {error}',
58
+ feedbackFailed: 'Не удалось отправить замечание: {error}',
59
+ questionFailed: 'Не удалось добавить вопрос: {error}',
60
+ removeFailed: 'Не удалось удалить вопрос: {error}',
61
+ generalPlaceholder: 'Общий комментарий (необязательно; Ctrl+Enter — отправить, Esc — скрыть)',
62
+ discard: 'Очистить',
63
+ discardConfirm: {
64
+ one: 'Удалить из ревью {n} замечание?',
65
+ few: 'Удалить из ревью {n} замечания?',
66
+ many: 'Удалить из ревью {n} замечаний?'
67
+ },
68
+ approve: 'Одобрить',
69
+ approveTitle: 'Сообщить агенту, что правок больше нет (замечания из ревью уйдут вместе с этим)',
70
+ done: 'Готово, правок нет',
71
+ doneTitle: 'Сообщить агенту, что правок нет',
72
+ submitReview: 'Отправить ревью',
73
+ reviewEmpty: 'Ревью: замечаний нет',
74
+ reviewCount: 'Ревью: {comments} {pages}',
75
+ comments: { one: '{n} замечание', few: '{n} замечания', many: '{n} замечаний' },
76
+ pages: { one: 'на {n} странице', few: 'на {n} страницах', many: 'на {n} страницах' },
77
+ removeFromReview: 'Убрать из ревью',
78
+ listening: 'агент: слушает ●',
79
+ notListening: 'агент: не слушает ○',
80
+ feedbackPlaceholder: 'Что должен изменить агент? (Ctrl+Enter — отправить, Esc — отмена)',
81
+ questionPlaceholder: 'Ваш вопрос (Ctrl+Enter — отправить, Esc — отмена)',
82
+ questionTo: 'Куда: {target}',
83
+ cancel: 'Отмена',
84
+ addToReview: 'В ревью',
85
+ sendNow: 'Отправить',
86
+ addQuestion: 'Добавить вопрос'
87
+ }
88
+ };