StrictDoc Documentation
strictdoc/export/html/_static/source_file_screen.js
Source file coverage
Path:
strictdoc/export/html/_static/source_file_screen.js
Lines:
644
Non-empty lines:
550
Non-empty lines covered with requirements:
550 / 550 (100.0%)
Functions:
0
Functions covered by requirements:
0 / 0 (0.0%)
1
// @relation(SDOC-SRS-36, scope=file)
2
 
3
(function () {
4
const __log = (topic, ...payload) => {
5
  console.log(`%c ${topic} `, 'background:yellow;color:black',
6
    ...payload
7
  );
8
}
9
 
10
class SimpleTabs {
11
  constructor(tabsContainer, tabContentSelector = "sdoc-tab-content") {
12
    this.tabsContainer = tabsContainer;
13
    this.tabContents = document.querySelectorAll(tabContentSelector);
14
    this.tabs = tabsContainer.querySelectorAll("sdoc-tab");
15
    this._init();
16
  }
17
 
18
  _init() {
19
    this.tabs.forEach((tab) => {
20
      tab.addEventListener("click", () => {
21
        this.activateTab(tab.innerText.trim());
22
      });
23
    });
24
 
25
    // Activate the tab marked as active, or the first one
26
    const activeTab = [...this.tabs].find((t) => t.hasAttribute("active")) || this.tabs[0];
27
    this.activateTab(activeTab.innerText.trim());
28
  }
29
 
30
  activateTab(tabName) {
31
    this.tabs.forEach((tab) => {
32
      if (tab.innerText.trim() === tabName) {
33
        tab.setAttribute("active", "");
34
      } else {
35
        tab.removeAttribute("active");
36
      }
37
    });
38
 
39
    this.tabContents.forEach((content) => {
40
      if (content.id === tabName) {
41
        content.setAttribute("active", "");
42
      } else {
43
        content.removeAttribute("active");
44
      }
45
    });
46
  }
47
}
48
 
49
class Switch {
50
  constructor({
51
    callback,
52
    labelText,
53
    checked,
54
    componentClass,
55
    colorOn,
56
    colorOff,
57
    size,
58
    stroke,
59
    units,
60
    alignRight,
61
  }) {
62
    this.colorOn = colorOn || 'rgb(100, 200, 50)';
63
    this.colorOff = colorOff || 'rgb(200, 200, 200)';
64
    this.labelText = labelText || '';
65
    this.checked = checked || false; // todo: replace true/false with strings
66
 
67
    this.componentClass = componentClass || 'std-switch-scc';
68
    this.size = size || 0.75;
69
    this.stroke = stroke || 0.25;
70
    this.units = units || 'rem';
71
    this.alignRight = alignRight || true;
72
 
73
    this.callback = callback;
74
  }
75
 
76
  create() {
77
    const block = document.createElement('div');
78
    block.classList.add(this.componentClass);
79
    const label = document.createElement('label');
80
    label.classList.add(`${this.componentClass}__label`);
81
    const input = document.createElement('input');
82
    input.classList.add(`${this.componentClass}__input`);
83
    input.type = 'checkbox';
84
    input.checked = this.checked;
85
    const slider = document.createElement('span');
86
    slider.classList.add(`${this.componentClass}__slider`);
87
    const text = document.createElement('span');
88
    text.innerHTML = this.labelText;
89
 
90
    input.addEventListener('change', () => this.callback(input.checked));
91
 
92
    label.append(input, slider, text);
93
    block.append(label);
94
    this.insertStyle();
95
 
96
    return block;
97
  }
98
 
99
  insertStyle() {
100
 
101
    const css = `
102
    .${this.componentClass} {
103
      display: inline-block;
104
      line-height: 0;
105
    }
106
    .${this.componentClass}__label {
107
      display: inline-flex;
108
      gap: ${this.size * 0.5}${this.units};
109
      font-size: ${this.size * 1.5}${this.units}; /* 0.75rem; */
110
      line-height: ${this.size}${this.units};
111
      align-items: center;
112
      justify-content: flex-start;
113
      user-select: none;
114
      cursor: pointer;
115
      flex-direction: ${this.alignRight ? "row-reverse" : "row"};
116
      text-align: ${this.alignRight ? "right" : "left"};
117
    }
118
    .${this.componentClass}__input {
119
      opacity: 0;
120
      width: 0;
121
      height: 0;
122
      position: absolute;
123
    }
124
    .${this.componentClass}__slider {
125
      position: relative;
126
      cursor: pointer;
127
      background-color: ${this.colorOff};
128
      -webkit-transition: .4s;
129
      transition: .4s;
130
      display: inline-block;
131
      width: ${this.size * 2 + this.stroke * 2}${this.units};
132
      height: ${this.size + this.stroke * 2}${this.units};
133
      border-radius: ${this.size * 0.5 + this.stroke}${this.units};
134
    }
135
    .${this.componentClass}__slider::before  {
136
      position: absolute;
137
      content: "";
138
      height: ${this.size}${this.units};
139
      width: ${this.size}${this.units};
140
      left: ${this.stroke}${this.units};
141
      bottom: ${this.stroke}${this.units};
142
      background-color: white;
143
      -webkit-transition: .4s;
144
      transition: .4s;
145
      border-radius: 50%;
146
    }
147
    input:checked + .${this.componentClass}__slider {
148
      background-color: ${this.colorOn};
149
    }
150
    input:focus + .${this.componentClass}__slider {
151
      box-shadow: 0 0 1px ${this.colorOn};
152
    }
153
    input:checked + .${this.componentClass}__slider::before {
154
      -webkit-transform: translateX(${this.size}${this.units});
155
      -ms-transform: translateX(${this.size}${this.units});
156
      transform: translateX(${this.size}${this.units});
157
    }
158
    `;
159
 
160
    const head = document.querySelector('head');
161
    const style = document.createElement('style');
162
    style.append(document.createTextNode(css));
163
    style.setAttribute("data-slider-styles", '');
164
    head.append(style);
165
  }
166
 
167
}
168
 
169
class Dom {
170
  constructor({
171
    sourceId,
172
    sourceContainerId,
173
    referContainerId,
174
    hashSplitter,
175
    strictdocPointerSelector,
176
    strictdocRequirementSelector,
177
    strictdocRangeBannerSelector,
178
    strictdocRangeBannerHeaderSelector,
179
    strictdocRangeHandlerSelector,
180
    strictdocRangeCloserSelector,
181
    strictdocLineSelector,
182
    strictdocLineNumberSelector,
183
    strictdocLineContentSelector,
184
    strictdocLineRangeSelector,
185
    strictdocSourceFilterClass,
186
    filteredClass,
187
    coveredClass,
188
    activeClass,
189
    collapsedClass,
190
    expandedClass,
191
    coverageClass,
192
    highlightClass,
193
    focusClass,
194
  }) {
195
 
196
    // CONSTANTS
197
    this.sourceId = sourceId || 'source';
198
    this.sourceContainerId = sourceContainerId || 'sourceContainer';
199
    this.referContainerId = referContainerId || 'referContainer';
200
    this.hashSplitter = hashSplitter || '#';
201
 
202
    // STRICTDOC SPECIFIC
203
    this.strictdocPointerSelector = strictdocPointerSelector || '.source__range-pointer';
204
    this.strictdocRequirementSelector = strictdocRequirementSelector || '.source-file__requirement';
205
    this.strictdocRangeBannerSelector = strictdocRangeBannerSelector || '.source__range';
206
    this.strictdocRangeBannerHeaderSelector = strictdocRangeBannerHeaderSelector || '.source__range-header';
207
    this.strictdocRangeHandlerSelector = strictdocRangeHandlerSelector || '.source__range-handler';
208
    this.strictdocRangeCloserSelector = strictdocRangeCloserSelector || '.source__range-closer';
209
    this.strictdocLineSelector = strictdocLineSelector || '.source__line';
210
    this.strictdocLineNumberSelector = strictdocLineNumberSelector || '.source__line-number';
211
    this.strictdocLineContentSelector = strictdocLineContentSelector || '.source__line-content';
212
    this.strictdocLineRangeSelector = strictdocLineRangeSelector || '.source__line-ranges';
213
    this.strictdocSourceFilterClass = strictdocSourceFilterClass || 'source__filter';
214
    this.filteredClass = filteredClass || 'filtered';
215
    this.activeClass = activeClass || 'active';
216
    this.coveredClass = coveredClass || 'covered';
217
    this.coverageClass = coverageClass || 'coverage';
218
    this.collapsedClass = collapsedClass || 'collapsed';
219
    this.expandedClass = expandedClass || 'expanded';
220
    this.highlightClass = highlightClass || 'highlighted';
221
    this.focusClass = focusClass || 'focus';
222
 
223
    // elements
224
    this.sourceContainer;
225
    this.referContainer;
226
    this.source;
227
    this.lines = {};
228
    this.requirements = {};
229
    this.ranges = {};
230
    this.closers = {};
231
 
232
    // state
233
    this.active = {
234
      range: null,
235
      pointers: [],
236
      labels: [],
237
      focus: false,
238
    };
239
  }
240
 
241
  prepare() {
242
    this._prepareSourceContainer();
243
    this._prepareReferContainer();
244
    this._prepareSource();
245
 
246
    this._prepareLines();
247
    this._prepareRanges();
248
    this._updateLinesWithRanges();
249
    this._updateRangesWithRequirements();
250
    this._updateRangesWithHandlers();
251
    this._updateRangesWithBanners();
252
    this._updateRangesWithClosers();
253
 
254
    // console.log('this.lines', this.lines);
255
    // console.log('this.ranges', this.ranges);
256
    // console.log('this.closers', this.closers);
257
    // console.log('this.requirements', this.requirements);
258
    // console.log('this.active', this.active);
259
  }
260
 
261
  useLocationHash() {
262
    const [_, reqId, rangeBegin, rangeEnd] = window.location.hash.split(this.hashSplitter);
263
    const rangeAlias = rangeBegin ? this._generateRangeAlias(rangeBegin, rangeEnd) : undefined;
264
 
265
    this.changeActive({
266
      rangeBegin,
267
      rangeEnd,
268
      rangeAlias,
269
      pointers: rangeAlias ? this.ranges[rangeAlias].pointers : null,
270
      labels: (reqId && rangeAlias) ? this.ranges[rangeAlias][reqId] : null,
271
    });
272
 
273
    this.highlightRange();
274
  }
275
 
276
  changeActive = ({
277
    rangeBegin,
278
    rangeEnd,
279
    rangeAlias,
280
    pointers,
281
    labels,
282
  }) => {
283
 
284
    // remove old 'active'
285
    this.active.pointers?.forEach(pointer => pointer?.classList.remove(this.activeClass));
286
    this.active.labels?.forEach(label => label?.classList.remove(this.activeClass));
287
    if (this.active.rangeAlias) {
288
      this.ranges[this.active.rangeAlias].banner.classList.remove(this.activeClass);
289
 
290
      const closer = this.closers?.[this.active.rangeEnd];
291
      console.assert(
292
        closer,
293
        "Closer must not be null. One known way of getting this error is " +
294
        "when a closing function/range marker is missing and not registered " +
295
        "with the file traceability info."
296
      );
297
 
298
      closer.classList.remove(this.activeClass);
299
    }
300
 
301
    // make changes to state
302
    this.active.pointers = pointers;
303
    this.active.labels = labels;
304
    this.active.rangeBegin = rangeBegin;
305
    this.active.rangeEnd = rangeEnd;
306
    this.active.rangeAlias = rangeAlias;
307
 
308
    // add new 'active'
309
    this.active.pointers?.forEach(pointer => pointer.classList.add(this.activeClass));
310
    this.active.labels?.forEach(label => label.classList.add(this.activeClass));
311
    this.ranges[rangeAlias]?.banner.classList.add(this.activeClass);
312
    this.closers[this.active.rangeEnd]?.classList.add(this.activeClass);
313
  }
314
 
315
  toggleRangeBannerVisibility(handler) {
316
    const banner = handler.closest(this.strictdocRangeBannerSelector);
317
 
318
    if (banner.classList.contains(this.expandedClass)) {
319
      banner.classList.remove(this.expandedClass);
320
      banner.classList.add(this.collapsedClass);
321
    } else {
322
      banner.classList.add(this.expandedClass);
323
      banner.classList.remove(this.collapsedClass);
324
    }
325
  }
326
 
327
  toggleCoverageVisibility(toggler) {
328
    if (toggler) {
329
      this.source.classList.add(this.coverageClass);
330
    } else {
331
      this.source.classList.remove(this.coverageClass);
332
    }
333
  }
334
 
335
  highlightRange() {
336
    this.scrollToActiveRangeIfNeeded();
337
 
338
    const begin = parseInt(this.active.rangeBegin, 10);
339
    const end = parseInt(this.active.rangeEnd, 10);
340
 
341
    for (var key in this.lines) {
342
      this.lines[key].line.classList.remove(this.highlightClass);
343
      if (key >= begin && key <= end) {
344
        this.lines[key].line.classList.add(this.highlightClass);
345
      }
346
    }
347
  }
348
 
349
  scrollToActiveRangeIfNeeded() {
350
    // scroll to highlighted, do not scroll to top when unset highlighting:
351
    if (this.active.rangeAlias) {
352
      const activeRange = this.ranges[this.active.rangeAlias]?.bannerHeader || 0;
353
      requestAnimationFrame(() => {
354
        this.scrollTo(activeRange);
355
      });
356
    }
357
  }
358
 
359
  scrollTo(element) {
360
    const top = element.offsetTop || 0;
361
    this.sourceContainer.scrollTo({
362
      top: top,
363
      behavior: 'smooth',
364
    });
365
  }
366
 
367
  _prepareSource() {
368
    this.source = document.getElementById(this.sourceId);
369
    this.source.style.position = 'relative';
370
    this.source.style.zIndex = '1';
371
  }
372
 
373
  _prepareSourceContainer() {
374
    this.sourceContainer = document.getElementById('sourceContainer');
375
  }
376
 
377
  _prepareReferContainer() {
378
    this.referContainer = document.getElementById('referContainer');
379
  }
380
 
381
  _prepareLines() {
382
    this.lines = [...document.querySelectorAll(this.strictdocLineSelector)]
383
      .reduce((acc, line) => {
384
        const lineNumber = line.querySelector(this.strictdocLineNumberSelector);
385
        const lineContent = line.querySelector(this.strictdocLineContentSelector);
386
        acc[line.dataset.line] = {
387
          line: line,
388
          lineNumber: lineNumber,
389
          lineContent: lineContent,
390
          ranges: []
391
        };
392
        return acc
393
      }, {});
394
  }
395
 
396
  _getRangePart(hash) {
397
    const parts = hash.split(this.hashSplitter);
398
    return `${this.hashSplitter}${parts[parts.length - 2]}${this.hashSplitter}${parts[parts.length - 1]}`;
399
  };
400
 
401
  _prepareRanges() {
402
    [...document.querySelectorAll(this.strictdocPointerSelector)]
403
      .map(pointer => {
404
        const thisFileOrOther = pointer.dataset.traceabilityFileType;
405
        // consider only references to the current file:
406
        if (thisFileOrOther === "other_file") {
407
          return;
408
        }
409
 
410
        const rangeBegin = pointer.dataset.begin;
411
        const rangeEnd = pointer.dataset.end;
412
        const rangeReq = pointer.dataset.reqid;
413
 
414
        const range = this._generateRangeAlias(rangeBegin, rangeEnd);
415
 
416
        if (!this.ranges[range]) {
417
          // add new range
418
          this.ranges[range] = {};
419
          this.ranges[range].pointers = [];
420
 
421
          this.ranges[range].beginLine = this.lines[rangeBegin].lineNumber;
422
          this.ranges[range].endLine = this.lines[rangeEnd].lineNumber;
423
          this.ranges[range].begin = rangeBegin;
424
          this.ranges[range].end = rangeEnd;
425
        }
426
 
427
        // todo pointers?
428
        if (rangeReq) {
429
 
430
          // add pointer from code
431
          (this.ranges[range][rangeReq] ??= []).push(pointer);
432
 
433
        } else {
434
 
435
          // add pointer from menu
436
          this.ranges[range].pointers.push(pointer);
437
        }
438
 
439
        pointer.addEventListener("click", (event) => {
440
          const targetHash = `#${rangeReq || ""}${this.hashSplitter}${rangeBegin}${this.hashSplitter}${rangeEnd}`;
441
          const currentHash = window.location.hash;
442
 
443
          const isSameHash = currentHash === targetHash;
444
          // Buttons linked to requirements include an ID in the hash,
445
          // while buttons in the source code do not.
446
          // Therefore, only the range part of the hash (e.g., #3#10)
447
          // should be compared to identify the currently active range.
448
          const isSameRange = this._getRangePart(currentHash) === `${this.hashSplitter}${rangeBegin}${this.hashSplitter}${rangeEnd}`;
449
 
450
          const isModifierPressed = event.metaKey || event.ctrlKey;
451
 
452
          const targetBannerHeader = this.ranges[range]?.bannerHeader;
453
          const topBefore = targetBannerHeader?.getBoundingClientRect().top;
454
 
455
          // Modifier click
456
          if (isModifierPressed) {
457
            event.preventDefault(); // cancel opening a new browser tab
458
 
459
            if (isSameRange) {
460
              this._toggleFocus();
461
              this._compensateSourceContainerScrollPosition(targetBannerHeader, topBefore);
462
            } else {
463
              // Sets the URL hash to activate a specific range without reloading the page:
464
              window.location.hash = targetHash;
465
              this.useLocationHash();
466
              this._activateFocus();
467
              this._compensateSourceContainerScrollPosition(targetBannerHeader, topBefore);
468
            }
469
            return;
470
          }
471
 
472
          // Normal click
473
          if (isSameRange) {
474
            // active → reset
475
            event.preventDefault();
476
            // Removes hash from URL without reloading or adding history entry:
477
            history.replaceState(null, '', window.location.pathname);
478
            this.useLocationHash();
479
            this._clearFocus();
480
            this._compensateSourceContainerScrollPosition(targetBannerHeader, topBefore);
481
          } else {
482
            // inactive → just reset the focus,
483
            // the basic functionality via URL will work itself out
484
            this._clearFocus();
485
            this._compensateSourceContainerScrollPosition(targetBannerHeader, topBefore);
486
          }
487
        });
488
 
489
      });
490
  }
491
 
492
  _compensateSourceContainerScrollPosition(element, topBefore) {
493
    // Compensates scroll to keep the given element in the same viewport position.
494
    // Expects the element and its initial .getBoundingClientRect().top value as input.
495
    // Measures element's top offset relative to the viewport before and after DOM changes.
496
    // Uses requestAnimationFrame to wait for DOM/layout updates before measuring again.
497
    // Calculates delta = after - before, i.e. how much the element moved visually.
498
    // Scrolls by that delta to restore the element's original viewport position.
499
    requestAnimationFrame(() => {
500
      const topAfter = element?.getBoundingClientRect().top;
501
      const delta = topAfter - topBefore;
502
      this.sourceContainer.scrollBy({ top: delta });
503
    });
504
  }
505
 
506
  _toggleFocus() {
507
    if (this.active.focus) {
508
      this.sourceContainer.classList.remove(this.focusClass);
509
      this.referContainer.classList.remove(this.focusClass);
510
    } else {
511
      this.sourceContainer.classList.add(this.focusClass);
512
      this.referContainer.classList.add(this.focusClass);
513
    }
514
    this.active.focus = !this.active.focus;
515
  }
516
 
517
  _activateFocus() {
518
    if (!this.active.focus) {
519
      this.active.focus = true;
520
      this.sourceContainer.classList.add(this.focusClass);
521
      this.referContainer.classList.add(this.focusClass);
522
    }
523
  }
524
 
525
  _clearFocus() {
526
    if (this.active.focus) {
527
      this.active.focus = false;
528
      this.sourceContainer.classList.remove(this.focusClass);
529
      this.referContainer.classList.remove(this.focusClass);
530
    }
531
  }
532
 
533
  _updateRangesWithRequirements() {
534
    const requirements = [...document.querySelectorAll(this.strictdocRequirementSelector)];
535
    requirements.forEach(requirement => {
536
 
537
      const rangeBegin = requirement.dataset.begin;
538
      const rangeEnd = requirement.dataset.end;
539
      const rangeReq = requirement.dataset.reqid;
540
 
541
      if (rangeEnd && rangeBegin) {
542
        const range = this._generateRangeAlias(rangeBegin, rangeEnd);
543
        console.assert(this.ranges[range], "The range must be registered:", range);
544
 
545
        (this.ranges[range].requirements ??= {})[rangeReq] = {};
546
        this.ranges[range].requirements[rangeReq].begin = rangeBegin;
547
        this.ranges[range].requirements[rangeReq].end = rangeEnd;
548
        this.ranges[range].requirements[rangeReq].element = requirement;
549
      } else {
550
        this.requirements[rangeReq] = requirement;
551
      }
552
 
553
 
554
    })
555
  }
556
 
557
  _updateRangesWithHandlers() {
558
    const handlers = [...document.querySelectorAll(this.strictdocRangeHandlerSelector)];
559
    handlers.forEach(handler => {
560
 
561
      const rangeBegin = handler.dataset.begin;
562
      const rangeEnd = handler.dataset.end;
563
      const range = this._generateRangeAlias(rangeBegin, rangeEnd);
564
 
565
      console.assert(this.ranges[range], "The range must be registered:", range);
566
 
567
      this.ranges[range].handler = handler;
568
      handler.addEventListener("click", event => this.toggleRangeBannerVisibility(event.currentTarget));
569
    })
570
  }
571
 
572
  _updateRangesWithBanners() {
573
    const banners = [...document.querySelectorAll(this.strictdocRangeBannerSelector)];
574
    banners.forEach(banner => {
575
 
576
      const rangeBegin = banner.dataset.begin;
577
      const rangeEnd = banner.dataset.end;
578
 
579
      if (rangeBegin && rangeEnd) {
580
        const range = this._generateRangeAlias(rangeBegin, rangeEnd);
581
        console.assert(this.ranges[range], "The range must be registered:", range);
582
        this.ranges[range].banner = banner;
583
        this.ranges[range].bannerHeader = banner.querySelector(this.strictdocRangeBannerHeaderSelector); // 'source__range-header'
584
      }
585
    })
586
  }
587
 
588
  _updateRangesWithClosers() {
589
    const closers = [...document.querySelectorAll(this.strictdocRangeCloserSelector)];
590
    closers.forEach(closer => {
591
 
592
      const rangeEnd = closer.dataset.end;
593
 
594
      if (rangeEnd) {
595
        this.closers[rangeEnd] = closer;
596
      }
597
    })
598
  }
599
 
600
  _updateLinesWithRanges() {
601
    Object.entries(this.ranges).forEach(([key, value]) => {
602
      const begin = parseInt(value.begin, 10);
603
      const end = parseInt(value.end, 10);
604
 
605
      for (let i = begin; i <= end; i++) {
606
        (this.lines[i].ranges ??= []).push(key);
607
        (this.lines[i].pointers ??= []).push(...value.pointers);
608
 
609
        console.assert(this.lines[i].lineNumber, `The line ${i} must be registered.`);
610
        this.lines[i].lineNumber.classList.add(this.strictdocSourceFilterClass);
611
        this.lines[i].line.classList.add(this.coveredClass);
612
      }
613
    });
614
  }
615
 
616
  _generateRangeAlias(begin, end) { return `${begin}${this.hashSplitter}${end}` };
617
}
618
 
619
const dom = new Dom({});
620
 
621
window.addEventListener("load", function () {
622
  dom.prepare();
623
  dom.useLocationHash();
624
 
625
  const switcher = new Switch(
626
    {
627
      labelText: 'Show coverage',
628
      size: 0.5,
629
      stroke: 0.2,
630
      checked: false,
631
      callback: (checked) => dom.toggleCoverageVisibility(checked),
632
    }
633
  );
634
  document.getElementById('sourceCodeCoverageSwitch').append(switcher.create());
635
 
636
  const tabsContainer = document.querySelector("sdoc-tabs");
637
  if (tabsContainer) {
638
    new SimpleTabs(tabsContainer);
639
  }
640
 
641
});
642
 
643
window.addEventListener("hashchange", () => dom.useLocationHash());
644
})();