source: main/trunk/greenstone3/web/interfaces/default/js/utility_scripts.js@ 37860

Last change on this file since 37860 was 37860, checked in by anupama, 10 months ago

I'd run a JS change Dr Bainbridge made by Finn, where the spread operator was used in a way not liked by older versions of firefox (running on the local 32 bit linux test VM). I think Finn ok-ed the change, but I seem to have forgotten to commit it after the change was made locally for testing. Committing now.

File size: 135.2 KB
Line 
1/** JavaScript file of utility functions.
2 * At present contains functions for sanitising of URLs,
3 * since tomcat 8+, being more compliant with URL/URI standards, is more strict about URLs.
4 */
5
6/*
7 Given a string consisting of a single character, returns the %hex (%XX)
8 https://www.w3resource.com/javascript-exercises/javascript-string-exercise-27.php
9 https://stackoverflow.com/questions/40100096/what-is-equivalent-php-chr-and-ord-functions-in-javascript
10 https://www.w3resource.com/javascript-exercises/javascript-string-exercise-27.php
11*/
12function urlEncodeChar(single_char_string) {
13 /*let hex = Number(single_char_string.charCodeAt(0)).toString(16);
14 var str = "" + hex;
15 str = "%" + str.toUpperCase();
16 return str;
17 */
18
19 var hex = "%" + Number(single_char_string.charCodeAt(0)).toString(16).toUpperCase();
20 return hex;
21}
22
23/*
24 Tomcat 8 appears to be stricter in requiring unsafe and reserved chars
25 in URLs to be escaped with URL encoding
26 See section "Character Encoding Chart of
27 https://perishablepress.com/stop-using-unsafe-characters-in-urls/
28 Reserved chars:
29 ; / ? : @ = &
30 -----> %3B %2F %3F %3A %40 %3D %26
31 [Now also reserved, but no special meaning yet in URLs (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent)
32 and not required to be enforced yet, so we're aren't at present dealing with these:
33 ! ' ( ) *
34 ]
35 Unsafe chars:
36 " < > # % { } | \ ^ ~ [ ] ` and SPACE/BLANK
37 ----> %22 %3C %3E %23 %25 %7B %7D %7C %5C %5E ~ %5B %5D %60 and %20
38 But the above conflicts with the reserved vs unreserved listings at
39 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI
40 Possibly more info: https://stackoverflow.com/questions/1547899/which-characters-make-a-url-invalid
41
42 And the bottom of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
43 lists additional characters that have been reserved since and which need encoding when in a URL component.
44
45 Javascript already provides functions encodeURI() and encodeURIComponent(), see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI
46 However, the set of chars they deal with only partially overlap with the set of chars that need encoding as per the RFC3986 for URIs and RFC1738 for URLs discussed at
47 https://perishablepress.com/stop-using-unsafe-characters-in-urls/
48 We want to handle all the characters listed as unsafe and reserved at https://perishablepress.com/stop-using-unsafe-characters-in-urls/
49 so we define and use our own conceptually equivalent methods for both existing JavaScript methods:
50 - makeSafeURL() for Javascript's encodeURI() to make sure all unsafe characters in URLs are escaped by being URL encoded
51 - and makeSafeURLComponent() for JavaScript's encodeURIComponent to additionally make sure all reserved characters in a URL portion are escaped by being URL encoded too
52
53 Function makeSafeURL() is passed a string that represents a URL and therefore only deals with characters that are unsafe in a URL and which therefore require escaping.
54 Function makeSafeURLComponent() deals with portions of a URL that when decoded need not represent a URL at all, for example data like inline templates passed in as a
55 URL query string's parameter values. As such makeSafeURLComponent() should escape both unsafe URL characters and characters that are reserved in URLs since reserved
56 characters in the query string part (as query param values representing data) may take on a different meaning from their reserved meaning in a URL context.
57*/
58
59/* URL encodes both
60 - UNSAFE characters to make URL safe, by calling makeSafeURL()
61 - and RESERVED characters (characters that have reserved meanings within a URL) to make URL valid, since the url component parameter could use reserved characters
62 in a non-URL sense. For example, the inline template (ilt) parameter value of a URL could use '=' and '&' signs where these would have XSLT rather than URL meanings.
63
64 See end of https://www.w3schools.com/jsref/jsref_replace.asp to use a callback passing each captured element of a regex in str.replace()
65*/
66function makeURLComponentSafe(url_part, encode_percentages) {
67 // https://stackoverflow.com/questions/12797118/how-can-i-declare-optional-function-parameters-in-javascript
68 encode_percentages = encode_percentages || 1; // this method forces the URL-encoding of any % in url_part, e.g. do this for inline-templates that haven't ever been encoded
69
70 var url_encoded = makeURLSafe(url_part, encode_percentages);
71 //return url_encoded.replace(/;/g, "%3B").replace(/\//g, "%2F").replace(/\?/g, "%3F").replace(/\:/g, "%3A").replace(/\@/g, "%40").replace(/=/g, "%3D").replace(/\&/g,"%26");
72 url_encoded = url_encoded.replace(/[\;\/\?\:\@\=\&]/g, function(s) {
73 return urlEncodeChar(s);
74 });
75 return url_encoded;
76}
77
78/*
79 URL encode UNSAFE characters to make URL passed in safe.
80 Set encode_percentages to 1 (true) if you don't want % signs encoded: you'd do so if the url is already partly URL encoded.
81*/
82function makeURLSafe(url, encode_percentages) {
83 encode_percentages = encode_percentages || 0; // https://stackoverflow.com/questions/12797118/how-can-i-declare-optional-function-parameters-in-javascript
84
85 var url_encoded = url;
86 if(encode_percentages) { url_encoded = url_encoded.replace(/\%/g,"%25"); } // encode % first
87 //url_encoded = url_encoded.replace(/ /g, "%20").replace(/\"/g,"%22").replace(/\</g,"%3C").replace(/\>/g,"%3E").replace(/\#/g,"%23").replace(/\{/g,"%7B").replace(/\}/g,"%7D");
88 //url_encoded = url_encoded.replace(/\|/g,"%7C").replace(/\\/g,"%5C").replace(/\^/g,"%5E").replace(/\[/g,"%5B").replace(/\]/g,"%5D").replace(/\`/g,"%60");
89 // Should we handle ~, but then what is its URL encoded value? Because https://meyerweb.com/eric/tools/dencoder/ URLencodes ~ to ~.
90 //return url_encoded;
91 url_encoded = url_encoded.replace(/[\ \"\<\>\#\{\}\|\\^\~\[\]\`]/g, function(s) {
92 return urlEncodeChar(s);
93 });
94 return url_encoded;
95}
96
97/***************
98* MENU SCRIPTS *
99***************/
100function moveScroller() {
101 var move = function() {
102 var editbar = $("#editBar");
103 var st = $(window).scrollTop();
104 var fa = $("#float-anchor").offset().top;
105 if(st > fa) {
106
107 editbar.css({
108 position: "fixed",
109 top: "0px",
110 width: editbar.data("width"),
111 //width: "30%"
112 });
113 } else {
114 editbar.data("width", editbar.css("width"));
115 editbar.css({
116 position: "relative",
117 top: "",
118 width: ""
119 });
120 }
121 };
122 $(window).scroll(move);
123 move();
124}
125
126
127function floatMenu(enabled)
128{
129 var menu = $(".tableOfContentsContainer");
130 if(enabled)
131 {
132 menu.data("position", menu.css("position"));
133 menu.data("width", menu.css("width"));
134 menu.data("right", menu.css("right"));
135 menu.data("top", menu.css("top"));
136 menu.data("max-height", menu.css("max-height"));
137 menu.data("overflow", menu.css("overflow"));
138 menu.data("z-index", menu.css("z-index"));
139
140 menu.css("position", "fixed");
141 menu.css("width", "300px");
142 menu.css("right", "0px");
143 menu.css("top", "100px");
144 menu.css("max-height", "600px");
145 menu.css("overflow", "auto");
146 menu.css("z-index", "200");
147
148 $("#unfloatTOCButton").show();
149 }
150 else
151 {
152 menu.css("position", menu.data("position"));
153 menu.css("width", menu.data("width"));
154 menu.css("right", menu.data("right"));
155 menu.css("top", menu.data("top"));
156 menu.css("max-height", menu.data("max-height"));
157 menu.css("overflow", menu.data("overflow"));
158 menu.css("z-index", menu.data("z-index"));
159
160 $("#unfloatTOCButton").hide();
161 $("#floatTOCToggle").prop("checked", false);
162 }
163
164 var url = gs.xsltParams.library_name + "?a=d&ftoc=" + (enabled ? "1" : "0") + "&c=" + gs.cgiParams.c;
165
166 $.ajax(url);
167}
168
169// TK Label Scripts
170
171var tkMetadataSetStatus = "needs-to-be-loaded";
172var tkMetadataElements = null;
173
174
175function addTKLabelToImage(labelName, definition, name, comment) {
176 // lists of tkLabels and their corresponding codes, in order
177 let tkLabels = ["Attribution","Clan","Family","MultipleCommunities","CommunityVoice","Creative","Verified","NonVerified","Seasonal","WomenGeneral","MenGeneral",
178 "MenRestricted","WomenRestricted","CulturallySensitive","SecretSacred","OpenToCommercialization","NonCommercial","CommunityUseOnly","Outreach","OpenToCollaboration"];
179 let tkCodes = ["tk_a","tk_cl","tk_f","tk_mc","tk_cv","tk_cr","tk_v","tk_nv","tk_s","tk_wg","tk_mg","tk_mr","tk_wr","tk_cs","tk_ss","tk_oc","tk_nc","tk_co","tk_o","tk_cb"];
180 for (let i = 0; i < tkLabels.length; i++) {
181 if (labelName == tkLabels[i]) {
182 let labeldiv = document.querySelectorAll(".tklabels img");
183 for (image of labeldiv) {
184 let labelCode = image.src.substr(image.src.lastIndexOf("/") + 1).replace(".png", ""); // get tk label code from image file name
185 if (labelCode == tkCodes[i]) {
186 image.title = "TK " + name + ": " + definition + " Click for more details."; // set tooltip
187 if (image.parentElement.parentElement.parentElement.classList[0] != "tocSectionTitle") { // disable onclick event in favourites section
188 image.addEventListener("click", function(e) {
189 let currPopup = document.getElementsByClassName("tkPopup")[0];
190 if (currPopup == undefined || (currPopup != undefined && currPopup.id != labelCode)) {
191 let popup = document.createElement("div");
192 popup.classList.add("tkPopup");
193 popup.id = labelCode;
194 let popupText = document.createElement("span");
195 let heading = "<h1>Traditional Knowledge Label:<br><h2>" + name + "</h2></h1>";
196 let moreInformation = "<br> For more information about TK Labels, ";
197 let link = document.createElement("a");
198 link.innerHTML = "click here.";
199 link.href = "https://localcontexts.org/labels/traditional-knowledge-labels/";
200 link.target = "_blank";
201 popupText.innerHTML = heading + comment + moreInformation;
202 popupText.appendChild(link);
203 let closeButton = document.createElement("span");
204 closeButton.innerHTML = "&#215;";
205 closeButton.id = "tkCloseButton";
206 closeButton.title = "Click to close window."
207 closeButton.addEventListener("click", function(e) {
208 closeButton.parentElement.remove();
209 });
210 popup.appendChild(closeButton);
211 popup.appendChild(popupText);
212 e.target.parentElement.appendChild(popup);
213 }
214 if (currPopup) currPopup.remove(); // remove existing popup div
215 });
216 }
217 }
218 }
219 }
220 }
221}
222
223function addTKLabelsToImages(lang) {
224 if (tkMetadataElements == null) {
225 console.error("ajax call not yet loaded tk label metadata set");
226 } else {
227 for (label of tkMetadataElements) { // for each tklabel element in tk.mds
228 let tkLabelName = label.attributes.name.value; // Element name=""
229 let attributes = label.querySelectorAll("[code=" + lang + "] Attribute"); // gets attributes for selected language
230 let tkName = attributes[0].textContent; // name="label"
231 let tkDefinition = attributes[1].textContent; // name="definition"
232 let tkComment = attributes[2].textContent; // name="comment"
233 addTKLabelToImage(tkLabelName, tkDefinition, tkName, tkComment);
234 }
235 }
236}
237
238function loadTKMetadataSetOld(lang) {
239 tkMetadataSetStatus = "loading";
240 $.ajax({
241 url: gs.variables["tkMetadataURL"],
242 success: function(xml) {
243 tkMetadataSetStatus = "loaded";
244 let parser = new DOMParser();
245 let tkmds = parser.parseFromString(xml, "text/xml");
246 tkMetadataElements = tkmds.querySelectorAll("Element");
247 if (document.readyState === "complete") {
248 addTKLabelsToImages(lang);
249 } else {
250 window.onload = function() {
251 addTKLabelsToImages(lang);
252 }
253 }
254 },
255 error: function() {
256 tkMetadataSetStatus = "no-metadata-set-for-this-collection";
257 console.log("No TK Label Metadata-set found for this collection");
258 }
259 });
260};
261function loadTKMetadataSet(lang, type) {
262 if (gs.variables["tkMetadataURL_"+type] == undefined) {
263 console.error("tkMetadataURL_"+type+" variable is not defined, can't load TK Metadata Set");
264 tkMetadataSetStatus = "no-metadata-set-for-this-"+type;
265 return;
266 }
267 tkMetadataSetStatus = "loading";
268 $.ajax({
269 url: gs.variables["tkMetadataURL_"+type],
270 async: false,
271 success: function(xml) {
272 tkMetadataSetStatus = "loaded";
273 let parser = new DOMParser();
274 let tkmds = parser.parseFromString(xml, "text/xml");
275 tkMetadataElements = tkmds.querySelectorAll("Element");
276 if (document.readyState === "complete") {
277 addTKLabelsToImages(lang);
278 } else {
279 window.onload = function() {
280 addTKLabelsToImages(lang);
281 }
282 }
283 },
284 error: function() {
285 tkMetadataSetStatus = "no-metadata-set-for-this-"+type;
286 console.log("No TK Label Metadata-set found for this "+type);
287 }
288 });
289};
290
291// Audio Scripts for Enriched Playback
292
293var wavesurfer;
294
295/**
296 * @param audio input audio file
297 * @param sectionData diarization data (.csv)
298 */
299function loadAudio(audio, sectionData) {
300 const inputFile = sectionData;
301 const mod_meta_base_url = gs.xsltParams.library_name + "?a=g&rt=r&ro=0&s=ModifyMetadata&s1.collection=" + gs.cgiParams.c + "&s1.site=" + gs.xsltParams.site_name + "&s1.d=" + gs.cgiParams.d;
302 const interface_bootstrap_images = "interfaces/" + gs.xsltParams.interface_name + "/images/bootstrap/"; // path to toolbar images
303 const GSSTATUS_SUCCESS = 11; // more information on codes found in: GSStatus.java
304
305 let editMode = false;
306 let currentRegion = {speaker: '', start: '', end: ''};
307 let currentRegions = [];
308
309 let itemType;
310
311 let dualMode = false;
312 let secondaryLoaded = false;
313 let selectedVersions = ['current'];
314
315 let waveformCursorX = 0;
316 let snappedToX = 0;
317 let snappedTo = "none";
318 let cursorPos = 0;
319 let ctrlDown = false;
320 let mouseDown = false;
321 let newRegionOffset = 0;
322
323 let editsMade = false;
324 let undoLevel = 0;
325 let undoStates = [];
326 let prevUndoState = "";
327 let tempZoomSave = 0;
328 let isZooming;
329
330 let canvasImages = {}; // stores canvas images of each version for fast loading from cache
331
332 let accentColour = "#66d640";
333 // let accentColour = "#F8C537";
334 let regionTransparency = "50";
335 let colourbrewerSet = colorbrewer.Set2[8];
336 let regionColourSet = [];
337
338
339 let waveformContainer = document.getElementById("waveform");
340 let waveformSpinner = document.getElementById('waveform-blocker');
341 let loader = document.getElementById('waveform-loader');
342 let initialLoad = true;
343
344 wavesurfer = WaveSurfer.create({ // wavesurfer options
345 autoCenterImmediately: true,
346 container: waveformContainer,
347 backend: "WebAudio",
348 // backgroundColor: "rgb(40, 54, 58)",
349 backgroundColor: "rgb(29, 43, 47)",
350 waveColor: "white",
351 progressColor: accentColour,
352 // progressColor: "grey",
353 // barWidth: 1,
354 // barHeight: 1.2,
355 // barGap: 2,
356 // barRadius: 1,
357 height: 140,
358 cursorColor: 'black',
359 // maxCanvasWidth: 32000,
360 minPxPerSec: 15, // default 20
361 partialRender: true, // use the PeakCache to improve rendering speed of large waveforms
362 pixelRatio: 1, // 1 results in faster rendering
363 scrollParent: true,
364 plugins: [
365 WaveSurfer.regions.create({
366 // formatTimeCallback: function(a, b) {
367 // return "TEST";
368 // }
369 }),
370 WaveSurfer.timeline.create({
371 container: "#wave-timeline",
372 secondaryColor: "white",
373 secondaryFontColor: "white",
374 notchPercentHeight: "0",
375 fontSize: "12",
376 // zoomDebounce: 30,
377 fontFamily: "Courier New"
378 }),
379 WaveSurfer.cursor.create({
380 showTime: true,
381 opacity: 1,
382 customShowTimeStyle: {
383 'background-color': '#000',
384 color: '#fff',
385 padding: '0.25rem',
386 'font-size': '12px'
387 },
388 formatTimeCallback: (num) => { return formatCursor(num); }
389 }),
390 ],
391 });
392
393 // toolbar elements & event handlers
394 const audioContainer = document.getElementById("audioContainer");
395 const dualModeCheckbox = document.getElementById("dual-mode-checkbox");
396 const wave = document.getElementsByTagName("wave")[0];
397 const caretContainer = document.getElementById("caret-container");
398 const primaryCaret = document.getElementById("primary-caret");
399 const secondaryCaret = document.getElementById("secondary-caret");
400 const chapters = document.getElementById("chapters");
401 const chaptersContainer = document.getElementById("chapters-container");
402 const editPanel = document.getElementById("edit-panel");
403 const chapterButton = document.getElementById("chapterButton");
404 const chapterSearchInput = document.getElementById("chapter-search-input");
405 const zoomOutButton = document.getElementById("zoomOutButton");
406 const zoomSlider = document.getElementById("zoom-slider");
407 const zoomInButton = document.getElementById("zoomInButton");
408 const backButton = document.getElementById("backButton");
409 const playPauseButton = document.getElementById("playPauseButton");
410 const forwardButton = document.getElementById("forwardButton");
411 const editButton = document.getElementById("editorModeButton");
412 const downloadButton = document.getElementById("downloadButton");
413 const muteButton = document.getElementById("muteButton");
414 const volumeSlider = document.getElementById("volume-slider");
415 const fullscreenButton = document.getElementById("fullscreenButton");
416 const changeAllCheckbox = document.getElementById("change-all-checkbox");
417 const changeAllLabel = document.getElementById("change-all-label");
418 const speakerInput = document.getElementById("speaker-input");
419 const startTimeInput = document.getElementById("start-time-input");
420 const endTimeInput = document.getElementById("end-time-input");
421 const removeButton = document.getElementById("remove-button");
422 const createButton = document.getElementById("create-button");
423 const discardButton = document.getElementById("discard-button");
424 const undoButton = document.getElementById("undo-button");
425 const redoButton = document.getElementById("redo-button");
426 const saveButton = document.getElementById("save-button");
427 const hoverSpeaker = document.getElementById("hover-speaker");
428 const contextMenu = document.getElementById("context-menu");
429 const contextReplace = document.getElementById("context-menu-replace");
430 const contextOverdub = document.getElementById("context-menu-overdub");
431 const contextLock = document.getElementById("context-menu-lock");
432 const contextDelete = document.getElementById("context-menu-delete");
433 const timelineMenu = document.getElementById("timeline-menu");
434 const timelineMenuButton = document.getElementById("timeline-menu-button");
435 const timelineMenuHide = document.getElementById("timeline-menu-hide");
436 const timelineMenuDualMode = document.getElementById("timeline-menu-dualmode");
437 const timelineMenuRegionConflict = document.getElementById("timeline-menu-region");
438 const timelineMenuSpeakerConflict = document.getElementById("timeline-menu-speaker");
439 const versionSelectMenu = document.getElementById('version-select-menu');
440 const versionSelectLabels = document.querySelectorAll(".track-arrow");
441 const savePopup = document.getElementById("save-popup");
442 const savePopupBG = document.getElementById("save-popup-bg");
443 const savePopupCancel = document.getElementById("save-popup-cancel");
444 const savePopupCommit = document.getElementById("save-popup-commit");
445 const savePopupCommitMsg = document.getElementById("commit-message");
446
447 audioContainer.addEventListener('fullscreenchange', (e) => { fullscreenChanged() });
448 audioContainer.addEventListener('contextmenu', onRightClick);
449 audioContainer.addEventListener("keyup", keyUp);
450 audioContainer.addEventListener("keydown", keyDown);
451 dualModeCheckbox.addEventListener("change", () => { dualModeChanged() });
452 wave.addEventListener('scroll', (e) => { waveformScrolled() })
453 wave.addEventListener('mousemove', (e) => waveformCursorX = e.x);
454 primaryCaret.addEventListener("click", (e) => caretClicked(e.target.id));
455 secondaryCaret.addEventListener("click", (e) => caretClicked(e.target.id));
456 chapters.style.height = "0px";
457 chaptersContainer.style.height = "0px";
458 editPanel.style.height = "0px";
459 chapterButton.addEventListener("click", () => { toggleChapters() });
460 chapterSearchInput.addEventListener("input", chapterSearchInputChange)
461 zoomOutButton.addEventListener("click", () => { zoomSlider.stepDown(); zoomSlider.dispatchEvent(new Event("input")) });
462 zoomInButton.addEventListener("click", () => { zoomSlider.stepUp(); zoomSlider.dispatchEvent(new Event("input")) });
463 backButton.addEventListener("click", () => { wavesurfer.skipBackward(); });
464 playPauseButton.addEventListener("click", () => { wavesurfer.playPause() });
465 forwardButton.addEventListener("click", () => { wavesurfer.skipForward(); });
466 editButton.addEventListener("click", toggleEditMode);
467 downloadButton.addEventListener("click", () => { downloadURI(audio, audio.split(".dir/")[1]) });
468 muteButton.addEventListener("click", () => {
469 if (volumeSlider.value == 0) wavesurfer.setMute(false)
470 else wavesurfer.setMute(true)
471 });
472 volumeSlider.style["accent-color"] = accentColour;
473 fullscreenButton.addEventListener("click", toggleFullscreen);
474 zoomSlider.style["accent-color"] = accentColour;
475 changeAllCheckbox.addEventListener("change", () => { selectAllCheckboxChanged() });
476 speakerInput.addEventListener("input", speakerChange);
477 speakerInput.addEventListener("blur", speakerInputUnfocused);
478 createButton.addEventListener("click", createNewRegion);
479 removeButton.addEventListener("click", removeRegion);
480 discardButton.addEventListener("click", () => discardRegionChanges(false));
481 undoButton.addEventListener("click", undo);
482 redoButton.addEventListener("click", redo);
483 saveButton.addEventListener("click", saveRegionChanges);
484 document.addEventListener('click', documentClicked);
485 document.addEventListener('mouseup', () => mouseDown = false);
486 document.addEventListener('mousedown', (e) => { if (e.target.id !== "create-button") newRegionOffset = 0 }); // resets new region offset on click
487 document.querySelectorAll('input[type=number]').forEach(e => {
488 e.onchange = (e) => { changeStartEndTime(e) }; // updates speaker objects when number input(s) are changed
489 e.onblur = () => { prevUndoState = "" };
490 });
491 contextReplace.addEventListener("click", replaceSelected);
492 contextOverdub.addEventListener("click", overdubSelected);
493 contextLock.addEventListener("click", toggleLockSelected);
494 contextDelete.addEventListener("click", removeRightClicked);
495 timelineMenu.addEventListener("click", e => e.stopPropagation());
496 timelineMenuButton.addEventListener("click", timelineMenuToggle);
497 timelineMenuHide.addEventListener("click", timelineMenuHideClicked);
498 timelineMenuDualMode.addEventListener("click", () => { dualModeChanged() });
499 timelineMenuRegionConflict.addEventListener("click", showStartStopConflicts);
500 timelineMenuSpeakerConflict.addEventListener("click", showSpeakerNameConflicts);
501
502 savePopupCancel.addEventListener("click", toggleSavePopup)
503 savePopupCommit.addEventListener("click", commitChanges);
504 savePopupBG.addEventListener("click", toggleSavePopup);
505 versionSelectLabels.forEach(arrow => arrow.addEventListener('click', toggleVersionDropdown));
506
507 volumeSlider.addEventListener("input", function() {
508 wavesurfer.setVolume(this.value);
509 if (this.value == 0) {
510 muteButton.src = interface_bootstrap_images + "mute.svg";
511 muteButton.style.opacity = 0.6;
512 } else {
513 muteButton.src = interface_bootstrap_images + "unmute.svg";
514 muteButton.style.opacity = 1;
515 }
516 });
517
518 zoomSlider.addEventListener('input', function() { // slider changes waveform zoom
519 wavesurfer.zoom(Number(this.value) / 4);
520 if (currentRegion.speaker && getCurrentRegionIndex() != -1) {
521 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
522 drawCurrentRegionBounds();
523 }
524 let handles = document.getElementsByClassName("wavesurfer-handle");
525 if (this.value < 20) {
526 for (const handle of handles) handle.style.setProperty("width", "1px", "important");
527 } else {
528 for (const handle of handles) handle.style.setProperty("width", "3px", "important");
529 }
530 });
531 showAudioLoader();
532
533 if (gs.variables.allowEditing === '0') { editButton.style.display = "none" }
534
535 wavesurfer.load(audio);
536
537 // wavesurfer events
538
539 wavesurfer.on('region-click', handleRegionClick);
540 wavesurfer.on('region-mouseenter', function(region) { // region hover effects
541 if (!mouseDown) {
542 handleRegionColours(region, true);
543 setHoverSpeaker(region.element.style.left, region.attributes.label.innerText);
544 if (!isInCurrentRegions(region)) {
545 removeRegionBounds();
546 drawRegionBounds(region, wave.scrollLeft, "black");
547 }
548 if (isCurrentRegion(region) && editMode) drawRegionBounds(region, wave.scrollLeft, "FireBrick");
549 }
550 });
551 wavesurfer.on('region-mouseleave', function(region) {
552 hoverSpeaker.innerHTML = "";
553 if (!mouseDown) {
554 if (!(wavesurfer.getCurrentTime() <= region.end && wavesurfer.getCurrentTime() >= region.start)) handleRegionColours(region, false);
555 if (!editMode) hoverSpeaker.innerHTML = "";
556 removeRegionBounds();
557 if (currentRegion && currentRegion.speaker && getCurrentRegionIndex() != -1) {
558 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
559 drawCurrentRegionBounds();
560 }
561 }
562 });
563 wavesurfer.on('region-in', function(region) { // play caret enters region
564 if (!mouseDown) {
565 handleRegionColours(region, true);
566 if (itemType == "chapter" && Array.from(chapters.children)[getIndexOfRegion(region)]) {
567 Array.from(chapters.children)[getIndexOfRegion(region)].scrollIntoView({
568 behavior: "smooth",
569 block: "nearest"
570 });
571 }
572 }
573 });
574 wavesurfer.on('region-out', function(region) { handleRegionColours(region, false) });
575 wavesurfer.on('region-update-end', handleRegionEdit); // end of click-drag event
576 wavesurfer.on('region-updated', handleRegionSnap);
577 wavesurfer.on('error', error => console.log(error));
578
579 wavesurfer.on("play", () => { playPauseButton.src = interface_bootstrap_images + "pause.svg"; });
580 wavesurfer.on("pause", () => { playPauseButton.src = interface_bootstrap_images + "play.svg"; });
581 wavesurfer.on("mute", function(mute) {
582 if (mute) {
583 muteButton.src = interface_bootstrap_images + "mute.svg";
584 muteButton.style.opacity = 0.6;
585 volumeSlider.value = 0;
586 }
587 else {
588 muteButton.src = interface_bootstrap_images + "unmute.svg";
589 muteButton.style.opacity = 1;
590 volumeSlider.value = 1;
591 }
592 });
593
594 wavesurfer.on('ready', function() { // retrieve regions once waveforms have loaded
595 window.onbeforeunload = (e) => {
596 if (undoStates.length > 1) {
597 console.log('undoStates.length: ' + undoStates.length);
598 e.returnValue = "Data will be lost if you leave the page, are you sure?";
599 return "Data will be lost if you leave the page, are you sure?";
600 }
601 };
602 if (document.getElementById('new-canvas')) document.getElementById('new-canvas').remove();
603 setTimeout(() => { // if not delayed exportImage does not retrieve waveform (despite being in waveform-ready?)
604 const currVersion = selectedVersions[(!dualMode || primaryCaret.src.includes("fill")) ? 0 : 1];
605 for (let key in canvasImages) {
606 if (currVersion == key && canvasImages[key] == undefined) { canvasImages[key] = wavesurfer.exportImage() } // add waveform image to cache if one isn't already assigned to the version
607 }
608 }, 1000);
609
610 if (initialLoad) {
611 if (inputFile.endsWith("csv")) { // diarization if csv
612 itemType = "chapter";
613 if (localStorage.getItem('undoStates') && localStorage.getItem('undoLevel')) {
614 console.log('-- Loading regions from localStorage --');
615 undoStates = JSON.parse(localStorage.getItem('undoStates'));
616 undoLevel = JSON.parse(localStorage.getItem('undoLevel'));
617 primarySet.tempSpeakerObjects = undoStates[undoLevel].state;
618 primarySet.speakerObjects = cloneSpeakerObjectArray(primarySet.tempSpeakerObjects);
619 primarySet.uniqueSpeakers = [];
620 for (const item of primarySet.tempSpeakerObjects) {
621 if (!primarySet.uniqueSpeakers.includes(item.speaker)) primarySet.uniqueSpeakers.push(item.speaker);
622 }
623 populateChaptersAndRegions(primarySet);
624 if (undoStates[undoLevel].secState && undoStates[undoLevel].secState.length > 0) {
625 secondarySet.tempSpeakerObjects = undoStates[undoLevel].secState;
626 secondarySet.speakerObjects = cloneSpeakerObjectArray(secondarySet.tempSpeakerObjects);
627 secondarySet.uniqueSpeakers = [];
628 for (const item of secondarySet.tempSpeakerObjects) {
629 if (!secondarySet.uniqueSpeakers.includes(item.speaker)) secondarySet.uniqueSpeakers.push(item.speaker);
630 }
631 secondaryLoaded = true;
632 }
633 updateRegionEditPanel();
634 } else {
635 loadCSVFile(inputFile, primarySet);
636 dualModeChanged(true, "true");
637 setTimeout(()=>{
638 dualModeChanged(true, "false");
639 }, 150)
640 }
641 } else if (inputFile.endsWith("json")) { // transcription if json
642 itemType = "word";
643 loadJSONFile(inputFile);
644 } else {
645 console.log("Filetype of " + inputFile + " not supported.")
646 }
647
648 chapters.style.cursor = "default"; // remove load cursor
649 wave.className = "audio-scroll";
650 $.ajax({
651 type: "GET",
652 url: gs.variables.metadataServerURL,
653 data: { a: 'get-fldv-info', site: gs.xsltParams.site_name, c: gs.cgiParams.c, d: gs.cgiParams.d },
654 dataType: "json",
655 }).then(data => {
656 for (const version of ["current", ...data]) {
657 canvasImages[version] = undefined;
658 let menuItem = document.createElement("div");
659 menuItem.classList.add("version-select-menu-item");
660 menuItem.id = version;
661 let text = version.includes("nminus") ? version.replace("nminus-", "Previous(") + ")" : version;
662 menuItem.innerText = text.charAt(0).toUpperCase() + text.slice(1);
663 menuItem.addEventListener('click', versionClicked);
664 let dataObj = { a: 'get-archives-metadata', site: gs.xsltParams.site_name, c: gs.cgiParams.c, d: gs.cgiParams.d, metaname: "commitmessage" };
665 if (version != "current") Object.assign(dataObj, {dv: version});
666 $.ajax({ // get commitmessage metadata to show as hover tooltip
667 type: "GET",
668 url: gs.variables.metadataServerURL,
669 data: dataObj,
670 dataType: "text",
671 }).then(comment => {
672 menuItem.title = "Commit message: " + comment;
673 versionSelectMenu.append(menuItem);
674 [...versionSelectMenu.children].sort((a,b) => a.innerText>b.innerText?1:-1).forEach(n=>versionSelectMenu.appendChild(n)); // sort alphabetically
675 }, (error) => { console.log("get-archives-metadata error:"); console.log(error); });
676 }
677 }, (error) => { console.log("get-fldv-info error:"); console.log(error); });
678 initialLoad = false;
679 }
680 // fixes blank waveform/regions when loading Current -> Prev.1 -> Prev.2
681 zoomSlider.value = 25;
682 zoomSlider.dispatchEvent(new Event("input"));
683 wavesurfer.zoom(50 / 4);
684 hideAudioLoader();
685 });
686
687 /**
688 * Draws string above waveform at the provided offset
689 * @param {number} offset Offset (from left) to desired location
690 * @param {string} name String to be drawn
691 */
692 function setHoverSpeaker(offset, name) {
693 hoverSpeaker.innerHTML = name;
694 let newOffset = parseInt(offset.slice(0, -2)) - wave.scrollLeft;
695 hoverSpeaker.style.marginLeft = newOffset + "px";
696 }
697
698 /** Click handler, manages selected region/s, set swapping, region playing */
699 function handleRegionClick(region, e) {
700 if (e.target.classList.contains("region-menu")) return;
701 e.stopPropagation();
702 contextMenu.classList.remove('visible');
703 if (!editMode) { // play region audio on click
704 wavesurfer.play(region.start); // plays from start of region
705 } else { // select or deselect current region
706 if (!region.element) return;
707 if (region.element.classList.contains("region-top")) {
708 currSpeakerSet = primarySet;
709 swapCarets(true);
710 } else if (region.element.classList.contains("region-bottom")) {
711 currSpeakerSet = secondarySet;
712 swapCarets(false);
713 }
714 prevUndoState = "";
715
716 if (!e.ctrlKey && !e.shiftKey) {
717 currentRegions = [];
718 currentRegion = region;
719 currentRegion.speaker = currentRegion.attributes.label.innerText;
720 wavesurfer.backend.seekTo(currentRegion.start);
721 } else if (e.ctrlKey) { // control was held during click
722 if (currentRegions.length == 0 && isCurrentRegion(region)) {
723 removeCurrentRegion();
724 } else if (getCurrentRegionIndex() != -1 && isInCurrentRegions(region)) {
725 const removeIndex = getIndexInCurrentRegions(region);
726 if (removeIndex != -1) currentRegions.splice(removeIndex, 1);
727 if (currentRegions.length > 0 && isCurrentRegion(region)) { // change current region if removed
728 currentRegion = currentRegions[0];
729 }
730 } else {
731 if (currentRegions.length < 1) currentRegions.push(currentRegion);
732 if (getIndexInCurrentRegions(region) == -1) currentRegions.push(region); // add if it doesn't already exist
733 currentRegion = region;
734 currentRegion.speaker = currentRegion.attributes.label.innerText;
735 wavesurfer.backend.seekTo(currentRegion.start);
736 }
737 if (currentRegions.length == 1) currentRegions = []; // clear selected regions if there is only one
738 } else if (e.shiftKey) { // shift was held during click
739 clearChapterSearch();
740 if (getCurrentRegionIndex() != -1 && getIndexOfRegion(region) != -1) {
741 if (currentRegions && currentRegions.length > 0) {
742 if (Math.max(...getCurrentRegionsIndexes()) < getIndexOfRegion(region)) { // shifting forwards / down
743 currentRegions = currSpeakerSet.tempSpeakerObjects.slice(Math.min(...getCurrentRegionsIndexes()), getIndexOfRegion(region)+1);
744 } else { // shifting backwards / up
745 currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getIndexOfRegion(region), Math.max(...getCurrentRegionsIndexes())+1);
746 }
747 } else {
748 if (getCurrentRegionIndex() < getIndexOfRegion(region)) { // shifting forwards / down
749 currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getCurrentRegionIndex(), getIndexOfRegion(region)+1);
750 } else { // shifting backwards / up
751 currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getIndexOfRegion(region), getCurrentRegionIndex()+1);
752 }
753 }
754 }
755 }
756 if (changeAllCheckbox.checked) { currentRegions = getRegionsWithSpeaker(currentRegion.speaker) }
757 reloadRegionsAndChapters();
758 }
759 }
760
761 /**
762 * Returns index of given region within the currently selected regions
763 * @param {object} region Region within currently selected regions to return index for
764 * @returns {int} Index position of region
765 */
766 function getIndexInCurrentRegions(region) {
767 for (const reg of currentRegions) {
768 const regSpeaker = reg.attributes ? reg.attributes.label.innerText : reg.speaker;
769 if (reg.start == region.start && reg.end == region.end && regSpeaker == region.attributes.label.innerText) {
770 return currentRegions.indexOf(reg);
771 }
772 }
773 return -1;
774 }
775
776 /**
777 * Returns index of region within speakerObject array
778 * @param {object} region Region to return index for
779 * @returns {int} Index position of region
780 */
781 function getIndexOfRegion(region) {
782 for (const reg of currSpeakerSet.tempSpeakerObjects) {
783 if (region.attributes && reg.start == region.start && reg.end == region.end && reg.speaker == region.attributes.label.innerText) {
784 return currSpeakerSet.tempSpeakerObjects.indexOf(reg);
785 }
786 }
787 return -1;
788 }
789
790 /**
791 * Builds metadata-server.pl URL to retrieve audio at given version
792 * @param {string} version GS document version to retrieve from (nminus-X)
793 */
794 function getAudioURLFromVersion(version) {
795 let base_url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d;
796 if (version !== "current") base_url += "&dv=" + version // get fldv if not current version
797 return base_url + "&assocname=" + gs.documentMetadata.Audio;
798 }
799
800 /**
801 * Builds metadata-server.pl URL to retrieve CSV at given version
802 * @param {string} version GS document version to retrieve from (nminus-X)
803 */
804 function getCSVURLFromVersion(version) {
805 let base_url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d;
806 if (version !== "current") base_url += "&dv=" + version; // get fldv if not current version
807 return base_url + "&assocname=" + "structured-audio.csv";
808 }
809
810 /** Version click handler, first checks if changes have been made and shows popup if true */
811 function versionClicked(e) {
812 let unsavedChanges = false;
813 if (undoStates.length > 0) { // only if changes have been made in track being changed FROM
814 let clickedVersionPos = e.target.parentElement.classList.contains('versionTop') ? 0 : 1;
815 for (const state of undoStates) {
816 if (state.changedTrack == selectedVersions[clickedVersionPos]) {
817 unsavedChanges = true;
818 break;
819 }
820 }
821 }
822 if (unsavedChanges) {
823 const areYouSure = "There are unsaved changes.\nAre you sure you want to lose changes made in this version?";
824 if (window.confirm(areYouSure)) {
825 console.log('OK');
826 discardRegionChanges(true);
827 changeVersion(e);
828 } else {
829 console.log('CANCEL');
830 return;
831 }
832 } else changeVersion(e);
833 }
834
835 /** Changes current audio/csv set to clicked version's equivalent */
836 function changeVersion(e) {
837 removeCurrentRegion();
838 const audio_url = getAudioURLFromVersion(e.target.id);
839 const csv_url = getCSVURLFromVersion(e.target.id);
840 versionSelectMenu.classList.remove('visible');
841 const setToUpdate = e.target.parentElement.classList.contains('versionTop') ? primarySet : secondarySet;
842 if (e.target.parentElement.classList.contains('versionTop')) {
843 if (!currSpeakerSet.isSecondary) {
844 if (dualMode) $(".region-top").remove();
845 else $(".wavesurfer-region").remove();
846 showAudioLoader();
847 // if (canvasImages[e.target.id]) { // if waveform image exists in cache
848 // drawImageOnWaveform(canvasImages[e.target.id]);
849 // }
850 wavesurfer.load(audio_url); // load audio
851 } else {
852 $(".region-top").remove();
853 }
854 document.getElementById('track-set-label-top').children[0].innerText = e.target.id.includes("nminus") ? e.target.id.replace("nminus-", "Previous(") + ")" : "Current"; // update top label text
855 selectedVersions[0] = e.target.id; // update the selected versions
856 } else {
857 if (currSpeakerSet.isSecondary) {
858 if (dualMode) $(".region-bottom").remove();
859 else $(".wavesurfer-region").remove();
860 showAudioLoader();
861 // if (canvasImages[e.target.id]) { // if waveform image exists in cache
862 // drawImageOnWaveform(canvasImages[e.target.id]);
863 // }
864 wavesurfer.load(audio_url);
865 } else {
866 $(".region-bottom").remove();
867 }
868 document.getElementById('track-set-label-bottom').children[0].innerText = e.target.id.includes("nminus") ? e.target.id.replace("nminus-", "Previous(") + ")" : "Current"; // update bottom label text
869 selectedVersions[1] = e.target.id;
870 }
871 loadCSVFile(csv_url, setToUpdate, true);
872 }
873
874 /** Utility function to download audio */
875 function downloadURI(loc, name) {
876 let link = document.createElement("a");
877 link.download = name;
878 link.href = loc;
879 link.click();
880 }
881
882 /** Document click listener for context box closure and region deselection */
883 function documentClicked(e) { // document on click
884 if (e.target.classList.contains("region-menu")) return;
885 contextMenu.classList.remove('visible');
886 timelineMenu.classList.remove('visible');
887 versionSelectMenu.classList.remove('visible');
888 versionSelectLabels.forEach(arrow => {
889 // arrow.style.transform = 'rotate(90deg)';
890 // arrow.style.paddingTop = '0';
891 arrow.style.display = 'inline';
892 });
893 if (editMode && e.target.tagName !== "INPUT" && e.target.tagName !== "IMG" && !e.target.classList.contains("ui-button") && !$("#audio-dropdowns").has($(e.target)).length
894 && !e.target.classList.contains("context-menu-item")) {
895 let currReg = getCurrentRegionIndex() != -1 ? currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region : false; // save for deselection
896 let currRegs = getCurrentRegionsIndexes().length > 1 ? currentRegions : false; // save for deselection
897 removeCurrentRegion();
898 reloadChapterList();
899 if (currReg != false) regionLeave(currReg); // deselect curr region
900 if (currRegs != false) {
901 for (const reg of currRegs) {
902 regionLeave(reg.region); // deselect curr regions
903 regionLeave(reg.region); // deselect curr regions
904 }
905 }
906 removeRegionBounds();
907 removeButton.innerHTML = "Remove Selected Region";
908 updateRegionEditPanel();
909 }
910 }
911
912 /** Draws and returns padlock image at given parent element */
913 function drawPadlock(parent) {
914 let lockedImg = document.createElement("img");
915 lockedImg.classList.add("region-padlock");
916 lockedImg.src = interface_bootstrap_images + "lock.svg";
917 lockedImg.title = "This region is locked. Click to unlock region.";
918 parent.prepend(lockedImg);
919 return lockedImg;
920 }
921
922 /**
923 * Draws triple dot menu button and attaches click listener
924 * @param {object} region Region to attach menu button to
925 */
926 function drawMenuButton(region) {
927 let menuImg = document.createElement("img");
928 menuImg.src = interface_bootstrap_images + "menu.svg";
929 menuImg.classList.add("region-menu");
930 menuImg.title = "Show region options";
931 menuImg.addEventListener("click", e => {
932 audioContainer.dispatchEvent(new MouseEvent("contextmenu", { clientX: menuImg.x + 20, clientY: menuImg.y + 5 }));
933 });
934 region.element.append(menuImg);
935 }
936
937 /**
938 * Attaches a click listener to given padlock element
939 * @param padlock Element to attach listener to
940 * @param region Associated region
941 * @param isChapter Whether padlock exists in chapter (true) or wavesurfer region (false)
942 */
943 function attachPadlockListener(padlock, region, isChapter) {
944 if (isChapter == true) {
945 padlock.addEventListener('click', () => { // attach to chapter padlock
946 let index = getIndexOfRegion(region);
947 currSpeakerSet.tempSpeakerObjects[index].locked = false;
948 padlock.classList.add('hide');
949 if (currSpeakerSet.tempSpeakerObjects[index].region.element.firstChild) currSpeakerSet.tempSpeakerObjects[index].region.element.firstChild.remove();
950 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "lockChange", index);
951 });
952 } else {
953 padlock.addEventListener('click', () => { // attach to region padlock
954 let index = getIndexOfRegion(region);
955 currSpeakerSet.tempSpeakerObjects[index].locked = false;
956 padlock.remove();
957 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "lockChange", index);
958 });
959 }
960 }
961
962 /** Locks or unlocks selected region based on its current state */
963 function toggleLockSelected(e) { // locks / unlocks selected region(s)
964 if (e) e.stopPropagation();
965 if (getCurrentRegionIndex() != -1 && currentRegions.length <= 1) { // single selected
966 let currIndex = getCurrentRegionIndex();
967 currSpeakerSet.tempSpeakerObjects[currIndex].locked = !e.target.innerText.includes("Unlock");
968 if (currSpeakerSet.tempSpeakerObjects[currIndex].locked) {
969 chapters.childNodes[currIndex].childNodes[1].classList.remove('hide');
970 let lock = drawPadlock(currSpeakerSet.tempSpeakerObjects[currIndex].region.element);
971 attachPadlockListener(lock, currSpeakerSet.tempSpeakerObjects[currIndex].region, false);
972 contextLock.innerText = "Unlock Selected";
973 } else {
974 chapters.childNodes[currIndex].childNodes[1].classList.add('hide');
975 if (currSpeakerSet.tempSpeakerObjects[currIndex].region.element.getElementsByClassName("region-padlock").length > 0) {
976 currSpeakerSet.tempSpeakerObjects[currIndex].region.element.getElementsByClassName("region-padlock")[0].remove();
977 }
978 contextLock.innerText = "Lock Selected";
979 }
980 } else if (currentRegions.length > 1) { // multiple selected
981 let toLock = !e.target.innerText.includes("Unlock");
982 for (const idx of getCurrentRegionsIndexes()) {
983 currSpeakerSet.tempSpeakerObjects[idx].locked = toLock;
984 if (currSpeakerSet.tempSpeakerObjects[idx].locked) {
985 chapters.childNodes[idx].childNodes[1].classList.remove('hide');
986 if (currSpeakerSet.tempSpeakerObjects[idx].region.element.getElementsByClassName("region-padlock").length == 0) {
987 let lock = drawPadlock(currSpeakerSet.tempSpeakerObjects[idx].region.element);
988 attachPadlockListener(lock, currSpeakerSet.tempSpeakerObjects[idx].region, false);
989 }
990 contextLock.innerText = "Unlock Selected";
991 } else {
992 chapters.childNodes[idx].childNodes[1].classList.add('hide');
993 if (currSpeakerSet.tempSpeakerObjects[idx].region.element.getElementsByClassName("region-padlock").length > 0) {
994 currSpeakerSet.tempSpeakerObjects[idx].region.element.getElementsByClassName("region-padlock")[0].remove();
995 }
996 contextLock.innerText = "Lock Selected";
997 }
998 }
999 if (document.getElementById("context-menu-lock-2")) document.getElementById("context-menu-lock-2").remove();
1000 }
1001 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "lockChange", getCurrentRegionIndex());
1002 }
1003
1004 /** TODO */
1005 function timelineMenuHideClicked(e) { // hides all regions and chapter/edit divs
1006 if (!e.target.children[0].checked) {
1007 e.target.children[0].checked = true;
1008 timelineMenuDualMode.classList.add('disabled');
1009 timelineMenuRegionConflict.classList.add('disabled');
1010 timelineMenuSpeakerConflict.classList.add('disabled');
1011 if (editPanel.style.height != "0px") toggleEditMode();
1012 if (chapters.style.height != "0px") toggleChapters();
1013 $('.wavesurfer-region').fadeOut(100);
1014 }
1015 else {
1016 e.target.children[0].checked = false;
1017 timelineMenuDualMode.classList.remove('disabled');
1018 timelineMenuRegionConflict.classList.remove('disabled');
1019 timelineMenuSpeakerConflict.classList.remove('disabled');
1020 let fadeIn = true;
1021 if (timelineMenuRegionConflict.firstElementChild.checked) {
1022 showStartStopConflicts(e, true);
1023 fadeIn = false;
1024 }
1025 if (timelineMenuSpeakerConflict.firstElementChild.checked) {
1026 showSpeakerNameConflicts(e, true);
1027 fadeIn = false;
1028 }
1029 if (fadeIn) $('.wavesurfer-region').fadeIn(100);
1030 }
1031 }
1032
1033 function chapterSearchInputChange(e) { // filters chapters and regions by given speaker name
1034 if (e.isTrusted) { // triggered from user action
1035 if (document.getElementById("chapter-alert")) document.getElementById("chapter-alert").remove();
1036 let matches = 0;
1037 for (const idx in chapters.children) {
1038 if (chapters.children[idx].firstChild && chapters.children[idx].classList.contains("chapter") && currSpeakerSet.tempSpeakerObjects[idx]
1039 && currSpeakerSet.tempSpeakerObjects[idx].region && currSpeakerSet.tempSpeakerObjects[idx].region.element) {
1040 if (e.composed) removeCurrentRegion(); // composed true if called from input, false if manually triggered event
1041 if (!chapters.children[idx].firstChild.innerText.toLowerCase().includes(e.target.value.toLowerCase())) {
1042 chapters.children[idx].style.display = "none";
1043 currSpeakerSet.tempSpeakerObjects[idx].region.element.style.display = "none";
1044 } else {
1045 chapters.children[idx].style.display = "flex";
1046 currSpeakerSet.tempSpeakerObjects[idx].region.element.style.display = "";
1047 matches++;
1048 if (e.target.value.length > 0) {
1049 const reg = new RegExp(e.target.value, 'gi'); // [g]lobal, [i]gnore case
1050 chapters.children[idx].firstChild.innerHTML = chapters.children[idx].firstChild.innerText.replace(reg, '<b>$&</b>'); // highlights matching text
1051 } else {
1052 chapters.children[idx].firstChild.innerHTML = chapters.children[idx].firstChild.innerText; // highlights matching text
1053 }
1054 }
1055 }
1056 }
1057 flashChapters();
1058 if (matches == 0) {
1059 const msg = document.createElement("span");
1060 msg.innerHTML = "No Matches!";
1061 msg.id = "chapter-alert";
1062 chapters.prepend(msg);
1063 }
1064 }
1065 }
1066
1067 function clearChapterSearch() { // clears search filter and updates results
1068 chapterSearchInput.value = "";
1069 chapterSearchInput.dispatchEvent(new Event("input"));
1070 }
1071
1072 function showStartStopConflicts(e, forceRun) { // hides regions that have identical start/stop time
1073 removeCurrentRegion();
1074 if ((dualMode && !timelineMenuRegionConflict.children[0].checked) || forceRun) {
1075 timelineMenuRegionConflict.children[0].checked = true;
1076 let primHide = [];
1077 let secHide = [];
1078 if (!timelineMenuSpeakerConflict.children[0].checked) hideAll();
1079 for (const primIdx in primarySet.tempSpeakerObjects) {
1080 for (const secIdx in secondarySet.tempSpeakerObjects) {
1081 if (regionsMatch(primarySet.tempSpeakerObjects[primIdx], secondarySet.tempSpeakerObjects[secIdx])) { // if regions have same start/end time, hide
1082 primHide.push(primIdx);
1083 secHide.push(secIdx);
1084 }
1085 }
1086 }
1087 for (const primIdx in primarySet.tempSpeakerObjects) {
1088 if (!primHide.includes(primIdx)) {
1089 primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "";
1090 if (primaryCaret.src.includes('fill')) chapters.children[primIdx].style.display = "flex";
1091 }
1092 }
1093 for (const secIdx in secondarySet.tempSpeakerObjects) {
1094 if (!secHide.includes(secIdx)) {
1095 secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "";
1096 if (secondaryCaret.src.includes('fill')) chapters.children[secIdx].style.display = "flex";
1097 }
1098 }
1099 } else {
1100 timelineMenuRegionConflict.children[0].checked = false;
1101 if (timelineMenuSpeakerConflict.children[0].checked) showSpeakerNameConflicts(e, true);
1102 else clearConflicts();
1103 }
1104 }
1105
1106 function showSpeakerNameConflicts(e, forceRun) { // shows regions that have identical start/stop time but different names
1107 removeCurrentRegion();
1108 if ((dualMode && !timelineMenuSpeakerConflict.children[0].checked) || forceRun) {
1109 timelineMenuSpeakerConflict.children[0].checked = true;
1110 if (!timelineMenuRegionConflict.children[0].checked) hideAll();
1111 for (const primIdx in primarySet.tempSpeakerObjects) {
1112 for (const secIdx in secondarySet.tempSpeakerObjects) {
1113 if (regionsMatch(primarySet.tempSpeakerObjects[primIdx], secondarySet.tempSpeakerObjects[secIdx]) &&
1114 primarySet.tempSpeakerObjects[primIdx].speaker != secondarySet.tempSpeakerObjects[secIdx].speaker) { // hide if regions match but names don't
1115 primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "";
1116 secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "";
1117 if (primaryCaret.src.includes('fill')) chapters.children[primIdx].style.display = "flex";
1118 else chapters.children[secIdx].style.display = "flex";
1119 }
1120 }
1121 }
1122 } else {
1123 timelineMenuSpeakerConflict.children[0].checked = false;
1124 if (timelineMenuRegionConflict.children[0].checked) showStartStopConflicts(e, true);
1125 else clearConflicts();
1126 }
1127 }
1128
1129 function clearConflicts() { // shows all regions and chapters
1130 for (const primIdx in primarySet.tempSpeakerObjects) {
1131 for (const secIdx in secondarySet.tempSpeakerObjects) {
1132 primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "";
1133 secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "";
1134 chapters.children[primIdx].style.display = "flex";
1135 }
1136 }
1137 }
1138
1139 function hideAll() { // hides all regions and chapters
1140 for (const primIdx in primarySet.tempSpeakerObjects) {
1141 for (const secIdx in secondarySet.tempSpeakerObjects) {
1142 primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "none";
1143 secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "none";
1144 chapters.children[primIdx].style.display = "none";
1145 }
1146 }
1147 }
1148
1149 function timelineMenuToggle(e) { // shows / hides timeline menu
1150 e.stopPropagation();
1151 if (timelineMenu.classList.contains('visible')) {
1152 timelineMenu.classList.remove('visible');
1153 e.target.style.transform = 'rotate(0deg)';
1154 }
1155 else {
1156 timelineMenu.classList.add('visible');
1157 e.target.style.transform = 'rotate(-90deg)';
1158 }
1159 }
1160
1161 function handleRegionSnap(region, e) { // clips region to opposite set region if nearby, called on region update (lots)
1162 if (editMode && currentRegion && !wavesurfer.isPlaying()) {
1163 removeRegionBounds();
1164 setHoverSpeaker(region.element.style.left, currentRegion.speaker);
1165 drawRegionBounds(region, wave.scrollLeft, "FireBrick"); // gets set to red if currRegion
1166 if (e && e.action === "resize" && dualMode && editMode && !ctrlDown) { // won't actuate on drag
1167 let oppositeSet = secondarySet; // look down
1168 if (currSpeakerSet.isSecondary) oppositeSet = primarySet; // look up
1169 if (e.direction === "left") {
1170 region.update({ start: getSnapValue(region.start, oppositeSet.tempSpeakerObjects)});
1171 } else if (e.direction === "right") {
1172 region.update({ end: getSnapValue(region.end, oppositeSet.tempSpeakerObjects)});
1173 }
1174 }
1175 if (e && (e.action === "resize" || e.action === "drag")) {
1176 setInputInSeconds(startTimeInput, region.start);
1177 setInputInSeconds(endTimeInput, region.end);
1178 }
1179 }
1180 }
1181
1182 /**
1183 * Returns snap value if near [snapRadius] adjacent region edge
1184 * @param newDragPos Drag position in seconds to check for
1185 * @param speakerSet Adjacent region set
1186 * @returns {number} If found, returns snapped position, otherwise returns input position
1187 */
1188 function getSnapValue(newDragPos, speakerSet) {
1189 const snapRadius = 1;
1190 for (const region of speakerSet) { // scan opposite region for potential snapping points
1191 if (newDragPos > parseFloat(region.start) - snapRadius && newDragPos < parseFloat(region.start) + snapRadius) {
1192 snappedTo = "start";
1193 if (snappedToX == 0) snappedToX = waveformCursorX;
1194 return region.start;
1195 }
1196 if (newDragPos > parseFloat(region.end) - snapRadius && newDragPos < parseFloat(region.end) + snapRadius) {
1197 snappedTo = "end";
1198 if (snappedToX == 0) snappedToX = waveformCursorX;
1199 return region.end;
1200 }
1201 if (snappedTo !== "none" && (waveformCursorX - snappedToX > 10 || waveformCursorX - snappedToX < -10)) {
1202 snappedTo = "none";
1203 snappedToX = 0;
1204 return cursorPos;
1205 }
1206 }
1207 return newDragPos;
1208 }
1209
1210 function mmssToSeconds(input) {
1211 const arr = input.split(":");
1212 if (arr.length == 2) {
1213 return (parseInt(arr[0]) * 60) + parseInt(arr[1]);
1214 } else if (arr.length == 3) {
1215 return (parseInt(arr[0]) * 3600) + (parseInt(arr[1]) * 60) + parseInt(arr[2]);
1216 } else {
1217 console.error("unexpected input to mmssToSeconds: " + input);
1218 }
1219 }
1220
1221 function removeRightClicked(e) {
1222 if (!e.target.classList.contains('disabled')) {
1223 removeRegion();
1224 }
1225 }
1226
1227 function replaceSelected(e) { // moves selected region across, replaces and removes any overlapping regions in the opposite set
1228 if (!e.target.classList.contains('disabled')) {
1229 let destinationSet = secondarySet; // replace down
1230 if (currSpeakerSet.isSecondary) destinationSet = primarySet; // replace up
1231 let currItems = [currentRegion];
1232 if (currentRegions && currentRegions.length > 0) currItems = currentRegions;
1233 for (let idx = 0; idx < currItems.length; idx++) { // handles both currentRegion and currentRegions
1234 for (let idy = 0; idy < destinationSet.tempSpeakerObjects.length; idy++) {
1235 const reg = destinationSet.tempSpeakerObjects[idy];
1236 if ((parseFloat(reg.start) >= parseFloat(currItems[idx].start) && parseFloat(reg.start) <= parseFloat(currItems[idx].end)) ||
1237 (parseFloat(reg.start) <= parseFloat(currItems[idx].start) && parseFloat(reg.end) >= parseFloat(currItems[idx].start))) {
1238 destinationSet.tempSpeakerObjects.splice(idy, 1); // remove subsequent region
1239 idy--;
1240 }
1241 }
1242 }
1243 copySelected(e, true);
1244 reloadRegionsAndChapters();
1245 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "replace", getCurrentRegionIndex());
1246 }
1247 }
1248
1249 function containsRegion(set, region) { // true if given region exists in given set
1250 for (const item of set) {
1251 if (regionsMatch(region, item)) return true;
1252 }
1253 return false;
1254 }
1255
1256 function overdubSelected(e) { // moves selected region across, merges any overlapping regions in the opposite set
1257 if (!e.target.classList.contains('disabled')) {
1258 let destinationSet = secondarySet; // replace down
1259 if (currSpeakerSet.isSecondary) destinationSet = primarySet; // replace up
1260 let backup;
1261 if (destinationSet.isSecondary) backup = cloneSpeakerObjectArray(primarySet.tempSpeakerObjects); // saves selected set as this process changes values in selected set
1262 else backup = cloneSpeakerObjectArray(secondarySet.tempSpeakerObjects);
1263 copySelected(e, true);
1264 if (!currentRegions || currentRegions.length < 1) { // overdub single
1265 handleSameSpeakerOverlap(getCurrentRegionIndex(), destinationSet, true);
1266 } else { // overdub multiple
1267 for (const item of getCurrentRegionsIndexes().reverse()) { // reverse indexes so index doesn't break when regions are removed
1268 handleSameSpeakerOverlap(item, destinationSet, true);
1269 }
1270 }
1271 if (destinationSet.isSecondary) primarySet.tempSpeakerObjects = backup;
1272 else secondarySet.tempSpeakerObjects = backup;
1273 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "overdub", getCurrentRegionIndex());
1274 reloadRegionsAndChapters();
1275 }
1276 }
1277
1278 function copySelected(e, skipUndoState) { // copies region to opposite set [utility function for replace and overdub]
1279 if (!e.target.classList.contains('disabled')) {
1280 let destinationSet = secondarySet; // copy down
1281 if (currSpeakerSet.isSecondary) destinationSet = primarySet // copy up
1282 const selectedRegion = currentRegion;
1283 if (currentRegions && currentRegions.length > 1) { // copy multiple
1284 destinationSet.tempSpeakerObjects.push(...selectedRegions); // append current regions to dest. set
1285 // currSpeakerSet.isSecondary ? caretClicked("primary-caret") : caretClicked("secondary-caret"); // swap selected speakerSet (clears current regions)
1286 // for (const reg of destinationSet.tempSpeakerObjects) { // restore currentRegions in dest. set
1287 // for (const selReg of selectedRegions) {
1288 // if (regionsMatch(reg, selReg) && !containsRegion(currentRegions, reg)) {
1289 // currentRegions.push(reg);
1290 // }
1291 // }
1292 // if (regionsMatch(reg, selectedRegion)) { currentRegion = reg; }
1293 // }
1294 } else { // copy singular
1295 destinationSet.tempSpeakerObjects.push(selectedRegion); // append current region to dest. set
1296 // currSpeakerSet.isSecondary ? caretClicked("primary-caret") : caretClicked("secondary-caret"); // swap selected speakerSet (clears current regions)
1297 // for (const reg of destinationSet.tempSpeakerObjects) { // restore currentRegion in dest. set
1298 // if (regionsMatch(reg, selectedRegion)) {
1299 // currentRegion = reg;
1300 // break;
1301 // }
1302 // }
1303 }
1304 reloadRegionsAndChapters();
1305 if (!skipUndoState) addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "copy", getCurrentRegionIndex());
1306 }
1307 }
1308
1309 /**
1310 * Shows context menu with various region options
1311 * @param {MouseEvent} e Either right click event or left click triple menu click event
1312 */
1313 function onRightClick(e) {
1314 if ((e.target.classList.contains("wavesurfer-region") || e.target.id === "audioContainer" || e.target.classList.contains("chapter")) && editMode) {
1315 e.preventDefault();
1316 e.stopPropagation();
1317 // set current region to clicked region LLLLLLL
1318 let clickedRegion; // could be used to select clicked region
1319 for (const reg of currSpeakerSet.tempSpeakerObjects) {
1320 if (reg.region.element.title == e.target.title) {
1321 clickedRegion = reg;
1322 break;
1323 }
1324 }
1325 // console.log(clickedRegion)
1326 contextMenu.classList.add("visible");
1327 if (e.clientX + 200 > $(window).width()) contextMenu.style.left = ($(window).width() - 220) + "px"; // ensure menu doesn't clip on right
1328 else contextMenu.style.left = e.clientX + "px";
1329 contextMenu.style.top = e.clientY + "px";
1330
1331 let lockConflict = false;
1332 if (currentRegions.length > 1) {
1333 let firstIsLocked = 0;
1334 for (const reg of currentRegions) {
1335 if (firstIsLocked === 0) firstIsLocked = reg.locked;
1336 else if (firstIsLocked != reg.locked) lockConflict = true;
1337 }
1338 }
1339 if (lockConflict) {
1340 contextLock.classList.remove('disabled');
1341 if (!document.getElementById("context-menu-lock-2")) {
1342 let contextLock2 = contextLock.cloneNode();
1343 contextLock.innerText = "Lock Selected";
1344 contextLock2.innerText = "Unlock Selected";
1345 contextLock2.id = "context-menu-lock-2";
1346 contextLock2.addEventListener('click', toggleLockSelected);
1347 contextLock.parentNode.insertBefore(contextLock2, contextLock.nextSibling);
1348 }
1349 } else {
1350 contextLock.classList.remove('disabled');
1351 let currIndex = getCurrentRegionIndex();
1352 if (currSpeakerSet.tempSpeakerObjects[currIndex] && currSpeakerSet.tempSpeakerObjects[currIndex].locked) {
1353 contextLock.innerText = "Unlock Selected";
1354 chapters.childNodes[currIndex].childNodes[1].classList.remove('hide');
1355 } else if (currSpeakerSet.tempSpeakerObjects[currIndex]) {
1356 contextLock.innerText = "Lock Selected";
1357 chapters.childNodes[currIndex].childNodes[1].classList.add('hide');
1358 }
1359 }
1360
1361 if (dualMode && currentRegion && currentRegion.speaker !== "") {
1362 contextReplace.classList.remove('disabled');
1363 contextOverdub.classList.remove('disabled');
1364 } else {
1365 contextDelete.classList.add('disabled');
1366 contextLock.classList.add('disabled');
1367 contextReplace.classList.add('disabled');
1368 contextOverdub.classList.add('disabled');
1369 }
1370 if (currentRegion && currentRegion.speaker !== "") {
1371 contextDelete.classList.remove('disabled');
1372 contextLock.classList.remove('disabled');
1373 }
1374 if (dualMode) { // manipulate context texts
1375 const actionDirection = currSpeakerSet.isSecondary ? "Up" : "Down";
1376 contextReplace.innerHTML = "Replace Selected " + actionDirection;
1377 contextOverdub.innerHTML = "Overdub Selected " + actionDirection;
1378 }
1379 }
1380 }
1381
1382 function saveSelected(e) {
1383 let csvContent = "data:text/csv;charset=utf-8," + currSpeakerSet.speakerObjects.map(item => "\n" + [item.speaker, item.start, item.end].join());
1384 console.log(csvContent);
1385 var encodedUri = encodeURI(csvContent);
1386 window.open(encodedUri);
1387 }
1388
1389 function keyUp(e) { // key up listener
1390 if (e.key == "Control") ctrlDown = false;
1391 if (e.target.tagName !== "INPUT") {
1392 if (e.code === "Backspace" || e.code === "Delete") removeRegion();
1393 else if (e.code === "Space") { wavesurfer.playPause(); }
1394 else if (e.code === "ArrowLeft") wavesurfer.skipBackward();
1395 else if (e.code === "ArrowRight") wavesurfer.skipForward();
1396 else if (e.code === "KeyL") toggleLockSelected(e);
1397 }
1398 if (e.code == "KeyZ" && e.ctrlKey) undo();
1399 else if (e.code == "KeyY" && e.ctrlKey) redo();
1400 }
1401
1402 function keyDown(e) { // keydown listener
1403 if (e.key == "Control") ctrlDown = true;
1404 if (e.code == "Space" && e.target.tagName.toLowerCase() != "input") e.preventDefault();
1405 }
1406
1407 /**
1408 * Shows / hides secondary speaker set
1409 * @param skipUndoState Utility param - skips the addition of an undo state
1410 * @param overrideValue Utility param - overrides the checkbox state
1411 */
1412 function dualModeChanged(skipUndoState, overrideValue) {
1413 if (overrideValue) dualModeCheckbox.checked = overrideValue == "true" ? true : false;
1414 else dualModeCheckbox.checked = !dualModeCheckbox.checked; // toggle dual mode checkbox
1415 dualMode = dualModeCheckbox.checked;
1416 currSpeakerSet = primarySet;
1417 if (!dualMode) removeCurrentRegion();
1418 clearChapterSearch();
1419 reloadRegionsAndChapters();
1420 if (dualMode) {
1421 if (!secondaryLoaded) {
1422 // const secondaryCSVURL = "http://localhost:8383/greenstone3/cgi-bin/metadata-server.pl?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.collectionMetadata.indexStem +
1423 // "&d=" + gs.documentMetadata.Identifier + "&assocname=structured-audio.csv&dv=nminus-1";
1424 const secondaryCSVURL = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.collectionMetadata.indexStem +
1425 "&d=" + gs.documentMetadata.Identifier + "&assocname=structured-audio.csv&dv=nminus-1";
1426 loadCSVFile(secondaryCSVURL, secondarySet);
1427 secondaryLoaded = true; // ensure secondarySet doesn't get re-read > once
1428 }
1429 document.getElementById("caret-container").style.display = "flex";
1430 timelineMenuRegionConflict.classList.remove("disabled");
1431 timelineMenuSpeakerConflict.classList.remove("disabled");
1432 $('#track-set-label-bottom').fadeIn(100);
1433 selectedVersions[1] = document.getElementById('track-set-label-bottom').children[0].innerText;
1434 } else {
1435 caretClicked('primary-caret');
1436 document.getElementById("caret-container").style.display = "none";
1437 selectedVersions.splice(1, 1); // trim to one version in array
1438 timelineMenuRegionConflict.firstElementChild.checked = false;
1439 timelineMenuSpeakerConflict.firstElementChild.checked = false;
1440 timelineMenuRegionConflict.classList.add("disabled");
1441 timelineMenuSpeakerConflict.classList.add("disabled");
1442 $('#track-set-label-bottom').fadeOut(100);
1443 }
1444 currSpeakerSet = primarySet;
1445 if (!skipUndoState) addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "dualModeChange", getCurrentRegionIndex());
1446 }
1447
1448 /**
1449 * Changes selected speaker set
1450 * @param {string} id ID of clicked caret image
1451 */
1452 function caretClicked(id) {
1453 clearChapterSearch();
1454 if (id === "primary-caret") {
1455 currSpeakerSet = primarySet;
1456 swapCarets(true);
1457 } else if (id === "secondary-caret") {
1458 currSpeakerSet = secondarySet;
1459 swapCarets(false);
1460 }
1461 }
1462
1463 /**
1464 * Loads destination waveform and audio if required, updates caret images
1465 * @param {boolean} toPrimary whether destination set is primary (true) or secondary (false)
1466 */
1467 function swapCarets(toPrimary) {
1468 const currCaretIsPrimary = primaryCaret.src.includes("fill") ? true : false; // initial value before swap
1469 if ((toPrimary && !currCaretIsPrimary) || (!toPrimary && currCaretIsPrimary)) {
1470 removeCurrentRegion(); // ensure currentRegion is only removed if changing speakerSet
1471 flashChapters();
1472 reloadChapterList();
1473 }
1474 if (toPrimary) {
1475 if (!currCaretIsPrimary) {
1476 showAudioLoader();
1477 if (canvasImages[selectedVersions[0]]) { // if waveform image exists in cache
1478 drawImageOnWaveform(canvasImages[selectedVersions[0]]);
1479 // hideAudioLoader();
1480 }
1481 // else showAudioLoader();
1482 let url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name +
1483 "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d + "&assocname=" + gs.documentMetadata.Audio;
1484 if (selectedVersions[0] !== "current") {
1485 if (selectedVersions[0].includes("Previous")) url += "&dv=" + selectedVersions[0].replace("Previous(", "nminus-").replace(")", "");
1486 else url += "&dv=" + selectedVersions[0];
1487 }
1488 wavesurfer.load(url);
1489 }
1490 primaryCaret.src = interface_bootstrap_images + "caret-right-fill.svg";
1491 secondaryCaret.src = interface_bootstrap_images + "caret-right.svg";
1492 } else {
1493 if (currCaretIsPrimary) {
1494 showAudioLoader();
1495 if (canvasImages[selectedVersions[1]]) {
1496 drawImageOnWaveform(canvasImages[selectedVersions[1]]);
1497 // hideAudioLoader();
1498 }
1499 // else showAudioLoader();
1500 let url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name +
1501 "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d + "&assocname=" + gs.documentMetadata.Audio;
1502 if (selectedVersions[1] !== "current") {
1503 if (selectedVersions[1].includes("Previous")) url += "&dv=" + selectedVersions[1].replace("Previous(", "nminus-").replace(")", "");
1504 else url += "&dv=" + selectedVersions[1];
1505 }
1506 wavesurfer.load(url);
1507 }
1508 primaryCaret.src = interface_bootstrap_images + "caret-right.svg";
1509 secondaryCaret.src = interface_bootstrap_images + "caret-right-fill.svg";
1510 }
1511 }
1512
1513 /**
1514 * Shows spinning loader over waveform, hides regions
1515 */
1516 function showAudioLoader() {
1517 $('.wavesurfer-region').fadeOut(100);
1518 $(".chapter").fadeOut(100);
1519 $(".track-set-label").fadeOut(100);
1520 waveformSpinner.style.display = 'block';
1521 loader.style.display = "inline";
1522 for (const ele of editPanel.children) ele.classList.add("disabled");
1523 playPauseButton.classList.add("disabled");
1524 }
1525
1526 /**
1527 * Hides spinning loader, brings back regions
1528 */
1529 function hideAudioLoader() {
1530 $('.wavesurfer-region').fadeIn(100);
1531 $(".chapter").fadeIn(100);
1532 $("#track-set-label-top").fadeIn(100);
1533 if (dualMode) $('#track-set-label-bottom').fadeIn(100);
1534 waveformSpinner.style.display = 'none';
1535 loader.style.display = "none";
1536 for (const ele of editPanel.children) ele.classList.remove("disabled");
1537 updateRegionEditPanel();
1538 playPauseButton.classList.remove("disabled");
1539 }
1540
1541 /**
1542 * Draws given image URL on waveform
1543 * @param image URL of image to be drawn
1544 */
1545 function drawImageOnWaveform(image) {
1546 // console.log('draw waveform image from cache')
1547 if (document.getElementById('new-canvas')) document.getElementById('new-canvas').remove();
1548 var newCanvas = document.createElement("div");
1549 newCanvas.id = "new-canvas";
1550 newCanvas.style.width = wavesurfer.drawer.canvases[0].wave.width + 'px';
1551 newCanvas.style.height = '140px';
1552 newCanvas.style.backgroundImage = "url('" + image + "')";
1553 waveformContainer.appendChild(newCanvas);
1554 }
1555
1556 /**
1557 * Regenerates chapter list to update any changes made in speakerSet
1558 */
1559 function reloadChapterList() {
1560 chapters.innerHTML = "";
1561 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
1562 let chapter = document.createElement("div");
1563 chapter.classList.add("chapter");
1564 chapter.id = "chapter" + i;
1565 let speakerName = document.createElement("span");
1566 speakerName.classList.add("speakerName");
1567 speakerName.innerText = currSpeakerSet.tempSpeakerObjects[i].speaker;
1568 let regionLocked = document.createElement("img");
1569 regionLocked.src = interface_bootstrap_images + "lock.svg";
1570 regionLocked.classList.add("speakerLocked", "hide");
1571 attachPadlockListener(regionLocked, currSpeakerSet.tempSpeakerObjects[i].region, true);
1572 if (currSpeakerSet.tempSpeakerObjects[i].locked && editMode) regionLocked.classList.remove("hide");
1573 let speakerTime = document.createElement("span");
1574 speakerTime.classList.add("speakerTime");
1575 speakerTime.innerHTML = minutize(currSpeakerSet.tempSpeakerObjects[i].start) + " - " + minutize(currSpeakerSet.tempSpeakerObjects[i].end) + "s";
1576 chapter.appendChild(speakerName);
1577 chapter.appendChild(regionLocked);
1578 chapter.appendChild(speakerTime);
1579 chapter.addEventListener("click", chapterClicked);
1580 chapter.addEventListener("mouseenter", e => { chapterEnter(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1581 chapter.addEventListener("mouseleave", e => { chapterLeave(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1582 if (chapterSearchInput.value.length > 0 && !speakerName.innerText.toLowerCase().includes(chapterSearchInput.value.toLowerCase())) {
1583 chapter.style.display = "none";
1584 currSpeakerSet.tempSpeakerObjects[i].region.element.style.display = "none";
1585 }
1586 chapters.appendChild(chapter);
1587 }
1588 }
1589
1590 /**
1591 * Shows / hides chapter section
1592 */
1593 let toggleChapters = function() {
1594 if (chapters.style.height == "0px") {
1595 chapters.style.height = "90%";
1596 chaptersContainer.style.height = "30vh";
1597 chapterSearchInput.placeholder = "Filter by Name...";
1598 } else {
1599 chapters.style.height = "0px";
1600 chaptersContainer.style.height = "0px";
1601 chapterSearchInput.placeholder = "";
1602 }
1603 }
1604
1605 /**
1606 * Object representing elements of a diarization output
1607 * @param {boolean} isSecondary Whether or not the set is secondary/bottom (true) or primary/top (false)
1608 * @param {Array} uniqueSpeakers Array of all unique speaker names within the diarization data, used for colouring regions
1609 * @param {Array} speakerObjects Array of objects containing speaker start/stop times and names
1610 * @param {Array} tempSpeakerObjects Temporary version of speakerObjects, which can be reverted back to if required
1611 */
1612 function SpeakerSet(isSecondary, uniqueSpeakers, speakerObjects, tempSpeakerObjects) {
1613 this.isSecondary = isSecondary;
1614 this.uniqueSpeakers = uniqueSpeakers;
1615 this.speakerObjects = speakerObjects;
1616 this.tempSpeakerObjects = tempSpeakerObjects;
1617 }
1618
1619 let primarySet = new SpeakerSet(false, [], [], [], []);
1620 let secondarySet = new SpeakerSet(true, [], [], [], []);
1621 let currSpeakerSet = primarySet;
1622
1623 /**
1624 * Reads diarization CSV file and populates speakerSet
1625 * @param {string} filename Source destination of input CSV file
1626 * @param {object} speakerSet speaker set to be populated
1627 * @param {boolean} forcePopulate Forces redraw of regions and chapters
1628 */
1629 function loadCSVFile(filename, speakerSet, forcePopulate) { // based on: https://stackoverflow.com/questions/7431268/how-to-read-data-from-csv-file-using-javascript
1630 $.ajax({
1631 type: "GET",
1632 url: filename,
1633 dataType: "text",
1634 }).then(function(data) {
1635 let dataLines = data.split(/\r\n|\n/);
1636 let headers;
1637 let startIndex = 0;
1638 speakerSet.uniqueSpeakers = []; // used for obtaining unique colours
1639 speakerSet.speakerObjects = []; // list of speaker items
1640
1641 if (dataLines[0].split(',').length === 3) headers = ["speaker", "start", "end"]; // assume speaker, start, end
1642 else if (dataLines[0].split(',').length === 4) headers = ["speaker", "start", "end", "locked"]; // assume speaker, start, end, locked
1643
1644 for (let i = startIndex; i < dataLines.length; i++) {
1645 let data = dataLines[i].split(',');
1646 if (data.length == headers.length) {
1647 let item = {};
1648 for (let j = 0; j < headers.length; j++) {
1649 item[headers[j]] = data[j];
1650 if (j == 0 && !speakerSet.uniqueSpeakers.includes(data[j])) {
1651 speakerSet.uniqueSpeakers.push(data[j]);
1652 }
1653 }
1654 if (headers.length === 3) item['locked'] = false;
1655 speakerSet.speakerObjects.push(item);
1656 }
1657 }
1658 speakerSet.tempSpeakerObjects = cloneSpeakerObjectArray(speakerSet.speakerObjects);
1659 if (!speakerSet.isSecondary || forcePopulate) populateChaptersAndRegions(speakerSet); // prevents secondary set being drawn on first load
1660 resetUndoStates(); // undo stack init
1661 }, (error) => { console.log("loadCSVFile error:"); console.log(error); });
1662 }
1663
1664 /**
1665 * Populates chapter list div and regions on waveform with given speaker set
1666 * @param {object} data Speaker set object with diarization data
1667 */
1668 function populateChaptersAndRegions(data) {
1669 // colorbrewer is a web tool for guidance in choosing map colour schemes based on a letiety of settings.
1670 // this colour scheme is designed for qualitative data
1671 if (regionColourSet.length < 1) {
1672 for (let i = 0; i < data.uniqueSpeakers.length; i++) { // not tested in cases where there are more than 8 speakers!!
1673 const adjIdx = i%8;
1674 regionColourSet[adjIdx] = { name: data.uniqueSpeakers[i], colour: colourbrewerSet[adjIdx] }
1675 }
1676 }
1677
1678 let isSelectedSet = false;
1679
1680 if ((!data.isSecondary && primaryCaret.src.includes("fill")) || (data.isSecondary && secondaryCaret.src.includes("fill"))) isSelectedSet = true;
1681 data.tempSpeakerObjects = sortSpeakerObjectsByStart(data.tempSpeakerObjects); // sort speakerObjects by start time
1682 if (isSelectedSet || !dualMode) chapters.innerHTML = ""; // clear chapter div for re-population
1683 for (let i = 0; i < data.tempSpeakerObjects.length; i++) {
1684 let chapter = document.createElement("div");
1685 chapter.classList.add("chapter");
1686 chapter.id = "chapter" + i;
1687 let speakerName = document.createElement("span");
1688 speakerName.classList.add("speakerName");
1689 speakerName.innerText = data.tempSpeakerObjects[i].speaker;
1690 let regionLocked = document.createElement("img");
1691 regionLocked.src = interface_bootstrap_images + "lock.svg";
1692 regionLocked.classList.add("speakerLocked", "hide");
1693 attachPadlockListener(regionLocked, data.tempSpeakerObjects[i].region, true);
1694 if (data.tempSpeakerObjects[i].locked && editMode) regionLocked.classList.remove("hide");
1695 let speakerTime = document.createElement("span");
1696 speakerTime.classList.add("speakerTime");
1697 speakerTime.innerHTML = minutize(data.tempSpeakerObjects[i].start) + " - " + minutize(data.tempSpeakerObjects[i].end) + "s";
1698 chapter.appendChild(speakerName);
1699 chapter.appendChild(regionLocked);
1700 chapter.appendChild(speakerTime);
1701 chapter.addEventListener("click", chapterClicked);
1702 chapter.addEventListener("mouseenter", e => { chapterEnter(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1703 chapter.addEventListener("mouseleave", e => { chapterLeave(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1704
1705 let selected = false;
1706 let dummyRegion = { start: data.tempSpeakerObjects[i].start, end: data.tempSpeakerObjects[i].end };
1707
1708 if ((isSelectedSet || !dualMode) && (isCurrentRegion(dummyRegion) || isInCurrentRegions(dummyRegion))) {
1709 chapter.classList.add("selected-chapter");
1710 selected = true;
1711 }
1712
1713 if (isSelectedSet || !dualMode) chapters.appendChild(chapter);
1714
1715 let regColour;
1716 if (regionColourSet.find(item => item.name === data.tempSpeakerObjects[i].speaker)) {
1717 regColour = regionColourSet.find(item => item.name === data.tempSpeakerObjects[i].speaker).colour;
1718 } else {
1719 regionColourSet.push({ name: data.tempSpeakerObjects[i].speaker, colour: colourbrewerSet[i%8]});
1720 regColour = regionColourSet.at(-1).colour;
1721 }
1722
1723 let associatedReg = wavesurfer.addRegion({ // create associated wavesurfer region
1724 id: "region" + i,
1725 start: data.tempSpeakerObjects[i].start,
1726 end: data.tempSpeakerObjects[i].end,
1727 drag: editMode,
1728 resize: editMode,
1729 attributes: {
1730 label: speakerName,
1731 },
1732 color: regColour + regionTransparency,
1733 //...(selected) && {color: "rgba(255,50,50,0.5)"},
1734 });
1735 // I think I checked with Finn that this fix by Dr Bainbridge is fine:
1736 if(selected) {
1737 associatedReg.color="rgba(255,50,50,0.5)";
1738 }
1739
1740 data.tempSpeakerObjects[i].region = associatedReg;
1741 if (selected && data.tempSpeakerObjects[i].locked) { // add padlock to regions if they are selected and locked
1742 let lock = drawPadlock(associatedReg.element);
1743 attachPadlockListener(lock, associatedReg, false);
1744 }
1745 if (selected) drawMenuButton(associatedReg);
1746 }
1747 if (waveformSpinner.style.display == 'block') $(".wavesurfer-region").fadeOut(100); // keep regions hidden until wavesurfer.load() has finished
1748 let handles = document.getElementsByTagName('handle');
1749 for (const handle of handles) handle.addEventListener('mousedown', () => mouseDown = true);
1750
1751 let regions = document.getElementsByTagName("region");
1752 if (dualMode) {
1753 if (document.getElementsByClassName("region-top").length == 0) {
1754 for (const reg of regions) {
1755 if (reg.classList.length == 1) reg.classList.add("region-top");
1756 }
1757 } else {
1758 for (const rego of regions) {
1759 if (!rego.classList.contains("region-top") && rego.classList.length == 1) rego.classList.add("region-bottom");
1760 }
1761 }
1762 }
1763 if (editMode) for (const reg of regions) reg.style.setProperty("z-index", "3", "important");
1764 else for (const reg of regions) reg.style.setProperty("z-index", "1", "important");
1765
1766 chapterSearchInput.dispatchEvent(new Event("input"));
1767 }
1768
1769 function loadJSONFile(filename) {
1770 $.ajax({
1771 type: "GET",
1772 url: filename,
1773 dataType: "text",
1774 }).then(function(data){ populateWords(JSON.parse(data)) }, (error) => { console.log("loadJSONFile error:"); console.log(error); });
1775 }
1776
1777 function populateWords(data) { // populates word section and adds regions to waveform
1778 let transcription = data.transcription;
1779 let words = data.words;
1780 let wordContainer = document.createElement("div");
1781 wordContainer.id = "word-container";
1782 for (let i = 0; i < words.length; i++) {
1783 let word = document.createElement("span");
1784 word.id = "word" + i;
1785 word.classList.add("word");
1786 word.innerHTML = transcription.split(" ")[i];
1787 word.addEventListener("click", e => { wordClicked(data, e.target.id) });
1788 word.addEventListener("mouseover", e => { chapterEnter(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1789 word.addEventListener("mouseleave", e => { chapterLeave(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1790 wordContainer.appendChild(word);
1791 wavesurfer.addRegion({
1792 id: "region" + i,
1793 start: words[i].startTime,
1794 end: words[i].endTime,
1795 drag: false,
1796 resize: false,
1797 color: "rgba(255, 255, 255, 0.1)",
1798 });
1799 }
1800 chapters.appendChild(wordContainer);
1801 }
1802
1803 let chapterClicked = function(e) { // plays audio from start of chapter
1804 const index = Array.from(chapters.children).indexOf(e.target);
1805 if (currSpeakerSet.tempSpeakerObjects[index]) {
1806 let clickedRegion = currSpeakerSet.tempSpeakerObjects[index].region;
1807 handleRegionClick(clickedRegion, e);
1808 }
1809 }
1810
1811 function wordClicked(data, id) { // plays audio from start of word
1812 let index = id.replace("word", "");
1813 let start = data.words[index].startTime;
1814 wavesurfer.play(start);
1815 }
1816
1817 function chapterEnter(idx) {
1818 let reg = currSpeakerSet.tempSpeakerObjects[idx].region;
1819 regionEnter(reg);
1820 setHoverSpeaker(reg.element.style.left, reg.attributes.label.innerText);
1821 if (!isInCurrentRegions(reg)) {
1822 removeRegionBounds();
1823 drawRegionBounds(reg, wave.scrollLeft, "black");
1824 }
1825 }
1826
1827 function chapterLeave(idx) {
1828 regionLeave(currSpeakerSet.tempSpeakerObjects[idx].region);
1829 removeRegionBounds();
1830 hoverSpeaker.innerHTML = "";
1831 if (currentRegion.speaker && getCurrentRegionIndex() != -1) {
1832 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
1833 drawCurrentRegionBounds();
1834 }
1835 }
1836 /**
1837 * Handles region and chapter colours
1838 * @param {object} region Region element to adjust
1839 * @param {boolean} highlight Whether or not region should be white-highlighted
1840 */
1841 function handleRegionColours(region, highlight) { // handles region, chapter & word colours
1842 if (!dualMode || (region.element.classList.contains("region-top") && primaryCaret.src.includes("fill")) || region.element.classList.contains("region-bottom") && secondaryCaret.src.includes("fill")) {
1843 let colour;
1844 if (highlight) {
1845 colour = "rgb(81, 90, 90)";
1846 regionEnter(region);
1847 } else {
1848 colour = "";
1849 regionLeave(region);
1850 }
1851 if (isCurrentRegion(region) || isInCurrentRegions(region)) {
1852 colour = "rgba(255, 50, 50, 0.5)";
1853 }
1854 if (chapters.childNodes[getIndexOfRegion(region)]) chapters.childNodes[getIndexOfRegion(region)].style.backgroundColor = colour;
1855 }
1856 }
1857
1858 function regionEnter(region) {
1859 if (isCurrentRegion(region) || isInCurrentRegions(region)) {
1860 region.update({ color: "rgba(255, 50, 50, 0.5)" });
1861 } else {
1862 region.update({ color: "rgba(255, 255, 255, 0.3)" });
1863 }
1864 if (editMode && currSpeakerSet.tempSpeakerObjects[getIndexOfRegion(region)] && currSpeakerSet.tempSpeakerObjects[getIndexOfRegion(region)].locked
1865 && region.element.getElementsByClassName("region-padlock").length == 0) { // hovered region is locked
1866 let lock = drawPadlock(region.element);
1867 attachPadlockListener(lock, region, false);
1868 }
1869 if (editMode && region.element.getElementsByClassName("region-menu").length == 0) {
1870 drawMenuButton(region);
1871 }
1872 }
1873
1874 function regionLeave(region) {
1875 if (itemType == "chapter") {
1876 if (isCurrentRegion(region) || isInCurrentRegions(region)) {
1877 region.update({ color: "rgba(255, 50, 50, 0.5)" });
1878 // } else if (!(wavesurfer.getCurrentTime() + 0.1 < region.end && wavesurfer.getCurrentTime() > region.start)) {
1879 } else {
1880 let index = region.id.replace("region", "");
1881 region.update({ color: regionColourSet.find(item => item.name === currSpeakerSet.tempSpeakerObjects[index].speaker).colour + regionTransparency });
1882 }
1883 if (region.element.getElementsByTagName("img").length > 0 && !isCurrentRegion(region) && !isInCurrentRegions(region)) {
1884 for (let child of Array.from(region.element.children)) {
1885 if (child.tagName == "IMG") {
1886 child.remove();
1887 }
1888 }
1889 }
1890 } else {
1891 region.update({ color: "rgba(255, 255, 255, 0.1)" });
1892 }
1893 }
1894
1895 function minutize(num) { // converts seconds to m:ss for chapters & waveform hover
1896 let date = new Date(null);
1897 date.setSeconds(num);
1898 return date.toTimeString().split(" ")[0].substring(3);
1899 }
1900
1901 function formatCursor(num) {
1902 cursorPos = num;
1903 return minutize(num);
1904 }
1905
1906 function getLetter(val) {
1907 let speakerNum = parseInt(val.replace("SPEAKER_",""));
1908 return String.fromCharCode(65 + speakerNum); // 'A' == UTF-16 65
1909 }
1910
1911 function toggleEditMode(skipDualModeToggle) { // toggles edit panel and redraws regions with resize handles
1912 if (gs.variables.allowEditing === '1') {
1913 toggleEditPanel();
1914 updateRegionEditPanel();
1915 reloadChapterList();
1916 }
1917 }
1918
1919 function toggleVersionDropdown(e) {
1920 e.stopPropagation();
1921 if (versionSelectMenu.classList.contains("visible")) {
1922 e.target.style.display = 'inline';
1923 versionSelectMenu.classList.remove("visible");
1924 }
1925 else {
1926 e.target.style.display = 'none';
1927 versionSelectMenu.classList.add("visible");
1928 versionSelectMenu.style.top = "2rem";
1929 versionSelectMenu.style.height = wave.clientHeight + wavesurfer.timeline.container.clientHeight + document.getElementById("audio-toolbar").clientHeight - 6 + "px";
1930 if (e.target.parentElement.id.includes("top")) versionSelectMenu.classList.add("versionTop");
1931 else versionSelectMenu.classList.remove("versionTop");
1932 for (version of versionSelectMenu.children) { // handle disabling of regions if being viewed
1933 if (selectedVersions.includes(version.id) || selectedVersions.includes(version.innerText)) version.classList.add('disabled');
1934 else version.classList.remove('disabled');
1935 }
1936 }
1937 }
1938
1939 function toggleEditPanel() { // show & hide edit panel
1940 removeCurrentRegion();
1941 hoverSpeaker.innerHTML = "";
1942 if (editPanel.style.height == "0px") {
1943 if (chapters.style.height == "0px") toggleChapters(); // expands chapter panel
1944 editPanel.style.height = "30vh";
1945 editPanel.style.padding = "0.5rem";
1946 setRegionEditMode(true);
1947 } else {
1948 editPanel.style.height = "0px";
1949 editPanel.style.padding = "0px";
1950 setRegionEditMode(false);
1951 }
1952 }
1953
1954 function setRegionEditMode(state) {
1955 editMode = state;
1956 chapters.innerHTML = '';
1957 $('.wavesurfer-region').hide();
1958 reloadRegionsAndChapters(); // editMode sets drag/resize property when regions are redrawn
1959 }
1960
1961 /**
1962 * Handles the edit of region start time, stop time, or speaker name, updating the speaker set
1963 * @param {object} region Region that has been updated
1964 */
1965 function handleRegionEdit(region, e) {
1966 if (region.element.classList.contains("region-bottom")) { currSpeakerSet = secondarySet; swapCarets(false) }
1967 else { currSpeakerSet = primarySet; swapCarets(true) }
1968 editsMade = true;
1969 currentRegion = region;
1970 wavesurfer.backend.seekTo(region.start);
1971 let regionIndex = getCurrentRegionIndex();
1972 currentRegion.speaker = currSpeakerSet.tempSpeakerObjects[regionIndex].speaker;
1973 currSpeakerSet.tempSpeakerObjects[regionIndex].region = region;
1974 currSpeakerSet.tempSpeakerObjects[regionIndex].start = region.start;
1975 currSpeakerSet.tempSpeakerObjects[regionIndex].end = region.end;
1976
1977 const chaps = chapters.childNodes; // chapter list
1978 chaps[regionIndex].childNodes[1].textContent = minutize(region.start) + " - " + minutize(region.end) + "s"; // update chapter item time
1979 currSpeakerSet.tempSpeakerObjects[regionIndex].region.update({start: region.start, end: region.end}); // update start/end
1980
1981 handleSameSpeakerOverlap(getCurrentRegionIndex(), currSpeakerSet); // recalculate index in case start pos has changed
1982 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "dragdrop", getCurrentRegionIndex());
1983 editLockedRegion(currSpeakerSet.tempSpeakerObjects[regionIndex], chaps);
1984
1985 editPanel.click(); // fixes buttons needing to be clicked twice (unknown cause!)
1986 }
1987
1988 /**
1989 * Shows popup to ensure user is aware they are editing a locked region
1990 * @param {object} region Region that is being edited
1991 */
1992 function editLockedRegion(region) { // ensures user is aware region being edited is locked
1993 if (region.locked) {
1994 let confirm = false;
1995 confirm = window.confirm("Editing a locked region will unlock it, are you sure you want to continue?");
1996 if (!confirm) undo(); // undo change if no
1997 else { // remove lock if yes
1998 region.locked = false;
1999 if (region.region && region.region.element.firstChild) region.region.element.firstChild.remove(); // remove region padlock
2000 if (chapters.childNodes[getCurrentRegionIndex()] && chapters.childNodes[getCurrentRegionIndex()].childNodes[1].tagName === "IMG") {
2001 chapters.childNodes[getCurrentRegionIndex()].childNodes[1].classList.add('hide'); // remove chapter padlock
2002 }
2003 }
2004 }
2005 }
2006
2007 /**
2008 * Merges same-speaker regions with overlapping bounds
2009 * @param {int} regionIdx Index of dragged/edited region
2010 * @param {object} speakerSet Speaker set dragged region exists in
2011 * @param {boolean} skipCurrentRegionUpdate Whether or not to skip the updating of current region
2012 */
2013 function handleSameSpeakerOverlap(regionIdx, speakerSet, skipCurrentRegionUpdate) {
2014 let draggedRegion = speakerSet.tempSpeakerObjects[regionIdx]; // regionIdx may point to a different region within the for-loop after adjustments, so defined here
2015 let draggedRegionSpeaker = draggedRegion.speaker;
2016 for (let i = 0; i < speakerSet.tempSpeakerObjects.length; i++) {
2017 if (speakerSet.tempSpeakerObjects[i].speaker === draggedRegionSpeaker && !regionsMatch(draggedRegion, speakerSet.tempSpeakerObjects[i])) { // ensure speaker name match
2018 if (parseFloat(speakerSet.tempSpeakerObjects[i].start) <= parseFloat(draggedRegion.end) && parseFloat(draggedRegion.start) <= parseFloat(speakerSet.tempSpeakerObjects[i].end)) { // ensure overlap
2019 draggedRegion.start = Math.min(speakerSet.tempSpeakerObjects[i].start, draggedRegion.start);
2020 draggedRegion.end = Math.max(speakerSet.tempSpeakerObjects[i].end, draggedRegion.end);
2021 draggedRegion.region.update({start: Math.min(speakerSet.tempSpeakerObjects[i].start, draggedRegion.start), end: Math.max(speakerSet.tempSpeakerObjects[i].end, draggedRegion.end)});
2022 if (!skipCurrentRegionUpdate) currentRegion = draggedRegion;
2023 speakerSet.tempSpeakerObjects[i].region.remove();
2024 speakerSet.tempSpeakerObjects.splice(i, 1); // remove consumed region
2025 setInputInSeconds(startTimeInput, draggedRegion.region.start); // update number inputs
2026 setInputInSeconds(endTimeInput, draggedRegion.region.end);
2027 i = -1; // reset for loop to support multiple consumptions
2028 }
2029 }
2030 }
2031 for (let i = 0; i < speakerSet.tempSpeakerObjects.length; i++) { // remove duplicates
2032 if (speakerSet.tempSpeakerObjects[i] && speakerSet.tempSpeakerObjects[i+1]) {
2033 if (regionsMatch(speakerSet.tempSpeakerObjects[i], speakerSet.tempSpeakerObjects[i+1])) {
2034 speakerSet.tempSpeakerObjects[i+1].region.remove();
2035 speakerSet.tempSpeakerObjects.splice(i+1, 1); // remove consumed region
2036 i--;
2037 }
2038 }
2039 }
2040 }
2041
2042 /**
2043 * Updates the edit panel elements based on various editing states
2044 */
2045 function updateRegionEditPanel() {
2046 if (currentRegion && currentRegion.speaker == "") {
2047 removeButton.classList.add("disabled");
2048 speakerInput.classList.add("disabled");
2049 changeAllCheckbox.classList.add("disabled");
2050 changeAllCheckbox.disabled = true;
2051 disableStartEndInputs();
2052 speakerInput.readOnly = true;
2053 speakerInput.value = "";
2054 } else {
2055 removeButton.classList.remove("disabled");
2056 speakerInput.classList.remove("disabled");
2057 changeAllCheckbox.classList.remove("disabled");
2058 if (!isZooming) changeAllCheckbox.disabled = false;
2059 enableStartEndInputs();
2060 speakerInput.readOnly = false;
2061 }
2062 if (editsMade) {
2063 discardButton.classList.remove("disabled");
2064 saveButton.classList.remove("disabled");
2065 } else {
2066 discardButton.classList.add("disabled");
2067 saveButton.classList.add("disabled");
2068 }
2069 if (changeAllCheckbox.checked) {
2070 // changeAllLabel.innerHTML = "Change all (x" + currentRegions.length + ")";
2071 disableStartEndInputs();
2072 }
2073 if (currentRegion && currentRegion.speaker != "") {
2074 speakerInput.value = currentRegion.speaker;
2075 setInputInSeconds(startTimeInput, currentRegion.start);
2076 setInputInSeconds(endTimeInput, currentRegion.end);
2077 }
2078 if (undoLevel - 1 < 0) undoButton.classList.add("disabled");
2079 else undoButton.classList.remove("disabled");
2080 if (undoLevel + 1 >= undoStates.length) redoButton.classList.add("disabled");
2081 else redoButton.classList.remove("disabled");
2082 }
2083
2084 /**
2085 * Adds a new region to the waveform at the current caret location with the speaker name "NEW_SPEAKER"
2086 */
2087 function createNewRegion() { // adds a new region to the waveform
2088 clearChapterSearch();
2089 const speaker = "NEW_SPEAKER"; // default name
2090 if (!currSpeakerSet.uniqueSpeakers.includes(speaker)) { currSpeakerSet.uniqueSpeakers.push(speaker) }
2091 const start = newRegionOffset + wavesurfer.getCurrentTime();
2092 const end = newRegionOffset + wavesurfer.getCurrentTime() + 15;
2093 newRegionOffset += 5; // offset new region if multiple new regions are created.
2094 currSpeakerSet.tempSpeakerObjects.push({speaker: speaker, start: start, end: end});
2095
2096 editsMade = true;
2097 currentRegions = [];
2098 currentRegion = getRegionFromProps({speaker: speaker, start: start, end: end});
2099 reloadRegionsAndChapters();
2100 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "create", getCurrentRegionIndex());
2101 }
2102
2103 function getRegionFromProps(props, speakerSet) { // find region using speaker, start & end time
2104 if (!speakerSet) speakerSet = currSpeakerSet;
2105 for (let i = 0; i < speakerSet.tempSpeakerObjects.length; i++) {
2106 if (speakerSet.tempSpeakerObjects[i].speaker === props.speaker && speakerSet.tempSpeakerObjects[i].start === props.start && speakerSet.tempSpeakerObjects[i].end === props.end) {
2107 return speakerSet.tempSpeakerObjects[i];
2108 }
2109 }
2110 console.log("getRegionFromProps failed to find matching region");
2111 }
2112
2113 /**
2114 * Removes the currently selected region or regions
2115 */
2116 function removeRegion() {
2117 if (!removeButton.classList.contains("disabled")) {
2118 if (getCurrentRegionIndex() != -1) { // if currentRegion has been set
2119 let currentRegionIndex = getCurrentRegionIndex();
2120 let currentRegionIndexes = getCurrentRegionsIndexes();
2121 let lockTemplate = { locked: currSpeakerSet.tempSpeakerObjects[currentRegionIndex].locked };
2122 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
2123 if (isCurrentRegion(currSpeakerSet.tempSpeakerObjects[i].region)) {
2124 currSpeakerSet.tempSpeakerObjects[i].region.remove();
2125 currSpeakerSet.tempSpeakerObjects.splice(i, 1); // remove from tempSpeakerObjects
2126 editsMade = true;
2127 if (i >= 0) i--; // decrement index for side-by-side regions
2128 if (!changeAllCheckbox.checked && currentRegions.length < 1) {
2129 removeCurrentRegion();
2130 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "remove", currentRegionIndex);
2131 updateRegionEditPanel();
2132 reloadChapterList();
2133 editLockedRegion(lockTemplate);
2134 return; // jump out of function
2135 }
2136 } else if (isInCurrentRegions(currSpeakerSet.tempSpeakerObjects[i])) {
2137 currSpeakerSet.tempSpeakerObjects[i].region.remove();
2138 currSpeakerSet.tempSpeakerObjects.splice(i, 1);
2139 if (i >= 0) i--;
2140 }
2141 }
2142 removeCurrentRegion();
2143 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "remove", currentRegionIndex, currentRegionIndexes); // multiple regions removed
2144 updateRegionEditPanel();
2145 reloadChapterList();
2146 editLockedRegion(lockTemplate);
2147 } else { console.log("no region selected") }
2148 }
2149 }
2150
2151 function regionsMatch(reg1, reg2) {
2152 if (reg1 && reg2 && reg1.start == reg2.start && reg1.end == reg2.end) return true;
2153 return false;
2154 }
2155
2156 function isCurrentRegion(region) {
2157 if (regionsMatch(currentRegion, region)) return true;
2158 return false;
2159 }
2160
2161 function isInCurrentRegions(region) {
2162 if (currentRegions != []) {
2163 for (let i = 0; i < currentRegions.length; i++) {
2164 if (currentRegions[i].start == region.start && currentRegions[i].end == region.end) {
2165 return true;
2166 }
2167 }
2168 }
2169 return false;
2170 }
2171
2172 function getCurrentRegionIndex() { // returns the index of currently selected region
2173 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
2174 if (isCurrentRegion(currSpeakerSet.tempSpeakerObjects[i].region)) { return i }
2175 }
2176 return -1;
2177 }
2178
2179 function getCurrentRegionsIndexes() { // returns the indexes of currently selected regions
2180 let indexes = [];
2181 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
2182 if (isInCurrentRegions(currSpeakerSet.tempSpeakerObjects[i].region)) { indexes.push(i) }
2183 }
2184 return indexes;
2185 }
2186
2187 function removeCurrentRegion() { // removes current region, regions and bound markers
2188 currentRegion = {speaker: '', start: '', end: ''};
2189 currentRegions = [];
2190 removeRegionBounds();
2191 hoverSpeaker.innerHTML = "";
2192 }
2193
2194 function getRegionsWithSpeaker(speaker) { // returns all regions with the given speaker name
2195 let out = [];
2196 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
2197 if (currSpeakerSet.tempSpeakerObjects[i].speaker === speaker) { out.push(currSpeakerSet.tempSpeakerObjects[i]) }
2198 }
2199 return out;
2200 }
2201
2202 function sortSpeakerObjectsByStart(speakerOb) { // sorts the speaker object array by start time
2203 return speakerOb.sort(function(a,b) {
2204 return a.start - b.start;
2205 });
2206 }
2207
2208 /**
2209 * Changes the associated speaker name of a region, updating the speaker set
2210 */
2211 function speakerChange() {
2212 const newSpeaker = speakerInput.value;
2213 clearChapterSearch();
2214 if (newSpeaker && newSpeaker.trim() != "") {
2215 speakerInput.style.outline = "2px solid transparent";
2216 if (getCurrentRegionIndex() != -1) { // if a region is selected
2217 const chaps = chapters.childNodes;
2218 if (!currSpeakerSet.uniqueSpeakers.includes(newSpeaker)) { currSpeakerSet.uniqueSpeakers.push(newSpeaker) }
2219 if (currentRegions && currentRegions.length < 1) { // single change
2220 currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].speaker = newSpeaker; // update corrosponding speakerObject speaker
2221 currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.attributes.label.innerText = newSpeaker;
2222 chaps[getCurrentRegionIndex()].firstChild.textContent = newSpeaker; // update chapter text
2223 } else if (currentRegions && currentRegions.length > 1) { // multiple changes
2224 for (idx of getCurrentRegionsIndexes()) {
2225 currSpeakerSet.tempSpeakerObjects[idx].speaker = newSpeaker;
2226 currSpeakerSet.tempSpeakerObjects[idx].region.attributes.label.innerText = newSpeaker;
2227 chaps[idx].firstChild.textContent = newSpeaker;
2228 }
2229 }
2230 currentRegion.speaker = newSpeaker;
2231 chapterLeave(getCurrentRegionIndex()); // update region bound text
2232 editsMade = true;
2233 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "speaker-change", getCurrentRegionIndex(), getCurrentRegionsIndexes());
2234 editLockedRegion(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()]);
2235 } else { console.log("no region selected") }
2236 } else { console.log("no text in speaker input"); speakerInput.style.outline = "2px solid firebrick"; }
2237 }
2238
2239 function speakerInputUnfocused() {
2240 prevUndoState = "";
2241 if (speakerInput.value == "" && !speakerInput.classList.contains("disabled")) {
2242 speakerInput.style.outline = "2px solid firebrick";
2243 window.alert("Speaker input cannot be left empty. Please enter a speaker name.");
2244 setTimeout(() => speakerInput.focus(), 10); // timeout needed otherwise input isn't selected
2245 } else speakerInput.style.outline = "2px transparent";
2246 }
2247
2248 /**
2249 * Selects all (or reverts select-all) regions matching any of the currently selected speaker names
2250 * @param {boolean} skipUndoState Whether or not to skip the addition of an undo state
2251 */
2252 function selectAllCheckboxChanged(skipUndoState) { // "Change all" toggled
2253 if (changeAllCheckbox.checked) {
2254 if (!isZooming) {
2255 tempZoomSave = zoomSlider.value;
2256 zoomTo(0); // zoom out to encompass all selected regions
2257 }
2258 let uniqueSelectedSpeakers;
2259 if (currentRegions && currentRegions.length > 0) { // if more than one region selected
2260 uniqueSelectedSpeakers = [... new Set(currentRegions.map(a => a.speaker))]; // gets unique speakers in currentRegions
2261 uniqueSelectedSpeakers.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
2262 } else uniqueSelectedSpeakers = [currentRegion.speaker];
2263 currentRegions = [];
2264 for (const speaker of uniqueSelectedSpeakers) {
2265 for (const region of getRegionsWithSpeaker(speaker)) {
2266 currentRegions.push(region);
2267 region.region.update({color: "rgba(255,50,50,0.5)"});
2268 }
2269 }
2270 } else {
2271 if (!isZooming) {
2272 zoomTo(tempZoomSave / 4); // zoom back in to previous level
2273 }
2274 currentRegions = []; // this will lose track of previously selected region*s*
2275 }
2276 reloadRegionsAndChapters();
2277 if (!skipUndoState) addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "selectAllChange", getCurrentRegionIndex(), getCurrentRegionsIndexes());
2278 }
2279
2280 function enableStartEndInputs() { // removes the 'disabled' tag from all time inputs
2281 for (idx in startTimeInput.childNodes) { startTimeInput.childNodes[idx].disabled = false }
2282 for (idx in endTimeInput.childNodes) { endTimeInput.childNodes[idx].disabled = false }
2283 }
2284
2285 function disableStartEndInputs() { // adds the 'disabled' tag to all time inputs
2286 for (idx in startTimeInput.childNodes) { startTimeInput.childNodes[idx].value = 0; startTimeInput.childNodes[idx].disabled = true; }
2287 for (idx in endTimeInput.childNodes) { endTimeInput.childNodes[idx].value = 0; endTimeInput.childNodes[idx].disabled = true; }
2288 }
2289
2290 /**
2291 * Zooms wavesurfer waveform to destination zoom level, used in select all function
2292 * @param {number} dest Destination zoom level
2293 */
2294 function zoomTo(dest) {
2295 isZooming = true;
2296 changeAllCheckbox.disabled = true;
2297 let isOut = false;
2298 if (dest == 0) isOut = true;
2299 zoomInterval = setInterval(() => {
2300 if (isOut) {
2301 if (zoomSlider.value != 0) {
2302 if (zoomSlider.value > 50) zoomSlider.value -= 30; // ramp up for finer adjustments
2303 else zoomSlider.stepDown();
2304 wavesurfer.zoom(zoomSlider.value / 4);
2305 } else {
2306 clearInterval(zoomInterval);
2307 isZooming = false;
2308 changeAllCheckbox.disabled = false;
2309 zoomSlider.dispatchEvent(new Event("input"));
2310 }
2311 } else {
2312 if (zoomSlider.value / 4 < dest) {
2313 if (zoomSlider.value > 50) zoomSlider.value += 30; // ramp up for finer adjustments
2314 else zoomSlider.stepUp();
2315 wavesurfer.zoom(zoomSlider.value / 4);
2316 } else {
2317 clearInterval(zoomInterval);
2318 isZooming = false;
2319 changeAllCheckbox.disabled = false;
2320 zoomSlider.dispatchEvent(new Event("input"));
2321 }
2322 }
2323 }, 10); // 10ms interval
2324 }
2325
2326 function toggleSavePopup() { // shows / hides commit popup div
2327 savePopupCommitMsg.value = savePopupCommitMsg.value.trim(); // clears initial whitespace caused by <xsl: text>
2328 if (savePopup.classList.contains("visible")) {
2329 savePopup.classList.remove("visible");
2330 savePopupBG.classList.remove("visible");
2331 } else {
2332 savePopup.classList.add("visible");
2333 savePopupBG.classList.add("visible");
2334 savePopup.children[0].innerText = "Commit changes for: " + selectedVersions[(!dualMode || primaryCaret.src.includes("fill")) ? 0 : 1];
2335 }
2336 }
2337
2338 function saveRegionChanges() { // saves tempSpeakerObjects to speakerObjects
2339 if (!saveButton.classList.contains("disabled")) {
2340 toggleSavePopup();
2341 // old save functionality
2342 // currSpeakerSet.speakerObjects = cloneSpeakerObjectArray(currSpeakerSet.tempSpeakerObjects);
2343 // editsMade = false;
2344 // removeCurrentRegion();
2345 // reloadRegionsAndChapters();
2346 // console.log("saved changes.");
2347 }
2348 }
2349
2350 /**
2351 * Commits changes made to the currently selected set to Greenstone's version history system.
2352 * Firstly increments FLDV, then saves commit message to document's metadata, then sets document's
2353 * associated file to tempSpeakerObjects CSV.
2354 */
2355 function commitChanges() {
2356 if (savePopupCommitMsg.value && savePopupCommitMsg.value.length > 0) {
2357 console.log('committing with message: ' + savePopupCommitMsg.value);
2358 // inc fldv_history
2359 $.ajax({
2360 type: "GET",
2361 url: mod_meta_base_url,
2362 data: { "o": "json", "s1.a": "inc-fldv-nminus1" }
2363 }).then((out) => {
2364 console.log('fldv inc success with status code: ' + out.page.pageResponse.status.code);
2365 if (out.page.pageResponse.status.code == GSSTATUS_SUCCESS) {
2366 ajaxSetCommitMeta();
2367 }
2368 }, (error) => { console.log("inc-fldv-nminus1 error:\n" + error) });
2369 toggleSavePopup();
2370 } else {
2371 window.alert("Commit message cannot be left empty.");
2372 }
2373 }
2374
2375 function ajaxSetCommitMeta() { // saves commit message to current document's metadata
2376 $.ajax({
2377 type: "GET",
2378 url: mod_meta_base_url,
2379 data: { "o" : "json", "s1.a": "set-archives-metadata", "s1.metaname": "commitmessage", "s1.metavalue": savePopupCommitMsg.value.trim(), "s1.metamode": "override" },
2380 }).then((out) => {
2381 console.log('commit success with status code: ' + out.page.pageResponse.status.code);
2382 if (out.page.pageResponse.status.code == GSSTATUS_SUCCESS) {
2383 ajaxSetAssocFile();
2384 }
2385 }, (error) => { console.log("commit_msg_url error:"); console.log(error); });
2386 }
2387
2388 function ajaxSetAssocFile() { // sets current document's associated file to tempSpeakerObjects
2389 $.ajax({
2390 type: "POST",
2391 url: gs.xsltParams.library_name,
2392 data: { "o" : "json", "a": "g", "rt": "r", "ro": "0", "s": "ModifyMetadata", "s1.collection": gs.cgiParams.c, "s1.site": gs.xsltParams.site_name, "s1.d": gs.cgiParams.d,
2393 "s1.a": "set-archives-assocfile", "s1.assocname": "structured-audio.csv", "s1.filedata": speakerObjToCSVText() },
2394 }).then((out) => {
2395 console.log('set-archives-assocfile success with status code: ' + out.page.pageResponse.status.code);
2396 resetUndoStates();
2397 }, (error) => { console.log("set_assoc_url error:"); console.log(error); });
2398 }
2399
2400 function speakerObjToCSVText() { // converts tempSpeakerObjects to csv-like string
2401 console.log(currSpeakerSet.tempSpeakerObjects.map(item => [item.speaker, item.start, item.end, item.locked]).join('\n'));
2402 return currSpeakerSet.tempSpeakerObjects.map(item => [item.speaker, item.start, item.end, item.locked]).join('\n');
2403 }
2404
2405 function discardRegionChanges(forceDiscard) { // resets tempSpeakerObjects to speakerObjects
2406 if (!discardButton.classList.contains("disabled") || forceDiscard) {
2407 let confirm = false;
2408 if (!forceDiscard) { confirm = window.confirm("Are you sure you want to discard changes?"); }
2409 if (confirm || forceDiscard) {
2410 currSpeakerSet.tempSpeakerObjects = cloneSpeakerObjectArray(currSpeakerSet.speakerObjects);
2411 editsMade = false;
2412 removeCurrentRegion();
2413 resetUndoStates();
2414 reloadRegionsAndChapters();
2415 console.log("discarded changes");
2416 }
2417 }
2418 }
2419
2420 /**
2421 * Redraws edit panel, chapter list and wavesurfer regions from speaker set
2422 */
2423 function reloadRegionsAndChapters() { // redraws edit panel, chapter list, wavesurfer regions
2424 updateRegionEditPanel();
2425 $(".region-top").remove();
2426 $(".region-bottom").remove();
2427 $(".wavesurfer-region").remove();
2428 populateChaptersAndRegions(primarySet);
2429 if (dualMode) {
2430 populateChaptersAndRegions(secondarySet);
2431 currSpeakerSet = primarySet;
2432 }
2433 updateCurrSpeakerSet();
2434 if (editMode && currentRegion && currentRegion.speaker && getCurrentRegionIndex() != -1 && currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element) {
2435 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
2436 drawCurrentRegionBounds();
2437 }
2438 if (currentRegions.length < 1) {
2439 removeButton.innerHTML = "Remove Selected Region";
2440 // enableStartEndInputs();
2441 } else {
2442 removeButton.innerHTML = "Remove Selected Regions (x" + currentRegions.length + ")";
2443 const uniqueSelectedSpeakers = [... new Set(currentRegions.map(a => a.speaker))]; // gets unique speakers in currentRegions
2444 uniqueSelectedSpeakers.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
2445 speakerInput.value = uniqueSelectedSpeakers.join(", ");
2446 }
2447 }
2448
2449 /**
2450 * Handles the change of a region's start or end time, updating hte speaker set
2451 */
2452 function changeStartEndTime(e) { // start/end time input handler
2453 let newStart = getTimeInSecondsFromInput(startTimeInput);
2454 let newEnd = getTimeInSecondsFromInput(endTimeInput);
2455 let duration = Math.floor(wavesurfer.getDuration()); // total duration of current audio
2456 if (getCurrentRegionIndex() != -1) { // if there is a selected region
2457 if (newEnd <= newStart) newStart = newEnd - 1; // when start time > end time, push region forward
2458 if (newEnd <= 0) newEnd = 1;
2459 if (newStart < 0) newStart = 0; // ensures region start doesn't go < 0s
2460 if (newEnd > duration) newEnd = duration; // ensures region start doesn't go > duration
2461
2462 setInputInSeconds(startTimeInput, newStart);
2463 setInputInSeconds(endTimeInput, newEnd);
2464
2465 let currRegIdx = getCurrentRegionIndex();
2466 currSpeakerSet.tempSpeakerObjects[currRegIdx].start = newStart;
2467 currSpeakerSet.tempSpeakerObjects[currRegIdx].end = newEnd;
2468 currSpeakerSet.tempSpeakerObjects[currRegIdx].region.update({start: newStart, end: newEnd});
2469 currentRegion.start = newStart;
2470 currentRegion.end = newEnd;
2471 editsMade = true;
2472 handleSameSpeakerOverlap(currRegIdx, currSpeakerSet);
2473 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "change-time", getCurrentRegionIndex());
2474 editLockedRegion(currSpeakerSet.tempSpeakerObjects[currRegIdx]);
2475 } else {
2476 console.log("no region selected");
2477 setInputInSeconds(startTimeInput, 0);
2478 setInputInSeconds(endTimeInput, 0);
2479 }
2480 }
2481
2482 /**
2483 * Calculates time in seconds of start or end time input group
2484 * @param {element} input Element of time input groups: hh:mm:ss
2485 * @returns {int} Time in seconds
2486 */
2487 function getTimeInSecondsFromInput(input) {
2488 let hours = input.children[0].valueAsNumber;
2489 let mins = input.children[1].valueAsNumber;
2490 let secs = input.children[2].valueAsNumber;
2491 return (hours * 3600) + (mins * 60) + secs;
2492 }
2493
2494 /**
2495 * Sets the start or end time element group inputs
2496 * @param {element} input Element of time input group to be updated
2497 * @param {int} seconds Duration in seconds to be converted into hh:mm:ss
2498 */
2499 function setInputInSeconds(input, seconds) { // sets start or end input time when given seconds
2500 let date = new Date(null);
2501 date.setMilliseconds(seconds * 1000);
2502 input.children[0].value = date.getHours() % 12;
2503 input.children[1].value = date.getMinutes();
2504 input.children[2].value = date.getSeconds() + "." + (Math.ceil(date.getMilliseconds() / 100) * 100);
2505 document.querySelectorAll('input[type=number]').forEach(e => {
2506 e.value = Math.round(e.valueAsNumber * 10) / 10; // to 1dp
2507 if (e.classList.contains("seconds") && !e.value.includes(".")) { e.value = e.value + ".0"; }
2508 else if (e.value.length === 1){ e.value = '0' + e.value; }// 0 padded on left
2509 });
2510 }
2511
2512 /**
2513 * Adds a new undo state to the global undo state list
2514 * @param {object} state Primary set at current state
2515 * @param {object} secState Secondary set at current state
2516 * @param {boolean} isSec Whether or not current change was made to primary (false) or secondary (true) set
2517 * @param {boolean} dualMode Whether or not audio editor was in dual mode when undo state was added
2518 * @param {string} type Type of change e.g "remove", "speaker-change"
2519 * @param {int} currRegIdx Index of currently selected region (for restoration)
2520 * @param {Array} currRegIdxs Index of currently selected regions, if applicable (for restoration)
2521 */
2522 function addUndoState(state, secState, isSec, dualMode, type, currRegIdx, currRegIdxs) { // adds a new state to the undoStates stack
2523 let newState = cloneSpeakerObjectArray(state.tempSpeakerObjects); // clone method removes references
2524 let newSecState = cloneSpeakerObjectArray(secState.tempSpeakerObjects); // clone method removes references
2525 let changedTrack = (type == "dualModeChange" || type == "selectAllChange") ? "none" : selectedVersions[isSec ? 1 : 0] // sets changedTrack to version name of edited region set
2526 undoButton.classList.remove("disabled");
2527 undoStates = undoStates.slice(0, undoLevel + 1); // trim to current level if undos have already been made
2528 undoStates.push({state: newState, secState: newSecState, isSec: isSec, changedTrack: changedTrack, dualMode: dualMode, currentRegionIndex: currRegIdx, currentRegionIndexes: currRegIdxs, type: type});
2529 if ((type === "change-time" && prevUndoState === "change-time") || (type === "speaker-change" && prevUndoState === "speaker-change")) { // checks if similar change was made previously
2530 undoStates.splice(-2, 1); // remove second-to-last item in undoStates stack (merge last two changes into one to avoid multiple small edits)
2531 prevUndoState = type;
2532 } else undoLevel++;
2533 prevUndoState = type;
2534 redoButton.classList.add("disabled");
2535 for (const item of undoStates) { // remove cyclic object references
2536 item.state = cloneSpeakerObjectArray(item.state);
2537 item.secState = cloneSpeakerObjectArray(item.secState);
2538 }
2539 localStorage.setItem('undoStates', JSON.stringify(undoStates)); // update localStorage items
2540 localStorage.setItem('undoLevel', undoLevel);
2541 }
2542
2543 /**
2544 * Returns to the previous state in the undo state list
2545 */
2546 function undo() {
2547 if (!undoButton.classList.contains("disabled") && editMode) { // ensure there exist states to undo to
2548 clearChapterSearch();
2549 if (undoLevel - 1 < 0) console.log("ran out of undos");
2550 else {
2551 removeCurrentRegion();
2552 let adjustedUndoLevel = undoLevel-1;
2553 if (undoStates[undoLevel].type == "dualModeChange") { // toggle dual mode
2554 dualModeChanged(true);
2555 } else if (undoStates[undoLevel].type == "selectAllChange") { // toggle select all
2556 changeAllCheckbox.checked = !changeAllCheckbox.checked;
2557 selectAllCheckboxChanged(true);
2558 } else {
2559 primarySet.tempSpeakerObjects = cloneSpeakerObjectArray(undoStates[adjustedUndoLevel].state.slice(0)); // slice & clone removes potential references between arrays
2560 if (dualMode && undoStates[adjustedUndoLevel].secState && undoStates[adjustedUndoLevel].secState.length > 0) { // if secondary undoState exists
2561 secondarySet.tempSpeakerObjects = cloneSpeakerObjectArray(undoStates[adjustedUndoLevel].secState.slice(0)); // slice & clone removes potential references between arrays
2562 }
2563 let selectedSpeakerSet;
2564 // handle currentRegion change
2565 if (undoStates[undoLevel] && undoStates[undoLevel].type && undoStates[undoLevel].type == "remove") { // if destination state type is remove
2566 selectedSpeakerSet = (undoStates[undoLevel].isSec) ? secondarySet : primarySet;
2567 if (selectedSpeakerSet.isSecondary) caretClicked("secondary-caret");
2568 else caretClicked("primary-caret");
2569 currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel].currentRegionIndex]; // restore previous current state
2570 } else if (undoStates[undoLevel].currentRegionIndex) {
2571 if (!dualMode) selectedSpeakerSet = primarySet;
2572 else {
2573 selectedSpeakerSet = (undoStates[undoLevel-1].isSec) ? secondarySet : primarySet;
2574 if (selectedSpeakerSet.isSecondary) caretClicked("secondary-caret");
2575 else caretClicked("primary-caret");
2576 }
2577 currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel].currentRegionIndex];
2578 }
2579 // handle currentRegions restoration
2580 if (undoStates[undoLevel].currentRegionIndexes && undoStates[undoLevel].currentRegionIndexes.length > 1) {
2581 for (const idx of undoStates[undoLevel].currentRegionIndexes) currentRegions.push(currSpeakerSet.tempSpeakerObjects[idx]);
2582 }
2583 }
2584 editsMade = true;
2585 undoLevel--; // decrement undoLevel
2586 reloadRegionsAndChapters();
2587 localStorage.setItem('undoLevel', undoLevel);
2588 if (undoLevel - 1 < 0) undoButton.classList.add("disabled");
2589 else undoButton.classList.remove("disabled");
2590 }
2591 if (undoLevel < undoStates.length) redoButton.classList.remove("disabled");
2592 }
2593 }
2594
2595 /**
2596 * Moves forward one state in the undo state list
2597 */
2598 function redo() {
2599 if (!redoButton.classList.contains("disabled") && editMode) { // ensure there exist states to redo to
2600 clearChapterSearch();
2601 if (undoLevel + 1 >= undoStates.length) console.log("ran out of redos");
2602 else {
2603 if (undoStates[undoLevel+1].type == "dualModeChange") { // toggle dual mode
2604 dualModeChanged(true);
2605 } else if (undoStates[undoLevel+1].type == "selectAllChange") { // toggle select all
2606 changeAllCheckbox.checked = !changeAllCheckbox.checked;
2607 selectAllCheckboxChanged(true);
2608 } else {
2609 primarySet.tempSpeakerObjects = cloneSpeakerObjectArray(undoStates[undoLevel+1].state.slice(0)); // set primary to new state
2610 secondarySet.tempSpeakerObjects = cloneSpeakerObjectArray(undoStates[undoLevel+1].secState.slice(0)); // set secondary to new state
2611 let selectedSpeakerSet;
2612 // handle currentRegion change
2613 removeCurrentRegion();
2614 if (undoLevel+2 < undoStates.length) {
2615 if (undoStates[undoLevel+2] && undoStates[undoLevel+2].type && undoStates[undoLevel+2].type == "remove") {
2616 selectedSpeakerSet = (undoStates[undoLevel+2].isSec) ? secondarySet : primarySet;
2617 if (selectedSpeakerSet.isSecondary) caretClicked("secondary-caret");
2618 else caretClicked("primary-caret");
2619 currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel+2].currentRegionIndex];
2620 } else {
2621 selectedSpeakerSet = (undoStates[undoLevel+1].isSec) ? secondarySet : primarySet;
2622 if (selectedSpeakerSet.isSecondary) caretClicked("secondary-caret");
2623 else caretClicked("primary-caret");
2624 currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel+1].currentRegionIndex];
2625 }
2626 if (undoStates[undoLevel+1].currentRegionIndexes && undoStates[undoLevel+1].currentRegionIndexes.length > 1) {
2627 for (const idx of undoStates[undoLevel+1].currentRegionIndexes) currentRegions.push(currSpeakerSet.tempSpeakerObjects[idx]);
2628 }
2629 }
2630 }
2631 editsMade = true;
2632 reloadRegionsAndChapters();
2633 undoLevel++; // increment undoLevel
2634 localStorage.setItem('undoLevel', undoLevel);
2635 if (undoLevel + 1 > undoStates.length - 1) redoButton.classList.add("disabled");
2636 else redoButton.classList.remove("disabled");
2637 }
2638 if (undoLevel < undoStates.length) undoButton.classList.remove("disabled");
2639 }
2640 }
2641
2642 function resetUndoStates() { // clear undo history
2643 undoStates = [{state: cloneSpeakerObjectArray(primarySet.tempSpeakerObjects), secState: cloneSpeakerObjectArray(secondarySet.tempSpeakerObjects)}];
2644 undoLevel = 0;
2645 localStorage.removeItem('undoLevel');
2646 localStorage.removeItem('undoStates');
2647 undoButton.classList.add("disabled");
2648 redoButton.classList.add("disabled");
2649 }
2650
2651 function waveformScrolled() { // waveform scroll handler
2652 if (currentRegion.speaker && getCurrentRegionIndex() != -1) { // updates region bound markers if selected region exists
2653 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
2654 drawCurrentRegionBounds();
2655 }
2656 if (document.getElementById('new-canvas')) { document.getElementById('new-canvas').style.left = "-" + wave.scrollLeft + 'px' } // update placeholder waveform scroll position
2657 }
2658
2659 function drawCurrentRegionBounds() { // draws bounds of current region
2660 removeRegionBounds();
2661 let currIndexes = getCurrentRegionsIndexes();
2662 if (getCurrentRegionIndex() != -1) drawRegionBounds(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region, wave.scrollLeft, "FireBrick");
2663 for (let i = 0; i < currIndexes.length; i++) {
2664 drawRegionBounds(currSpeakerSet.tempSpeakerObjects[currIndexes[i]].region, wave.scrollLeft, "FireBrick");
2665 }
2666 }
2667
2668 /**
2669 * Draws bounding 'n' above hovered or selected region
2670 * @param {object} region Region to have bound drawn for
2671 * @param {number} scrollPos Scroll position of div, used to offset draw position
2672 * @param {string} colour Colour to draw bound (black and FireBrick are used)
2673 */
2674 function drawRegionBounds(region, scrollPos, colour) { // draws on canvas to show bounds of hovered/selected region
2675 const hoverSpeakerCanvas = document.createElement("canvas");
2676 hoverSpeakerCanvas.id = "hover-speaker-canvas";
2677 hoverSpeakerCanvas.classList.add("region-bounds");
2678 hoverSpeakerCanvas.width = audioContainer.clientWidth; // max width of drawn bounds
2679 const ctx = hoverSpeakerCanvas.getContext("2d");
2680 // ctx.translate(0.5, 0.5); // fixes lineWidth inconsistency
2681 ctx.lineWidth = 1;
2682 if (colour == "FireBrick") ctx.lineWidth = 3;
2683 if (currentRegions && currentRegions.length < 1 && isCurrentRegion(region) && editMode) {
2684 colour = "FireBrick";
2685 ctx.lineWidth = 3;
2686 }
2687 ctx.strokeStyle = colour;
2688 ctx.beginPath();
2689 ctx.moveTo(parseInt(region.element.style.left.slice(0, -2)) - scrollPos, 28);
2690 ctx.lineTo(parseInt(region.element.style.left.slice(0, -2)) - scrollPos, 20);
2691 ctx.lineTo(parseInt(region.element.style.left.slice(0, -2)) + parseInt(region.element.style.width.slice(0, -2)) - scrollPos, 20);
2692 ctx.lineTo(parseInt(region.element.style.left.slice(0, -2)) + parseInt(region.element.style.width.slice(0, -2)) - scrollPos, 28);
2693 ctx.stroke();
2694 audioContainer.prepend(hoverSpeakerCanvas);
2695 }
2696
2697 function removeRegionBounds() { // remove all region bound markers
2698 let canvases = document.getElementsByClassName('region-bounds');
2699 while (canvases[0]) canvases[0].parentNode.removeChild(canvases[0]);
2700 }
2701
2702 function updateCurrSpeakerSet() { // updates 'currSpeakerSet' var
2703 if (primaryCaret.src.includes("fill")) currSpeakerSet = primarySet;
2704 else if (secondaryCaret.src.includes("fill")) currSpeakerSet = secondarySet;
2705 }
2706
2707 function cloneSpeakerObjectArray(inputArray) { // clones speakerObjectArray without references (wavesurfer regions)
2708 let output = [];
2709 for (let i = 0; i < inputArray.length; i++) {
2710 output.push({ speaker: inputArray[i].speaker, start: inputArray[i].start, end: inputArray[i].end, locked: (inputArray[i].locked === "true" || inputArray[i].locked === true) });
2711 }
2712 return output;
2713 }
2714
2715 function flashChapters() { // flashes chapters a lighter colour momentarily to indicate an update/change
2716 chapters.style.backgroundColor = "rgb(66, 84, 88)";
2717 setTimeout(() => { chapters.style.backgroundColor = "rgb(40, 54, 58)" }, 500);
2718 }
2719
2720 /** Fullscreen onChange handler, increases waveform height & adjusts padding/margin */
2721 function fullscreenChanged() {
2722 if (!audioContainer.classList.contains("fullscreen")) {
2723 audioContainer.classList.add("fullscreen");
2724 wavesurfer.setHeight(175); // increase waveform height
2725 caretContainer.style.paddingLeft = "2rem";
2726 caretContainer.style.height = wavesurfer.getHeight() + "px"; // set height to waveform height
2727 audioContainer.prepend(caretContainer); // attach to audioContainer (otherwise doesn't show due to AC being fullscreen)
2728 } else {
2729 audioContainer.classList.remove("fullscreen");
2730 wavesurfer.setHeight(140);
2731 caretContainer.style.paddingLeft = "0";
2732 caretContainer.style.height = wavesurfer.getHeight() + "px";
2733 audioContainer.parentElement.prepend(caretContainer); // move back up in DOM hierarchy
2734 }
2735 setTimeout(() => { // ensures waveform shows
2736 zoomOutButton.click();
2737 zoomInButton.click();
2738 }, 250);
2739 }
2740
2741 /** Enables / disables the fullscreen view of audio player / editor */
2742 function toggleFullscreen() {
2743 if ((document.fullscreenElement && document.fullscreenElement !== null) ||
2744 (document.webkitFullscreenElement && document.webkitFullscreenElement !== null) ||
2745 (document.mozFullScreenElement && document.mozFullScreenElement !== null) ||
2746 (document.msFullscreenElement && document.msFullscreenElement !== null)) {
2747 document.exitFullscreen();
2748 } else {
2749 if (audioContainer.requestFullscreen) {
2750 audioContainer.requestFullscreen();
2751 } else if (audioContainer.webkitRequestFullscreen) { /* Safari */
2752 audioContainer.webkitRequestFullscreen();
2753 } else if (audioContainer.msRequestFullscreen) { /* IE11 */
2754 audioContainer.msRequestFullscreen();
2755 }
2756 }
2757 }
2758}
2759
2760/**
2761 * Formats seconds to hh:mm:ss
2762 * @param {number} duration
2763 * @returns {string} Time in hh:mm:ss format
2764 */
2765function formatAudioDuration(duration) {
2766 // console.log('duration: ' + duration);
2767 let [hrs, mins, secs, ms] = duration.replace(".", ":").split(":");
2768 return hrs + ":" + mins + ":" + secs;
2769}
Note: See TracBrowser for help on using the repository browser.