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
})();