index.ts
1 import { LunaUnload, Tracer } from "@luna/core"; 2 import { StyleTag } from "@luna/lib"; 3 4 // Import CSS directly using Luna's file:// syntax - Took me a while to figure out <3 5 import unlockSelection from "file://styles.css?minify"; 6 7 export const { trace } = Tracer("[Copy Lyrics]"); 8 9 // clean up resources 10 export const unloads = new Set<LunaUnload>(); 11 12 // StyleTag for lyrics selection styling 13 const lyricsStyleTag = new StyleTag("Copy-Lyrics", unloads, unlockSelection); 14 15 function SetClipboard(text: string): void { 16 const textarea = document.createElement("textarea"); 17 textarea.value = text; 18 textarea.style.position = "fixed"; // Avoid scrolling to bottom 19 document.body.appendChild(textarea); 20 textarea.select(); 21 22 try { 23 const success = document.execCommand("copy"); 24 if (!success) throw new Error("Failed to copy text."); 25 } catch (err) { 26 trace.msg.err(err instanceof Error ? err.message : String(err)); 27 } finally { 28 document.body.removeChild(textarea); 29 } 30 } 31 32 let isSelecting = false; 33 34 const onMouseDown = function (): void { 35 isSelecting = true; 36 }; 37 38 const onMouseUp = function (event: MouseEvent): void { 39 if (isSelecting) { 40 const selection = window.getSelection(); 41 if (selection && selection.toString().length > 0) { 42 const selectedSpans: HTMLSpanElement[] = []; 43 const range = selection.getRangeAt(0); 44 let container = range.commonAncestorContainer; 45 46 // If the container is NOT an element and a document, adjust it. 47 if ( 48 container.nodeType !== Node.ELEMENT_NODE && 49 container.nodeType !== Node.DOCUMENT_NODE 50 ) { 51 // Get the parent element if it's a text node 52 const parentElement = container.parentElement; 53 if (parentElement && parentElement.hasAttribute("data-current")) { 54 let text_ = selection.toString().trim(); 55 SetClipboard(text_); 56 trace.msg.log("Copied to clipboard!"); 57 return; 58 } 59 } 60 61 // Get all the spans inside the container. 62 const spans = (container as Element).getElementsByTagName("span"); 63 for (let span of spans) { 64 if (selection.containsNode(span, true)) { 65 selectedSpans.push(span as HTMLSpanElement); 66 } 67 } 68 69 // Concat the text of the selected spans. 70 let hasCorrectAttribute = false; 71 let text = ""; 72 selectedSpans.forEach((span) => { 73 if (span.hasAttribute("data-current")) { 74 hasCorrectAttribute = true; 75 text += span.textContent + "\n"; 76 if ([...span.classList].some((className) => className.startsWith("endOfStanza--"))) { 77 text += "\n"; 78 } 79 } 80 }); 81 82 text = text.trim(); 83 84 if (hasCorrectAttribute) { 85 SetClipboard(text); 86 trace.msg.log("Copied to clipboard!"); 87 selection.removeAllRanges(); 88 } 89 } 90 isSelecting = false; 91 } 92 }; 93 94 const onClickHooked = function (event: MouseEvent): boolean | void { 95 if (!isSelecting) return; 96 97 const target = event.target as HTMLElement; 98 if (target.tagName.toLowerCase() === "span" && target.hasAttribute("data-current")) { 99 // Prevent default behavior and stop event propagation 100 event.preventDefault(); 101 event.stopPropagation(); 102 event.stopImmediatePropagation(); 103 return false; 104 } 105 }; 106 107 // Add event listener with capture phase to intercept events before they reach other handlers 108 document.addEventListener("click", onClickHooked, true); 109 document.addEventListener("mousedown", onMouseDown); 110 document.addEventListener("mouseup", onMouseUp); 111 112 // Add cleanup to unloads 113 unloads.add(() => { 114 // Remove event listeners 115 document.removeEventListener("click", onClickHooked, true); 116 document.removeEventListener("mousedown", onMouseDown); 117 document.removeEventListener("mouseup", onMouseUp); 118 });