From d5a2a51be2b8a6956c847b7c638c6f1703a74280 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 28 Jan 2025 09:43:39 -0500 Subject: [PATCH] Continued work on media scheduler --- src/app/channel/media/queue.js | 117 ++++++++++++++- src/utils/media/yanker.js | 18 ++- src/views/partial/panels/queue.ejs | 15 +- src/views/partial/popup/scheduleMedia.ejs | 21 +++ src/views/partial/styles.ejs | 4 +- www/css/channel.css | 1 + www/css/panel/queue.css | 28 ++++ www/css/theme/movie-night.css | 12 +- www/js/channel/channel.js | 4 +- www/js/channel/chat.js | 6 +- www/js/channel/cpanel.js | 5 +- www/js/channel/panels/queuePanel.js | 164 +++++++++++++++++++--- www/js/channel/player.js | 2 +- www/js/utils.js | 72 +++++++--- 14 files changed, 415 insertions(+), 54 deletions(-) create mode 100644 src/views/partial/popup/scheduleMedia.ejs diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 107119d..e591858 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -44,16 +44,38 @@ module.exports = class{ defineListeners(socket){ socket.on("queue", (data) => {this.queueURL(socket, data)}); + socket.on("delete", (data => {this.deleteMedia(socket, data)})); + socket.on("move", (data => {this.moveMedia(socket, data)})); } async queueURL(socket, data){ try{ //pull URL and start time from data - let {url, start} = data; + let {url, start, title} = data; //Pull media list - const mediaList = await yanker.yankMedia(url); + const mediaList = await yanker.yankMedia(url, title); + + //If we didn't find any media + if(mediaList == null || mediaList.length <= 0){ + //Bitch, moan, complain... + loggerUtils.socketErrorHandler(socket, "No media found!", "queue"); + //and ignore it! + return; + } + + //If we have an invalid time + if(start == null || start < (new Date).getTime()){ + //Get last item from schedule + const lastItem = (Array.from(this.schedule)[this.schedule.size - 1]); + + //if we have a last item + if(lastItem != null){ + //Throw it on five ms after the last item + start = lastItem[1].startTime + (lastItem[1].duration * 1000) + 5; + } + } //Queue the first media object given this.queueMedia(mediaList[0], start, socket); @@ -62,6 +84,24 @@ module.exports = class{ } } + deleteMedia(socket, data){ + try{ + //Remove media by UUID + this.removeMedia(data.uuid, socket); + }catch(err){ + return loggerUtils.socketExceptionHandler(socket, err); + } + } + + moveMedia(socket, data){ + try{ + //Move media by UUID + this.rescheduleMedia(data.uuid, data.start, socket); + }catch(err){ + return loggerUtils.socketExceptionHandler(socket, err); + } + } + //Default start time to now + half a second to give everyone time to process shit queueMedia(inputMedia, start = new Date().getTime() + 50, socket){ //Create a new media queued object, set start time to now @@ -69,9 +109,6 @@ module.exports = class{ //schedule the media this.scheduleMedia(mediaObj, socket); - - //Refresh the next timer to ensure whatever comes on next is right - this.refreshNextTimer(); } refreshNextTimer(){ @@ -93,6 +130,62 @@ module.exports = class{ this.nextTimer = setTimeout(()=>{this.start(nextItem)}, startsIn); } + rescheduleMedia(uuid, start = new Date().getTime() + 50, socket){ + //Find and remove media from the schedule by UUID + const media = this.removeMedia(uuid); + + //If we got a bad request + if(media == null){ + //If an originating socket was provided for this request + if(socket != null){ + //Yell at the user for being an asshole + loggerUtils.socketErrorHandler(socket, "Cannot move non-existant item!", "queue"); + } + //Ignore it + return; + } + + //Set media time + media.startTime = start; + + //Re-schedule the media for the given time + this.scheduleMedia(media, socket); + } + + removeMedia(uuid, socket){ + //Get requested media + const media = this.getItemByUUID(uuid); + + //If we got a bad request + if(media == null){ + //If an originating socket was provided for this request + if(socket != null){ + //Yell at the user for being an asshole + loggerUtils.socketErrorHandler(socket, "Cannot delete non-existant item!", "queue"); + } + //Ignore it + return; + } + + //If we're currently playing the requested item. + if(this.nowPlaying != null && this.nowPlaying.uuid == uuid){ + //End playback + this.end(); + } + + //Take the item out of the schedule + this.schedule.delete(media.startTime); + + //Refresh next timer + this.refreshNextTimer(); + + //Broadcast the channel queue + this.broadcastQueue(); + + //return found media in-case our calling function needs it :P + return media; + } + scheduleMedia(mediaObj, socket){ /* This is a fun method and I think it deserves it's own little explination... Since we're working with a time based schedule, using start epochs as keys for our iterable seemed the best option @@ -159,6 +252,9 @@ module.exports = class{ //Broadcast the channel queue this.broadcastQueue(); + + //Refresh the next timer to ensure whatever comes on next is right + this.refreshNextTimer(); } start(mediaObj){ @@ -270,6 +366,17 @@ module.exports = class{ } } + getItemByUUID(uuid){ + //Iterate through the schedule + for(let item of this.schedule){ + //If the uuid matches + if(item[1].uuid == uuid){ + //return the found item + return item[1]; + } + } + } + sendMedia(socket){ //Create data object const data = { diff --git a/src/utils/media/yanker.js b/src/utils/media/yanker.js index 638e0fa..191a58c 100644 --- a/src/utils/media/yanker.js +++ b/src/utils/media/yanker.js @@ -21,7 +21,7 @@ const validator = require('validator');//No express here, so regular validator i const iaUtil = require('./internetArchiveUtils'); const media = require('../../app/channel/media/media'); -module.exports.yankMedia = async function(url){ +module.exports.yankMedia = async function(url, title){ const pullType = await this.getMediaType(url); if(pullType == 'ia'){ @@ -34,13 +34,21 @@ module.exports.yankMedia = async function(url){ for(let file of mediaInfo.files){ //Split file path by directories const path = file.name.split('/'); + //pull filename from path const name = path[path.length - 1]; + //Construct link from pulled info const link = `https://archive.org/download/${mediaInfo.metadata.identifier}/${file.name}`; - //Create new media object from file info - mediaList.push(new media(name, name, link, link, 'ia', Number(file.length))); + //if we where handed a null title + if(title == null || title == ''){ + //Create new media object from file info substituting filename for title + mediaList.push(new media(name, name, link, link, 'ia', Number(file.length))); + }else{ + //Create new media object from file info + mediaList.push(new media(title, name, link, link, 'ia', Number(file.length))); + } } //return media object list @@ -52,12 +60,16 @@ module.exports.yankMedia = async function(url){ } module.exports.getMediaType = async function(url){ + //Encode URI in-case we where handed something a little too humie friendly + url = encodeURI(url); + //Check if we have a valid url if(!validator.isURL(url)){ //If not toss the fucker out return null; } + //If we have link to a resource from archive.org if(url.match(/^https\:\/\/archive.org\//g)){ //return internet archive code diff --git a/src/views/partial/panels/queue.ejs b/src/views/partial/panels/queue.ejs index e7b5ed5..c52e8aa 100644 --- a/src/views/partial/panels/queue.ejs +++ b/src/views/partial/panels/queue.ejs @@ -16,7 +16,16 @@ along with this program. If not, see . %>
- +
+ + + + + + + +
+ -
+
+
diff --git a/src/views/partial/popup/scheduleMedia.ejs b/src/views/partial/popup/scheduleMedia.ejs new file mode 100644 index 0000000..3f83583 --- /dev/null +++ b/src/views/partial/popup/scheduleMedia.ejs @@ -0,0 +1,21 @@ +<%# Canopy - The next generation of stoner streaming software +Copyright (C) 2024-2025 Rainbownapkin and the TTN Community + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . %> +<%# %> + +
+ + +
\ No newline at end of file diff --git a/src/views/partial/styles.ejs b/src/views/partial/styles.ejs index 9cc8977..0fa4e09 100644 --- a/src/views/partial/styles.ejs +++ b/src/views/partial/styles.ejs @@ -1,4 +1,4 @@ -<%# Canopy - The next generation of stoner streaming software + <%# Technically favicon has nothing to do with .css, but it's still looks related, uses a link tag, and globally used :P %> diff --git a/www/css/channel.css b/www/css/channel.css index 3be0ab3..04a8200 100644 --- a/www/css/channel.css +++ b/www/css/channel.css @@ -249,6 +249,7 @@ span.user-entry{ #cpanel-pinned-div{ position: relative; scrollbar-width: thin; + flex: 0 0; } .cpanel-div{ diff --git a/www/css/panel/queue.css b/www/css/panel/queue.css index d7f9410..b6cb460 100644 --- a/www/css/panel/queue.css +++ b/www/css/panel/queue.css @@ -20,6 +20,29 @@ along with this program. If not, see .*/ height: 100%; } +#queue-controls{ + position: absolute; + left: 0; + right: 0; + z-index: 3; +} + +#queue-control-offset{ + margin-bottom: 2em; +} + +#queue-control-buttons{ + height: 2em; + display: flex; + flex-direction: row; + justify-content: space-between; +} + + +#queue-control-buttons button{ + width: 4em; +} + #queue{ display: flex; flex: 1; @@ -58,6 +81,7 @@ div.queue-entry{ margin: 0 1em; right: 0; left: 0; + cursor:grab; } div.queue-entry a{ @@ -79,4 +103,8 @@ div.queue-entry a{ right: 0; text-align: center; font-size: 3em; +} + +.media-tooltip p{ + margin: 0; } \ No newline at end of file diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index b4c66fb..b17b3ce 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -460,6 +460,10 @@ span.emote-list-trash-icon{ border: 1px solid var(--accent0) } +#queue-controls{ + background-color: var(--bg0-alpha1); +} + span.queue-marker{ background-color: var(--accent0); } @@ -477,8 +481,14 @@ div.queue-entry{ border: 1px solid var(--accent1); } -.queue-entry a{ +.queue-entry p{ color: var(--accent1); + margin: 0; + text-align: center; +} + +.media-tooltip p{ + font-family: monospace; } /* altcha theming*/ diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index 87a4ad8..11f773e 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -57,7 +57,7 @@ class channel{ this.socket.on("error", console.log); this.socket.on("queue", (data) => { - this.queue = data.queue; + this.queue = new Map(data.queue); }) } @@ -74,7 +74,7 @@ class channel{ this.chatBox.handleClientInfo(data); //Store queue for use by the queue panel - this.queue = data.queue; + this.queue = new Map(data.queue); } } diff --git a/www/js/channel/chat.js b/www/js/channel/chat.js index 6ca3b7c..305c88a 100644 --- a/www/js/channel/chat.js +++ b/www/js/channel/chat.js @@ -59,7 +59,7 @@ class chatBox{ this.autocompleteDisplay.addEventListener("click", this.tabComplete.bind(this)); this.sendButton.addEventListener("click", this.send.bind(this)); this.settingsIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new panelObj(client))}); - this.adminIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new panelObj(client))}); + this.adminIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new queuePanel(client))}); this.emoteIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new emotePanel(client))}); //Header icons @@ -310,11 +310,11 @@ class chatBox{ const limit = window.innerWidth * .2; //Set width to target or 20vh depending on whether or not we've hit the width limit - this.chatPanel.style.width = targetChatWidth > limit ? `${targetChatWidth}px` : '20vh'; + this.chatPanel.style.flexBasis = targetChatWidth > limit ? `${targetChatWidth}px` : '20vh'; //Fix busted layout var pageBreak = document.body.scrollWidth - document.body.getBoundingClientRect().width; - this.chatPanel.style.width = `${this.chatPanel.getBoundingClientRect().width + pageBreak}px`; + this.chatPanel.style.flexBasis = `${this.chatPanel.getBoundingClientRect().width + pageBreak}px`; //This sometimes gets called before userList ahs been initiated :p if(this.client.userList != null){ this.client.userList.clickDragger.fixCutoff(); diff --git a/www/js/channel/cpanel.js b/www/js/channel/cpanel.js index 65a1eaa..3e5fa2c 100644 --- a/www/js/channel/cpanel.js +++ b/www/js/channel/cpanel.js @@ -25,7 +25,7 @@ class cPanel{ this.poppedPanels = []; //ClickDragger Objects - this.activePanelDragger = new canopyUXUtils.clickDragger("#cpanel-active-drag-handle", "#cpanel-active-div", false); + this.activePanelDragger = new canopyUXUtils.clickDragger("#cpanel-active-drag-handle", "#cpanel-active-div", false, null, false); this.pinnedPanelDragger = new canopyUXUtils.clickDragger("#cpanel-pinned-drag-handle", "#cpanel-pinned-div", false, this.client.chatBox.clickDragger); //Element Nodes @@ -150,6 +150,7 @@ class panelObj{ this.name = name; this.pageURL = pageURL; this.panelDocument = panelDocument; + this.ownerDoc = this.panelDocument.ownerDocument == null ? this.panelDocument : this.panelDocument.ownerDocument; this.client = client; } @@ -162,6 +163,8 @@ class panelObj{ } docSwitch(){ + //Set owner doc + this.ownerDoc = this.panelDocument.ownerDocument == null ? this.panelDocument : this.panelDocument.ownerDocument; } closer(){ diff --git a/www/js/channel/panels/queuePanel.js b/www/js/channel/panels/queuePanel.js index faf4ec6..2a3a9fa 100644 --- a/www/js/channel/panels/queuePanel.js +++ b/www/js/channel/panels/queuePanel.js @@ -8,11 +8,16 @@ class queuePanel extends panelObj{ //Create variable to hold rescale timer this.rescaleTimer = null; + this.autoscroll = true; + //Define non-input event listeners this.defineListeners(); } docSwitch(){ + //Call derived doc switch function + super.docSwitch(); + //Get queue div this.queue = this.panelDocument.querySelector('#queue'); //Get queue container @@ -24,6 +29,21 @@ class queuePanel extends panelObj{ //Re-acquire time marker this.timeMarker = this.panelDocument.querySelector('#time-marker'); + //Get main control buttons + this.addMediaButton = this.panelDocument.querySelector('#queue-add-media'); + this.scrollLockButton = this.panelDocument.querySelector('#queue-scroll-lock'); + + //Get control divs + //Add Media + this.addMediaDiv = this.panelDocument.querySelector('#queue-media-prompts'); + + //Get control div elements + //Add Media + this.addMediaLinkPrompt = this.panelDocument.querySelector('#media-link-input'); + this.addMediaNamePrompt = this.panelDocument.querySelector('#media-name-input'); + this.queueLastButton = this.panelDocument.querySelector('#queue-last-button'); + this.queueAtButton = this.panelDocument.querySelector('#queue-at-button'); + //Render out the queue this.fullRender(); @@ -38,13 +58,48 @@ class queuePanel extends panelObj{ defineListeners(){ - this.client.socket.on("queue", (data) => { - this.renderQueue(); - }) + //Render queue when we receive a new copy of the queue data from the server + this.client.socket.on("clientMetadata", (data) => {this.renderQueue();}) + this.client.socket.on("queue", (data) => {this.renderQueue();}) } setupInput(){ + //Re-render queue and time-marker on window resize as time-relative absolute positioning will be absolutely thrown + this.ownerDoc.defaultView.addEventListener('resize', this.resizeRender.bind(this)); + + //queue this.queue.addEventListener('wheel', this.scaleScroll.bind(this)); + + //control bar controls + this.addMediaButton.addEventListener('click', this.toggleAddMedia.bind(this)); + this.scrollLockButton.addEventListener('click', this.lockScroll.bind(this)); + + //control bar divs + this.queueLastButton.addEventListener('click', this.queueLast.bind(this)) + } + + toggleAddMedia(event){ + if(this.addMediaDiv.style.display == 'none'){ + this.addMediaDiv.style.display = ''; + }else{ + this.addMediaDiv.style.display = 'none'; + } + } + + queueLast(event){ + //Send off the request + this.client.socket.emit("queue",{url:this.addMediaLinkPrompt.value, title:this.addMediaNamePrompt.value}); + this.addMediaLinkPrompt.value = ''; + this.addMediaNamePrompt.value = ''; + } + + lockScroll(event){ + //Enable scroll lock + this.autoscroll = true; + //Light the indicator + this.scrollLockButton.classList.add('positive-button'); + //Render the marker early to insta-jump + this.renderTimeMarker(); } scaleScroll(event){ @@ -85,7 +140,6 @@ class queuePanel extends panelObj{ //Clear out the queue UI this.clearQueue(); - //Calculate new scale const newScale = this.scale + (scaleFactor * scrollDirection); @@ -107,6 +161,12 @@ class queuePanel extends panelObj{ clearTimeout(this.rescaleTimer); //Set timeout to re-render after input stops this.rescaleTimer = setTimeout(this.fullRender.bind(this), 500); + //Otherwise, if we're just scrolling + }else{ + //Disable scroll lock + this.autoscroll = false; + //Unlight the indicator + this.scrollLockButton.classList.remove('positive-button'); } } @@ -167,12 +227,26 @@ class queuePanel extends panelObj{ return timeStrings.join(', '); } + resizeRender(event){ + const date = new Date(); + this.renderQueue(date); + this.renderTimeMarker(date); + } + clearQueue(){ //Clear out queue container - this.queueContainer.innerHTML = ''; + this.queueContainer.innerHTML = '';; //Clear out queue marker container this.queueMarkerContainer.innerHTML = ''; + + //Grab all related tooltips + const tooltips = this.ownerDoc.body.querySelectorAll('.media-tooltip'); + //clear them out since we're clearing out the elements with the event to remove them + //These should clear out on their own on mousemove but this makes things look a *little* prettier :) + for(let tooltip of tooltips){ + tooltip.parentNode.remove(); + } //Clear any marker timers clearTimeout(this.timeMarkerTimer); @@ -206,13 +280,16 @@ class queuePanel extends panelObj{ } //render the time marker - this.renderTimeMarker(date); + this.renderTimeMarker(date, true); //render out the queue this.renderQueue(date); } renderQueue(date = new Date()){ + //Clear out queue container + this.queueContainer.innerHTML = ''; + //for every entry in the queue for(let entry of this.client.queue){ //Create entry div @@ -224,9 +301,64 @@ class queuePanel extends panelObj{ entryDiv.style.bottom = `${this.offsetByDate(new Date(entry[1].startTime + (entry[1].duration * 1000)), true)}px`; //Create entry title - const entryTitle = document.createElement('a'); + const entryTitle = document.createElement('p'); entryTitle.textContent = entry[1].title; - entryTitle.href = entry[1].url; + + //Tooltip generation + //tooltip div + const tooltipDiv = document.createElement('div'); + tooltipDiv.classList.add('media-tooltip'); + + //tooltip title + const tooltipTitle = document.createElement('p'); + tooltipTitle.textContent = `Title: ${entry[1].title}`; + + //tooltip filename + const tooltipFilename = document.createElement('p'); + tooltipFilename.textContent = `File Name: ${entry[1].fileName}`; + + //tooltip source + const tooltipSource = document.createElement('p'); + tooltipSource.textContent = `Source: ${entry[1].type}`; + + //tooltip duration + const tooltipDuration = document.createElement('p'); + tooltipDuration.textContent = `Duration: ${entry[1].duration}`; + + //tooltip start + const tooltipStart = document.createElement('p'); + tooltipStart.textContent = `Start Time: ${new Date(entry[1].startTime).toLocaleString()}`; + + //tooltip end + const tooltipEnd = document.createElement('p'); + tooltipEnd.textContent = `Start Time: ${new Date(entry[1].startTime + (entry[1].duration * 1000)).toLocaleString()}`; + + //append components + for(let component of [ + tooltipTitle, + tooltipFilename, + tooltipSource, + tooltipDuration, + tooltipStart, + tooltipEnd + ]){ + tooltipDiv.append(component); + } + + //Setup media tooltip + entryDiv.addEventListener('mouseenter',(event)=>{utils.ux.displayTooltip(event, tooltipDiv, false, null, true, this.ownerDoc);}); + + //context menu + const menuMap = new Map([ + ["Play now", ()=>{this.client.socket.emit('move', {uuid: entry[1].uuid})}], + ["Move To...", ()=>{}], + ["Delete", ()=>{this.client.socket.emit('delete', {uuid: entry[1].uuid})}], + ["Open in New Tab", ()=>{window.open(entry[1].url, '_blank').focus();}], + ["Copy URL", ()=>{navigator.clipboard.writeText(entry[1].url);}], + ]); + + //Setup context menu + entryDiv.addEventListener('contextmenu', (event)=>{utils.ux.displayContextMenu(event, '', menuMap, this.ownerDoc);}); //Append entry elements entryDiv.append(entryTitle); @@ -236,7 +368,7 @@ class queuePanel extends panelObj{ } } - renderTimeMarker(date = new Date()){ + renderTimeMarker(date = new Date(), forceScroll = false){ //Calculate marker position by date const markerPosition = Math.round(this.offsetByDate(date)); @@ -260,12 +392,9 @@ class queuePanel extends panelObj{ this.timeMarker.style.top = `${markerPosition}px`; //If the panel document isn't null (we're not actively switching panels) - if(this.panelDocument != null){ - //Grab the current owner document object - const ownerDoc = this.panelDocument.ownerDocument == null ? this.panelDocument : this.panelDocument.ownerDocument; - + if(this.panelDocument != null && (this.autoscroll || forceScroll)){ //Get height difference between window and queue layout controller - const docDifference = ownerDoc.defaultView.innerHeight - this.queueLayoutController.getBoundingClientRect().height; + const docDifference = this.ownerDoc.defaultView.innerHeight - this.queueLayoutController.getBoundingClientRect().height; //Calculate scroll target by body difference and marker position const scrollTarget = (markerPosition - (this.queueLayoutController.getBoundingClientRect().height - docDifference) / 2) + docDifference; //Calculate scroll behavior by distance @@ -292,6 +421,9 @@ class queuePanel extends panelObj{ } renderQueueScale(inputDate = new Date()){ + //Clear out queue marker container + this.queueMarkerContainer.innerHTML = ''; + //Make sure we don't modify the date we're handed const date = structuredClone(inputDate); @@ -350,8 +482,8 @@ class queuePanel extends panelObj{ } offsetByDate(date = new Date(), bottomOffset = false){ - //Pull start of day epoch from given date - const dayEpoch = structuredClone(date).setHours(0,0,0,0); + //Pull start of day epoch from given date, make sure to use a new date object so we don't fuck up any date objects passed to us + const dayEpoch = new Date(date).setHours(0,0,0,0); //Get difference between now and day epoch to get time since the start of the current day in milliseconds const curTime = date.getTime() - dayEpoch; //Devide by amount of milliseconds in a day to convert time over to a floating point number between 0 and 1 diff --git a/www/js/channel/player.js b/www/js/channel/player.js index adeb85e..ec9b74f 100644 --- a/www/js/channel/player.js +++ b/www/js/channel/player.js @@ -229,7 +229,7 @@ class player{ this.showVideoIcon.style.display = "flex"; this.client.chatBox.hideChatIcon.style.display = "none"; //Need to clear the width from the split, or else it doesn't display properly - this.client.chatBox.chatPanel.style.width = "100%"; + this.client.chatBox.chatPanel.style.flexBasis = "100%"; } } diff --git a/www/js/utils.js b/www/js/utils.js index bb415d5..d899d2d 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -71,17 +71,21 @@ class canopyUXUtils{ } } - displayTooltip(event, content, ajaxTooltip, cb, soft = false){ + displayTooltip(event, content, ajaxTooltip, cb, soft = false, doc = document){ //Create the tooltip const tooltip = new canopyUXUtils.tooltip(content, ajaxTooltip, ()=>{ - //Call mouse move again after ajax load to re-calculate position within context of the new content - tooltip.moveToMouse(event); + //If this is an ajax tooltip + if(ajaxTooltip){ + //Call mouse move again after ajax load to re-calculate position within context of the new content + tooltip.moveToMouse(event); + } + //If we have a callback function if(typeof cb == "function"){ //Call async callback cb(); } - }); + }, doc); //Move the tooltip with the mouse event.target.addEventListener('mousemove', tooltip.moveToMouse.bind(tooltip)); @@ -92,18 +96,29 @@ class canopyUXUtils{ //remove the tooltip on mouseleave event.target.addEventListener('mouseleave', tooltip.remove.bind(tooltip)); + //Kill tooltip with parent + doc.body.addEventListener('mousemove', killWithParent); + if(soft){ //remove the tooltip on context menu open event.target.addEventListener('click', tooltip.remove.bind(tooltip)); event.target.addEventListener('contextmenu', tooltip.remove.bind(tooltip)); } + + function killWithParent(){ + //If the tooltip parent no longer exists + if(!event.target.isConnected){ + //Kill the whole family :D + tooltip.remove(); + } + } } - displayContextMenu(event, title, menuMap){ + displayContextMenu(event, title, menuMap, doc){ event.preventDefault(); //Create context menu - const contextMenu = new canopyUXUtils.contextMenu(title, menuMap); + const contextMenu = new canopyUXUtils.contextMenu(title, menuMap, doc); //Move context menu to mouse contextMenu.moveToMouse(event); @@ -167,12 +182,13 @@ class canopyUXUtils{ } static tooltip = class{ - constructor(content, ajaxTooltip = false, cb){ + constructor(content, ajaxTooltip = false, cb, doc = document){ //Define non-tooltip node values this.content = content; this.ajaxPopup = ajaxTooltip; this.cb = cb; this.id = Math.random(); + this.doc = doc; //create and append tooltip this.tooltip = document.createElement('div'); @@ -190,7 +206,15 @@ class canopyUXUtils{ this.tooltip.textContent = "Loading tooltip..." this.tooltip.innerHTML = await utils.ajax.getTooltip(this.content); }else{ - this.tooltip.innerHTML = this.content; + //If the content we received is a string + if(typeof this.content == "string"){ + //Use it + this.tooltip.innerHTML = this.content; + //Otherwise + }else{ + //Append it as a node + this.tooltip.appendChild(this.content); + } } if(this.cb){ @@ -200,14 +224,14 @@ class canopyUXUtils{ } displayTooltip(){ - document.body.appendChild(this.tooltip); + this.doc.body.appendChild(this.tooltip); } moveToPos(x,y){ //If the distance between the left edge of the window - the window width is more than the width of our tooltip - if((window.innerWidth - (window.innerWidth - x)) > this.tooltip.getBoundingClientRect().width){ + if((this.doc.defaultView.innerWidth - (this.doc.defaultView.innerWidth - x)) > this.tooltip.getBoundingClientRect().width){ //Push it to the right edge of the cursor, where the hard edge typically is - this.tooltip.style.right = `${window.innerWidth - x}px`; + this.tooltip.style.right = `${this.doc.defaultView.innerWidth - x}px`; this.tooltip.style.left = ''; //otherwise, if we're close to the edge }else{ @@ -218,9 +242,9 @@ class canopyUXUtils{ //If the distance between the top edge of the window - the window height is more than the heigt of our tooltip - if((window.innerHeight - (window.innerHeight - y)) > this.tooltip.getBoundingClientRect().height){ + if((this.doc.defaultView.innerHeight - (this.doc.defaultView.innerHeight - y)) > this.tooltip.getBoundingClientRect().height){ //Push it above the mouse - this.tooltip.style.bottom = `${window.innerHeight - y}px`; + this.tooltip.style.bottom = `${this.doc.defaultView.innerHeight - y}px`; this.tooltip.style.top = ''; //otherwise if we're close to the edge }else{ @@ -243,9 +267,9 @@ class canopyUXUtils{ } static contextMenu = class extends this.tooltip{ - constructor(title, menuMap){ + constructor(title, menuMap, doc = document){ //Call inherited tooltip constructor - super('Loading Menu...'); + super('Loading Menu...', false, null, doc); //Set tooltip class this.tooltip.classList.add('context-menu'); @@ -281,8 +305,8 @@ class canopyUXUtils{ //Create event listener to remove tooltip whenever anything is clicked, inside or out of the menu //Little hacky but we have to do it a bit later to prevent the opening event from closing the menu - setTimeout(()=>{document.body.addEventListener('click', this.remove.bind(this));}, 1); - setTimeout(()=>{document.body.addEventListener('contextmenu', this.remove.bind(this));}, 1); + setTimeout(()=>{this.doc.body.addEventListener('click', this.remove.bind(this));}, 1); + setTimeout(()=>{this.doc.body.addEventListener('contextmenu', this.remove.bind(this));}, 1); } } @@ -390,7 +414,7 @@ class canopyUXUtils{ } static clickDragger = class{ - constructor(handle, element, leftHandle = true, parent){ + constructor(handle, element, leftHandle = true, parent, flex = true){ //Pull needed nodes this.handle = document.querySelector(handle); this.element = document.querySelector(element); @@ -407,6 +431,9 @@ class canopyUXUtils{ //we put a click dragger in yo click dragger so you could click and drag while you click and drag this.parent = parent; + //Whether or not click dragger is in a flexbox + this.flex = flex; + //Setup our event listeners this.setupInput(); } @@ -453,7 +480,13 @@ class canopyUXUtils{ //if we're not breaking the page, or we're moving left if((!this.breakingScreen && pageBreak <= 0) || event.clientX < this.handle.getBoundingClientRect().left){ //Apply difference to width + if(this.flex){ + this.element.style.flexBasis = `${this.calcWidth(difference)}vw`; + } + //I know it's kludgy to apply this along-side flex basis but it fixes some nasty bugs with nested draggers + //Don't @ me, it's not like i'm an actual web developer anyways :P this.element.style.width = `${this.calcWidth(difference)}vw`; + //If we let go here, the width isn't breaking anything so there's nothing to fix. this.breakingScreen = false; }else{ @@ -477,6 +510,9 @@ class canopyUXUtils{ fixCutoff(standalone = true, pageBreak = document.body.scrollWidth - document.body.getBoundingClientRect().width){ //Fix the page width + if(this.flex){ + this.element.style.flexBasis = `${this.calcWidth(this.element.getBoundingClientRect().width + pageBreak)}vw`; + } this.element.style.width = `${this.calcWidth(this.element.getBoundingClientRect().width + pageBreak)}vw`; //If we're calling this outside of drag() (regardless of draglock unless set otherwise)