From 18746335a2a1c8997fbab291582dcaaa33233929 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 18 Sep 2024 19:06:50 +0200 Subject: [PATCH 1/3] fix for strikeout selection issue --- index.html | 100 ++++- js/hyperaudio-lite.js | 971 +++++++++++++++++++----------------------- 2 files changed, 548 insertions(+), 523 deletions(-) diff --git a/index.html b/index.html index b5b3bce..b14d040 100644 --- a/index.html +++ b/index.html @@ -1236,6 +1236,98 @@

Load from Local Storage

document.querySelector('#strikethrough').addEventListener('click', (e) => { + audioDataArray = []; + let transcript = document.querySelector("#hypertranscript"); + + const selection = window.getSelection(); + if (!selection.rangeCount) return; + + const range = selection.getRangeAt(0); + let startNode = range.startContainer; + let endNode = range.endContainer; + + // Function to find the parent paragraph + function findParentParagraph(node) { + while (node && node.nodeName !== 'P') { + node = node.parentNode; + } + return node; + } + + // Find the parent paragraph of the start and end nodes + let startParagraph = findParentParagraph(startNode); + let endParagraph = findParentParagraph(endNode); + + if (!startParagraph || !endParagraph) return; + + // Function to apply strikethrough to a single element + function strikeoutElement(element) { + if (element.nodeType === Node.ELEMENT_NODE && !element.style.textDecoration.includes('line-through')) { + element.style.textDecoration = 'line-through'; + } + } + + // Function to find the specific child element containing a node + function findChildElementContainingNode(parent, node) { + for (let child of parent.children) { + if (child.contains(node)) { + return child; + } + } + return null; + } + + // Check if the selection ends at the very beginning of the end paragraph + const isEndAtParagraphStart = endNode === endParagraph && range.endOffset === 0; + + // Apply strikethrough to selected elements + let currentParagraph = startParagraph; + let inSelection = false; + + while (currentParagraph) { + let startElement = currentParagraph === startParagraph ? findChildElementContainingNode(currentParagraph, startNode) : null; + let endElement = currentParagraph === endParagraph ? findChildElementContainingNode(currentParagraph, endNode) : null; + + // Skip the last paragraph if the selection ends at its start + if (currentParagraph === endParagraph && isEndAtParagraphStart) { + break; + } + + Array.from(currentParagraph.children).forEach(child => { + if (child === startElement || inSelection) { + inSelection = true; + strikeoutElement(child); + } + + if (child === endElement) { + // Only strikeout the end element if it's not at the very start of the paragraph + if (!(currentParagraph === endParagraph && range.endOffset === 0)) { + strikeoutElement(child); + } + inSelection = false; + return; // Exit the forEach loop + } + }); + + if (currentParagraph === endParagraph) break; + currentParagraph = currentParagraph.nextElementSibling; + inSelection = true; // For paragraphs between start and end + } + + // Clear the selection + selection.removeAllRanges(); + + // detect the ranges to be cut + audioDataArray = createArrayOfGaps(transcript); + + // skip the text that is struck thru + skipStrikeThrus(audioDataArray); +}); + + + + /*document.querySelector('#strikethrough').addEventListener('click', (e) => { + audioDataArray = []; let transcript = document.querySelector("#hypertranscript"); let selection = null; @@ -1291,6 +1383,8 @@

Load from Local Storage

let allSelectionStruckThrough = true; + console.log(endNode); + if (endNode.style.textDecoration !== "line-through" || startNode.style.textDecoration !== "line-through") { allSelectionStruckThrough = false; } @@ -1318,6 +1412,10 @@

Load from Local Storage

while (startNode !== endNode && nextNode !== endNode){ nextNode.style.setProperty("text-decoration", textDecoration); + + console.log(nextNode); + console.log(endNode); + console.log(nextNode.nextElementSibling); // check not end of paragraph if (nextNode.nextElementSibling !== null) { @@ -1335,7 +1433,7 @@

Load from Local Storage

// skip the text that is struck thru skipStrikeThrus(audioDataArray); - }); + });*/ registerStrikeThrus(); diff --git a/js/hyperaudio-lite.js b/js/hyperaudio-lite.js index 67f1849..fbef82f 100644 --- a/js/hyperaudio-lite.js +++ b/js/hyperaudio-lite.js @@ -1,514 +1,519 @@ /*! (C) The Hyperaudio Project. MIT @license: en.wikipedia.org/wiki/MIT_License. */ -/*! Version 2.1.8 */ +/*! Version 2.3.1 */ 'use strict'; -function nativePlayer(instance) { - this.player = instance.player; - this.player.addEventListener('pause', instance.pausePlayHead, false); - this.player.addEventListener('play', instance.preparePlayHead, false); - this.paused = true; +// Base player class to handle common player functionality +class BasePlayer { + constructor(instance) { + this.player = this.initPlayer(instance); // Initialize the player + this.paused = true; // Set initial paused state + if (this.player) { + this.attachEventListeners(instance); // Attach event listeners for play and pause + } + } - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.player.currentTime); - }); + // Method to initialize the player - to be implemented by subclasses + initPlayer(instance) { + throw new Error('initPlayer method should be implemented by subclasses'); + } + + // Method to attach common event listeners + attachEventListeners(instance) { + this.player.addEventListener('pause', instance.pausePlayHead.bind(instance), false); + this.player.addEventListener('play', instance.preparePlayHead.bind(instance), false); + } + + // Method to get the current time of the player + getTime() { + return Promise.resolve(this.player.currentTime); } - this.setTime = (seconds) => { + // Method to set the current time of the player + setTime(seconds) { this.player.currentTime = seconds; } - this.play = () => { + // Method to play the media + play() { this.player.play(); this.paused = false; } - this.pause = () => { + // Method to pause the media + pause() { this.player.pause(); this.paused = true; } } -function soundcloudPlayer(instance) { - this.player = SC.Widget(instance.player.id); - this.player.bind(SC.Widget.Events.PAUSE, instance.pausePlayHead); - this.player.bind(SC.Widget.Events.PLAY, instance.preparePlayHead); - this.paused = true; - - this.getTime = () => { - return new Promise((resolve) => { - this.player.getPosition(ms => { - resolve(ms / 1000); - }); - }); +// Class for native HTML5 player +class NativePlayer extends BasePlayer { + // Initialize the native HTML5 player + initPlayer(instance) { + return instance.player; } +} - this.setTime = (seconds) => { - this.player.seekTo(seconds * 1000); +// Class for SoundCloud player +class SoundCloudPlayer extends BasePlayer { + // Initialize the SoundCloud player + initPlayer(instance) { + return SC.Widget(instance.player.id); } - this.play = () => { - this.player.play(); - this.paused = false; + // Attach event listeners specific to SoundCloud player + attachEventListeners(instance) { + this.player.bind(SC.Widget.Events.PAUSE, instance.pausePlayHead.bind(instance)); + this.player.bind(SC.Widget.Events.PLAY, instance.preparePlayHead.bind(instance)); } - this.pause = () => { - this.player.pause(); - this.paused = true; + // Get the current time of the SoundCloud player + getTime() { + return new Promise(resolve => { + this.player.getPosition(ms => resolve(ms / 1000)); + }); } -} - -function videojsPlayer(instance) { - this.player = videojs.getPlayer(instance.player.id); - this.player.addEventListener('pause', instance.pausePlayHead, false); - this.player.addEventListener('play', instance.preparePlayHead, false); - this.paused = true; - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.player.currentTime()); - }); + // Set the current time of the SoundCloud player + setTime(seconds) { + this.player.seekTo(seconds * 1000); } +} - this.setTime = (seconds) => { - this.player.currentTime(seconds); +// Class for VideoJS player +class VideoJSPlayer extends BasePlayer { + // Initialize the VideoJS player + initPlayer(instance) { + return videojs.getPlayer(instance.player.id); } - this.play = () => { - this.player.play(); - this.paused = false; + // Get the current time of the VideoJS player + getTime() { + return Promise.resolve(this.player.currentTime()); } - this.pause = () => { - this.player.pause(); - this.paused = true; + // Set the current time of the VideoJS player + setTime(seconds) { + this.player.currentTime(seconds); } } -function vimeoPlayer(instance) { - const iframe = document.querySelector('iframe'); - this.player = new Vimeo.Player(iframe); - this.player.setCurrentTime(0); - this.paused = true; - this.player.ready().then(instance.checkPlayHead); - this.player.on('play',instance.preparePlayHead); - this.player.on('pause',instance.pausePlayHead); - - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.player.getCurrentTime()); - }); +// Class for Vimeo player +class VimeoPlayer extends BasePlayer { + // Initialize the Vimeo player + initPlayer(instance) { + const iframe = document.querySelector('iframe'); + return new Vimeo.Player(iframe); } - this.setTime = (seconds) => { - this.player.setCurrentTime(seconds); + // Attach event listeners specific to Vimeo player + attachEventListeners(instance) { + this.player.ready().then(instance.checkPlayHead.bind(instance)); + this.player.on('play', instance.preparePlayHead.bind(instance)); + this.player.on('pause', instance.pausePlayHead.bind(instance)); } - this.play = () => { - this.player.play(); - this.paused = false; + // Get the current time of the Vimeo player + getTime() { + return this.player.getCurrentTime(); } - this.pause = () => { - this.player.pause(); - this.paused = true; + // Set the current time of the Vimeo player + setTime(seconds) { + this.player.setCurrentTime(seconds); } } -function youtubePlayer(instance) { - const tag = document.createElement('script'); - tag.id = 'iframe-demo'; - tag.src = 'https://www.youtube.com/iframe_api'; - const firstScriptTag = document.getElementsByTagName('script')[0]; - firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); - this.paused = true; - - const previousYTEvent = window.onYouTubeIframeAPIReady; - window.onYouTubeIframeAPIReady = () => { - if (typeof previousYTEvent !== 'undefined') { // used for multiple YouTube players - previousYTEvent(); +// Class for YouTube player +class YouTubePlayer extends BasePlayer { + // Initialize the YouTube player by loading YouTube IFrame API + initPlayer(instance) { + // Defer attaching event listeners until the player is ready + this.isReady = false; + + // Load the YouTube IFrame API script + if (!document.getElementById('iframe-demo')) { + const tag = document.createElement('script'); + tag.id = 'iframe-demo'; + tag.src = 'https://www.youtube.com/iframe_api'; + const firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); } + // Set the global callback for the YouTube IFrame API + window.onYouTubeIframeAPIReady = this.onYouTubeIframeAPIReady.bind(this, instance); + } + + // Callback when YouTube IFrame API is ready + onYouTubeIframeAPIReady(instance) { this.player = new YT.Player(instance.player.id, { events: { - onStateChange: onPlayerStateChange, - }, + onStateChange: this.onPlayerStateChange.bind(this, instance), + onReady: this.onPlayerReady.bind(this) + } }); - }; + } - let onPlayerStateChange = event => { - if (event.data === 1) { - // playing + // Event handler when the YouTube player is ready + onPlayerReady() { + this.isReady = true; + } + + // Handle YouTube player state changes (play, pause) + onPlayerStateChange(instance, event) { + if (event.data === YT.PlayerState.PLAYING) { // Playing instance.preparePlayHead(); this.paused = false; - } else if (event.data === 2) { - // paused + } else if (event.data === YT.PlayerState.PAUSED) { // Paused instance.pausePlayHead(); this.paused = true; } - }; - - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.player.getCurrentTime()); - }); } - this.setTime = (seconds) => { - this.player.seekTo(seconds, true); - } - - this.play = () => { - this.player.playVideo(); - } - - this.pause = () => { - this.player.pauseVideo(); + // Get the current time of the YouTube player + getTime() { + if (this.isReady) { + return Promise.resolve(this.player.getCurrentTime()); + } else { + return Promise.resolve(0); // Return 0 if the player is not ready + } } -} - -// Note – The Spotify Player is in beta. -// The API limits us to: -// 1. A seek accuracy of nearest second -// 2. An update frequency of one second (although a workaround is provided) -// 3. Playing a file without previous iteraction will always play from start -// ie – a shared selection will highlight but not start at the start of -// that selection. - -function spotifyPlayer(instance) { - this.currentTime = 0; - this.paused = true; - this.player = null; - window.onSpotifyIframeApiReady = IFrameAPI => { - - const element = document.getElementById(instance.player.id); - - const extractEpisodeID = (url) => { - const match = url.match(/episode\/(.+)$/); - return match ? match[1] : null; + // Set the current time of the YouTube player + setTime(seconds) { + if (this.isReady) { + this.player.seekTo(seconds, true); } + } - const subSample = (sampleInterval) => { - this.currentTime += sampleInterval; + // Play the YouTube video + play() { + if (this.isReady) { + this.player.playVideo(); } + } - const srcValue = element.getAttribute('src'); - const episodeID = extractEpisodeID(srcValue); - - const options = { - uri: `spotify:episode:${episodeID}`, + // Pause the YouTube video + pause() { + if (this.isReady) { + this.player.pauseVideo(); } + } +} - const callback = player => { - this.player = player; - player.addListener('playback_update', e => { - if (e.data.isPaused !== true) { - this.currentTime = e.data.position / 1000; - let currentSample = 0; - let totalSample = 0; - let sampleInterval = 0.25; - - while (totalSample < 1){ - currentSample += sampleInterval; - setTimeout(subSample, currentSample*1000, sampleInterval); - totalSample = currentSample + sampleInterval; +// Class for Spotify player +class SpotifyPlayer extends BasePlayer { + // Initialize the Spotify player by setting up the Spotify IFrame API + initPlayer(instance) { + window.onSpotifyIframeApiReady = IFrameAPI => { + const element = document.getElementById(instance.player.id); + const srcValue = element.getAttribute('src'); + const episodeID = this.extractEpisodeID(srcValue); + + const options = { uri: `spotify:episode:${episodeID}` }; + const callback = player => { + this.player = player; + player.addListener('playback_update', e => { + if (e.data.isPaused !== true) { + this.currentTime = e.data.position / 1000; + instance.preparePlayHead(); + this.paused = false; + } else { + instance.pausePlayHead(); + this.paused = true; } + }); - instance.preparePlayHead(); - this.paused = false; - } else { - instance.pausePlayHead(); - this.paused = true; - } - }); + player.addListener('ready', () => { + player.togglePlay(); // Priming the playhead + instance.checkPlayHead(); + }); + }; - player.addListener('ready', () => { - // With the Spotify API we need to play before we seek. - // Although togglePlay should autoplay it doesn't, - // but lets us prime the playhead. - player.togglePlay(); - instance.checkPlayHead(); - }); + IFrameAPI.createController(element, options, callback); }; - - IFrameAPI.createController(element, options, callback); } - this.getTime = () => { - return new Promise((resolve) => { - resolve(this.currentTime); - }); + // Extract episode ID from the Spotify URL + extractEpisodeID(url) { + const match = url.match(/episode\/(.+)$/); + return match ? match[1] : null; + } + + // Get the current time of the Spotify player + getTime() { + return Promise.resolve(this.currentTime); } - this.setTime = (seconds) => { + // Set the current time of the Spotify player + setTime(seconds) { this.player.seek(seconds); } - this.play = () => { + // Play the Spotify track + play() { this.player.play(); this.paused = false; } - this.pause = () => { + // Pause the Spotify track + pause() { this.player.togglePlay(); this.paused = true; } } +// Mapping player types to their respective classes const hyperaudioPlayerOptions = { - "native": nativePlayer, - "soundcloud": soundcloudPlayer, - "youtube": youtubePlayer, - "videojs": videojsPlayer, - "vimeo": vimeoPlayer, - "spotify": spotifyPlayer -} - + "native": NativePlayer, + "soundcloud": SoundCloudPlayer, + "youtube": YouTubePlayer, + "videojs": VideoJSPlayer, + "vimeo": VimeoPlayer, + "spotify": SpotifyPlayer +}; + +// Factory function to create player instances function hyperaudioPlayer(playerType, instance) { - if (playerType !== null && playerType !== undefined) { - return new playerType(instance); + if (playerType) { + return new hyperaudioPlayerOptions[playerType](instance); } else { - console.warn("HYPERAUDIO LITE WARNING: data-player-type attribute should be set on player if not native, eg SoundCloud, YouTube, Vimeo, VideoJS"); + console.warn("HYPERAUDIO LITE WARNING: data-player-type attribute should be set on player if not native, e.g., SoundCloud, YouTube, Vimeo, VideoJS"); } } +// Main class for HyperaudioLite functionality class HyperaudioLite { constructor(transcriptId, mediaElementId, minimizedMode, autoscroll, doubleClick, webMonetization, playOnClick) { this.transcript = document.getElementById(transcriptId); this.init(mediaElementId, minimizedMode, autoscroll, doubleClick, webMonetization, playOnClick); - } - init = (mediaElementId, minimizedMode, autoscroll, doubleClick, webMonetization, playOnClick) => { + // Ensure correct binding for class methods + this.preparePlayHead = this.preparePlayHead.bind(this); + this.pausePlayHead = this.pausePlayHead.bind(this); + this.setPlayHead = this.setPlayHead.bind(this); + this.checkPlayHead = this.checkPlayHead.bind(this); + this.clearTimer = this.clearTimer.bind(this); + } + + // Initialize the HyperaudioLite instance + init(mediaElementId, minimizedMode, autoscroll, doubleClick, webMonetization, playOnClick) { + this.setupTranscriptHash(); + this.setupPopover(); + this.setupPlayer(mediaElementId); + this.setupTranscriptWords(); + this.setupEventListeners(doubleClick, playOnClick); + this.setupInitialPlayHead(); + this.minimizedMode = minimizedMode; + this.autoscroll = autoscroll; + this.webMonetization = webMonetization; + } + // Setup hash for transcript selection + setupTranscriptHash() { const windowHash = window.location.hash; const hashVar = windowHash.substring(1, windowHash.indexOf('=')); - if (hashVar === this.transcript.id) { this.hashArray = windowHash.substring(this.transcript.id.length + 2).split(','); } else { this.hashArray = []; } + } - document.addEventListener( - 'selectionchange', - () => { - const mediaFragment = this.getSelectionMediaFragment(); - - if (mediaFragment !== null) { - document.location.hash = mediaFragment; + // Setup the popover for text selection + setupPopover() { + if (typeof popover !== 'undefined') { + this.transcript.addEventListener('mouseup', () => { + const selection = window.getSelection(); + const popover = document.getElementById('popover'); + let selectionText; + + if (selection.toString().length > 0) { + selectionText = selection.toString().replaceAll("'", "`"); + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + popover.style.left = `${rect.left + window.scrollX}px`; + popover.style.top = `${rect.bottom + window.scrollY}px`; + popover.style.display = 'block'; + + const mediaFragment = this.getSelectionMediaFragment(); + + if (mediaFragment) { + document.location.hash = mediaFragment; + } + } else { + popover.style.display = 'none'; } - }, - false, - ); - - this.minimizedMode = minimizedMode; - this.textShot = ''; - this.wordIndex = 0; - - this.autoscroll = autoscroll; - this.scrollerContainer = this.transcript; - this.scrollerOffset = 0; - this.scrollerDuration = 800; - this.scrollerDelay = 0; - - this.doubleClick = doubleClick; - this.webMonetization = webMonetization; - this.playOnClick = playOnClick; - this.highlightedText = false; - this.start = null; - - this.myPlayer = null; - this.playerPaused = true; - - if (this.autoscroll === true) { - this.scroller = window.Velocity || window.jQuery.Velocity; + + const popoverBtn = document.getElementById('popover-btn'); + popoverBtn.addEventListener('click', (e) => { + popover.style.display = 'none'; + let cbText = `${selectionText} ${document.location}`; + navigator.clipboard.writeText(cbText); + + const dialog = document.getElementById("clipboard-dialog"); + document.getElementById("clipboard-text").innerHTML = cbText; + dialog.showModal(); + + const confirmButton = document.getElementById("clipboard-confirm"); + confirmButton.addEventListener("click", () => dialog.close()); + + e.preventDefault(); + return false; + }); + }); } + } - //Create the array of timed elements (wordArr) - - const words = this.transcript.querySelectorAll('[data-m]'); - this.wordArr = this.createWordArray(words); - this.parentTag = words[0].parentElement.tagName; - this.parentElements = this.transcript.getElementsByTagName(this.parentTag); + // Setup the media player + setupPlayer(mediaElementId) { this.player = document.getElementById(mediaElementId); - - // Grab the media source and type from the first section if it exists - // and add it to the media element. - const mediaSrc = this.transcript.querySelector('[data-media-src]'); - - if (mediaSrc !== null && mediaSrc !== undefined) { + if (mediaSrc) { this.player.src = mediaSrc.getAttribute('data-media-src'); } - if (this.player.tagName == 'VIDEO' || this.player.tagName == 'AUDIO') { - //native HTML media elements + if (this.player.tagName === 'VIDEO' || this.player.tagName === 'AUDIO') { this.playerType = 'native'; } else { - //assume it is a SoundCloud or YouTube iframe this.playerType = this.player.getAttribute('data-player-type'); } - this.myPlayer = hyperaudioPlayer(hyperaudioPlayerOptions[this.playerType], this); - this.parentElementIndex = 0; - words[0].classList.add('active'); - //this.parentElements[0].classList.add('active'); - let playHeadEvent = 'click'; + this.myPlayer = hyperaudioPlayer(this.playerType, this); + } - if (this.doubleClick === true) { - playHeadEvent = 'dblclick'; - } + // Setup the transcript words + setupTranscriptWords() { + const words = this.transcript.querySelectorAll('[data-m]'); + this.wordArr = this.createWordArray(words); + this.parentTag = words[0].parentElement.tagName; + this.parentElements = this.transcript.getElementsByTagName(this.parentTag); + } - this.transcript.addEventListener(playHeadEvent, this.setPlayHead, false); - this.transcript.addEventListener(playHeadEvent, this.checkPlayHead, false); + // Setup event listeners for interactions + setupEventListeners(doubleClick, playOnClick) { + this.minimizedMode = false; + this.autoscroll = false; + this.doubleClick = doubleClick; + this.webMonetization = false; + this.playOnClick = playOnClick; + this.highlightedText = false; + this.start = null; - this.start = this.hashArray[0]; + if (this.autoscroll) { + this.scroller = window.Velocity || window.jQuery.Velocity; + } - //check for URL based start and stop times + const playHeadEvent = doubleClick ? 'dblclick' : 'click'; + this.transcript.addEventListener(playHeadEvent, this.setPlayHead.bind(this), false); + this.transcript.addEventListener(playHeadEvent, this.checkPlayHead.bind(this), false); + } + // Setup initial playhead position based on URL hash + setupInitialPlayHead() { + this.start = this.hashArray[0]; if (!isNaN(parseFloat(this.start))) { this.highlightedText = true; - let indices = this.updateTranscriptVisualState(this.start); - let index = indices.currentWordIndex; - - if (index > 0) { - this.scrollToParagraph(indices.currentParentElementIndex, index); + if (indices.currentWordIndex > 0) { + this.scrollToParagraph(indices.currentParentElementIndex, indices.currentWordIndex); } } this.end = this.hashArray[1]; - //TODO convert to binary search for below for quicker startup if (this.start && this.end) { + const words = this.transcript.querySelectorAll('[data-m]'); for (let i = 1; i < words.length; i++) { - const wordStart = parseInt(words[i].getAttribute('data-m')) / 1000; - if (wordStart > parseFloat(this.start) && parseFloat(this.end) > wordStart) { + let startTime = parseInt(words[i].getAttribute('data-m')) / 1000; + let wordStart = (Math.round(startTime * 100) / 100).toFixed(2); + if (wordStart >= parseFloat(this.start) && parseFloat(this.end) > wordStart) { words[i].classList.add('share-match'); } } } - }; // end init - - createWordArray = words => { - let wordArr = []; + } - words.forEach((word, i) => { + // Create an array of words with metadata from the transcript + createWordArray(words) { + return Array.from(words).map(word => { const m = parseInt(word.getAttribute('data-m')); let p = word.parentNode; while (p !== document) { - if ( - p.tagName.toLowerCase() === 'p' || - p.tagName.toLowerCase() === 'figure' || - p.tagName.toLowerCase() === 'ul' - ) { + if (['p', 'figure', 'ul'].includes(p.tagName.toLowerCase())) { break; } p = p.parentNode; } - wordArr[i] = { n: words[i], m: m, p: p }; - wordArr[i].n.classList.add('unread'); + word.classList.add('unread'); + return { n: word, m, p }; }); - - return wordArr; - }; + } getSelectionRange = () => { - let range = null; - let selection = null; + const selection = window.getSelection(); + if (selection.rangeCount === 0) return null; - if (window.getSelection) { - selection = window.getSelection(); - } else if (document.selection) { - selection = document.selection.createRange(); - } - - // check to see if selection is actually inside the transcript - let insideTranscript = false; - let parentElement = selection.focusNode; - while (parentElement !== null) { - if (parentElement.id === this.transcript.id) { - insideTranscript = true; - break; + const range = selection.getRangeAt(0); + + // Helper function to get the closest span + function getClosestSpan(node) { + while (node && node.nodeType !== Node.ELEMENT_NODE) { + node = node.parentNode; } - parentElement = parentElement.parentElement; + return node.closest('[data-m]'); } - if (selection.toString() !== '' && insideTranscript === true && selection.focusNode !== null && selection.anchorNode !== null) { - - let fNode = selection.focusNode.parentNode; - let aNode = selection.anchorNode.parentNode; - - if (aNode.tagName === "P") { - aNode = selection.anchorNode.nextElementSibling; - } - - if (fNode.tagName === "P") { - fNode = selection.focusNode.nextElementSibling; - } - - if (aNode.getAttribute('data-m') === null || aNode.className === 'speaker') { - aNode = aNode.nextElementSibling; - } + // Get all relevant spans + const allSpans = Array.from(this.transcript.querySelectorAll('[data-m]')); - if (fNode.getAttribute('data-m') === null || fNode.className === 'speaker') { - fNode = fNode.previousElementSibling; - } + // Find the first and last span that contain selected text + let startSpan = null; + let endSpan = null; + let selectedText = range.toString(); + let trimmedSelectedText = selectedText.trim(); - // if the selection starts with a space we want the next element - if(selection.toString().charAt(0) == " " && aNode !== null) { - aNode = aNode.nextElementSibling; + for (let span of allSpans) { + if (range.intersectsNode(span) && span.textContent.trim() !== '') { + if (!startSpan) startSpan = span; + endSpan = span; } + } - if (aNode !== null) { - let aNodeTime = parseInt(aNode.getAttribute('data-m'), 10); - let aNodeDuration = parseInt(aNode.getAttribute('data-d'), 10); - let fNodeTime; - let fNodeDuration; - - if (fNode !== null && fNode.getAttribute('data-m') !== null) { - // if the selection ends in a space we want the previous element if it exists - if(selection.toString().slice(-1) == " " && fNode.previousElementSibling !== null) { - fNode = fNode.previousElementSibling; - } - - fNodeTime = parseInt(fNode.getAttribute('data-m'), 10); - fNodeDuration = parseInt(fNode.getAttribute('data-d'), 10); + if (!startSpan || !endSpan) return null; - // if the selection starts with a space we want the next element + // Adjust start span if selection starts with a space + let startIndex = allSpans.indexOf(startSpan); + while (selectedText.startsWith(' ') && startIndex < allSpans.length - 1) { + startIndex++; + startSpan = allSpans[startIndex]; + selectedText = selectedText.slice(1); + } - } + // Calculate start time + let startTime = parseInt(startSpan.dataset.m) / 1000; - // 1 decimal place will do - aNodeTime = Math.round(aNodeTime / 100) / 10; - aNodeDuration = Math.round(aNodeDuration / 100) / 10; - fNodeTime = Math.round(fNodeTime / 100) / 10; - fNodeDuration = Math.round(fNodeDuration / 100) / 10; + // Calculate end time - let nodeStart = aNodeTime; - let nodeDuration = Math.round((fNodeTime + fNodeDuration - aNodeTime) * 10) / 10; + let duration = 0; + if (endSpan.dataset.d) { + duration = parseInt(endSpan.dataset.d); + } else { + // when no duration exists default to 1 second + duration = 1000; + } - if (aNodeTime >= fNodeTime) { - nodeStart = fNodeTime; - nodeDuration = Math.round((aNodeTime + aNodeDuration - fNodeTime) * 10) / 10; - } + let endTime = (parseInt(endSpan.dataset.m) + duration) / 1000; - if (nodeDuration === 0 || nodeDuration === null || isNaN(nodeDuration)) { - nodeDuration = 10; // arbitary for now - } + // Format to seconds at 2 decimal place precision + let startTimeFormatted = (Math.round(startTime * 100) / 100).toFixed(2); + let endTimeFormatted = (Math.round(endTime * 100) / 100).toFixed(2); - if (isNaN(parseFloat(nodeStart))) { - range = null; - } else { - //fragment = this.transcript.id + '=' + nodeStart + ',' + Math.round((nodeStart + nodeDuration) * 10) / 10; - range = nodeStart + ',' + Math.round((nodeStart + nodeDuration) * 10) / 10; - } - } - } - return(range); + // Only return a range if there's actually selected text (excluding only spaces) + return trimmedSelectedText ? `${startTimeFormatted},${endTimeFormatted}` : null; } getSelectionMediaFragment = () => { @@ -516,190 +521,157 @@ class HyperaudioLite { if (range === null) { return null; } + console.log(range); return (this.transcript.id + '=' +range); } - setPlayHead = e => { - const target = e.target ? e.target : e.srcElement; - - // cancel highlight playback + // Set the playhead position in the media player based on the transcript + setPlayHead(e) { + const target = e.target || e.srcElement; this.highlightedText = false; + this.clearActiveClasses(); - // clear elements with class='active' - let activeElements = Array.from(this.transcript.getElementsByClassName('active')); - - activeElements.forEach(e => { - e.classList.remove('active'); - }); - - if (this.myPlayer.paused === true && target.getAttribute('data-m') !== null) { + if (this.myPlayer.paused && target.dataset.m) { target.classList.add('active'); target.parentNode.classList.add('active'); } - const timeSecs = parseInt(target.getAttribute('data-m')) / 1000; + const timeSecs = parseInt(target.dataset.m) / 1000; this.updateTranscriptVisualState(timeSecs); - if (!isNaN(parseFloat(timeSecs))) { + if (!isNaN(timeSecs)) { this.end = null; this.myPlayer.setTime(timeSecs); - if (this.playOnClick === true) { + if (this.playOnClick) { this.myPlayer.play(); } } } - clearTimer = () => { - if (this.timer) clearTimeout(this.timer); + // Clear the active classes from the transcript + clearActiveClasses() { + const activeElements = Array.from(this.transcript.getElementsByClassName('active')); + activeElements.forEach(e => e.classList.remove('active')); } - preparePlayHead = () => { + // Prepare the playhead for playback + preparePlayHead() { this.myPlayer.paused = false; this.checkPlayHead(); } - pausePlayHead = () => { + // Pause the playhead + pausePlayHead() { this.clearTimer(); this.myPlayer.paused = true; } - checkPlayHead = () => { - + // Check the playhead position and update the transcript + checkPlayHead() { this.clearTimer(); - (async (instance) => { - instance.currentTime = await instance.myPlayer.getTime(); - - if (instance.highlightedText === true) { - instance.currentTime = instance.start; - instance.myPlayer.setTime(instance.currentTime); - instance.highlightedText = false; + (async () => { + this.currentTime = await this.myPlayer.getTime(); + if (this.highlightedText) { + this.currentTime = this.start; + this.myPlayer.setTime(this.currentTime); + this.highlightedText = false; } - // no need to check status if the currentTime hasn't changed - - instance.checkStatus(); - - })(this); + this.checkStatus(); + })(); } - scrollToParagraph = (currentParentElementIndex, index) => { - let newPara = false; - let scrollNode = this.wordArr[index - 1].n.parentNode; - - if (scrollNode !== null && scrollNode.tagName != 'P') { - // it's not inside a para so just use the element - scrollNode = this.wordArr[index - 1].n; + // Clear the timer for the playhead + clearTimer() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; } + } - if (currentParentElementIndex != this.parentElementIndex) { - - if (typeof this.scroller !== 'undefined' && this.autoscroll === true) { - if (scrollNode !== null) { - if (typeof this.scrollerContainer !== 'undefined' && this.scrollerContainer !== null) { - this.scroller(scrollNode, 'scroll', { - container: this.scrollerContainer, - duration: this.scrollerDuration, - delay: this.scrollerDelay, - offset: this.scrollerOffset, - }); - } else { - this.scroller(scrollNode, 'scroll', { - duration: this.scrollerDuration, - delay: this.scrollerDelay, - offset: this.scrollerOffset, - }); - } + // Scroll to the paragraph containing the current word + scrollToParagraph(currentParentElementIndex, index) { + const scrollNode = this.wordArr[index - 1].n.closest('p') || this.wordArr[index - 1].n; + + if (currentParentElementIndex !== this.parentElementIndex) { + if (this.autoscroll && typeof this.scroller !== 'undefined') { + if (scrollNode) { + this.scroller(scrollNode, 'scroll', { + container: this.scrollerContainer, + duration: this.scrollerDuration, + delay: this.scrollerDelay, + offset: this.scrollerOffset, + }); } else { - // the wordlst needs refreshing - let words = this.transcript.querySelectorAll('[data-m]'); - this.wordArr = this.createWordArray(words); + this.wordArr = this.createWordArray(this.transcript.querySelectorAll('[data-m]')); this.parentElements = this.transcript.getElementsByTagName(this.parentTag); } } - - newPara = true; this.parentElementIndex = currentParentElementIndex; } - return(newPara); } - checkStatus = () => { - //check for end time of shared piece - - let interval = 0; - - if (this.myPlayer.paused === false) { - + // Check the status of the playhead and update the transcript + checkStatus() { + if (!this.myPlayer.paused) { if (this.end && parseInt(this.end) < parseInt(this.currentTime)) { this.myPlayer.pause(); this.end = null; } else { - let newPara = false; - //interval = 0; // used to establish next checkPlayHead - - let indices = this.updateTranscriptVisualState(this.currentTime); - let index = indices.currentWordIndex; - + const indices = this.updateTranscriptVisualState(this.currentTime); + const index = indices.currentWordIndex; if (index > 0) { - newPara = this.scrollToParagraph(indices.currentParentElementIndex, index); + this.scrollToParagraph(indices.currentParentElementIndex, index); } - //minimizedMode is still experimental - it changes document.title upon every new word if (this.minimizedMode) { - const elements = transcript.querySelectorAll('[data-m]'); + const elements = this.transcript.querySelectorAll('[data-m]'); let currentWord = ''; let lastWordIndex = this.wordIndex; for (let i = 0; i < elements.length; i++) { - if ((' ' + elements[i].className + ' ').indexOf(' active ') > -1) { + if (elements[i].classList.contains('active')) { currentWord = elements[i].innerHTML; this.wordIndex = i; } } - let textShot = ''; - - if (this.wordIndex != lastWordIndex) { - textShot = textShot + currentWord; + if (this.wordIndex !== lastWordIndex) { + document.title = currentWord; } + } - if (textShot.length > 16 || newPara === true) { - document.title = textShot; - textShot = ''; - newPara = false; + if (this.webMonetization === true) { + //check for payment pointer + let activeElements = this.transcript.getElementsByClassName('active'); + let paymentPointer = this.checkPaymentPointer(activeElements[activeElements.length - 1]); + + if (paymentPointer !== null) { + let metaElements = document.getElementsByTagName('meta'); + let wmMeta = document.querySelector("meta[name='monetization']"); + if (wmMeta === null) { + wmMeta = document.createElement('meta'); + wmMeta.name = 'monetization'; + wmMeta.content = paymentPointer; + document.getElementsByTagName('head')[0].appendChild(wmMeta); + } else { + wmMeta.name = 'monetization'; + wmMeta.content = paymentPointer; + } } } + let interval = 0; if (this.wordArr[index]) { - interval = parseInt(this.wordArr[index].n.getAttribute('data-m') - this.currentTime * 1000); - } - } - if (this.webMonetization === true) { - //check for payment pointer - let activeElements = this.transcript.getElementsByClassName('active'); - let paymentPointer = this.checkPaymentPointer(activeElements[activeElements.length - 1]); - - if (paymentPointer !== null) { - let metaElements = document.getElementsByTagName('meta'); - let wmMeta = document.querySelector("meta[name='monetization']"); - if (wmMeta === null) { - wmMeta = document.createElement('meta'); - wmMeta.name = 'monetization'; - wmMeta.content = paymentPointer; - document.getElementsByTagName('head')[0].appendChild(wmMeta); - } else { - wmMeta.name = 'monetization'; - wmMeta.content = paymentPointer; - } + interval = this.wordArr[index].n.getAttribute('data-m') - this.currentTime * 1000; } + + this.timer = setTimeout(() => this.checkPlayHead(), interval + 1); } - this.timer = setTimeout(() => { - this.checkPlayHead(); - }, interval + 1); // +1 to avoid rounding issues (better to be over than under) } else { this.clearTimer(); } - }; + } checkPaymentPointer = element => { let paymentPointer = null; @@ -723,39 +695,34 @@ class HyperaudioLite { return this.checkPaymentPointer(parent); } } - }; - - updateTranscriptVisualState = (currentTime) => { + } + // Update the visual state of the transcript based on the current time + updateTranscriptVisualState(currentTime) { let index = 0; let words = this.wordArr.length - 1; - // Binary search https://en.wikipedia.org/wiki/Binary_search_algorithm while (index <= words) { - const guessIndex = index + ((words - index) >> 1); // >> 1 has the effect of halving and rounding down - const difference = this.wordArr[guessIndex].m / 1000 - currentTime; // wordArr[guessIndex].m represents start time of word + const guessIndex = index + ((words - index) >> 1); + const difference = this.wordArr[guessIndex].m / 1000 - currentTime; if (difference < 0) { - // comes before the element index = guessIndex + 1; } else if (difference > 0) { - // comes after the element words = guessIndex - 1; } else { - // equals the element index = guessIndex; break; } } this.wordArr.forEach((word, i) => { - let classList = word.n.classList; - let parentClassList = word.n.parentNode.classList; + const classList = word.n.classList; + const parentClassList = word.n.parentNode.classList; if (i < index) { classList.add('read'); - classList.remove('unread'); - classList.remove('active'); + classList.remove('unread', 'active'); parentClassList.remove('active'); } else { classList.add('unread'); @@ -763,66 +730,26 @@ class HyperaudioLite { } }); - this.parentElements = this.transcript.getElementsByTagName(this.parentTag); - - //remove active class from all paras - Array.from(this.parentElements).forEach(el => { - if (el.classList.contains('active')) { - el.classList.remove('active'); - } - }); - - // set current word and para to active + Array.from(this.parentElements).forEach(el => el.classList.remove('active')); if (index > 0) { - if (this.myPlayer.paused === false) { + if (!this.myPlayer.paused) { this.wordArr[index - 1].n.classList.add('active'); } + this.wordArr[index - 1].n.parentNode.classList.add('active'); + } - if (this.wordArr[index - 1].n.parentNode !== null) { - this.wordArr[index - 1].n.parentNode.classList.add('active'); - } - } - - // Establish current paragraph index - let currentParentElementIndex; - - Array.from(this.parentElements).every((el, i) => { - if (el.classList.contains('active')) { - currentParentElementIndex = i; - return false; - } - return true; - }); + const currentParentElementIndex = Array.from(this.parentElements).findIndex(el => el.classList.contains('active')); - let indices = { + return { currentWordIndex: index, - currentParentElementIndex: currentParentElementIndex, + currentParentElementIndex }; - - return indices; - }; - - setScrollParameters = (duration, delay, offset, container) => { - this.scrollerContainer = container; - this.scrollerDuration = duration; - this.scrollerDelay = delay; - this.scrollerOffset = offset; - }; - - toggleAutoScroll = () => { - this.autoscroll = !this.autoscroll; - }; - - setAutoScroll = state => { - this.autoscroll = state; - }; + } } -// required for testing +// Export for testing or module usage if (typeof module !== 'undefined' && module.exports) { module.exports = { HyperaudioLite }; -} - -//export default HyperaudioLite; +} \ No newline at end of file From 278f7bbf98ba57dd22d769a54649411135c5b25e Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 18 Sep 2024 19:10:45 +0200 Subject: [PATCH 2/3] bumped version, removed commented code and console.logs --- index.html | 123 ++--------------------------------------------------- 1 file changed, 4 insertions(+), 119 deletions(-) diff --git a/index.html b/index.html index b14d040..3dcc07c 100644 --- a/index.html +++ b/index.html @@ -1,6 +1,6 @@ - +