// ==UserScript==
// @name luvnotes ♡ AO3 comment notepad
// @author luvwich
// @description another floating comment box for ao3
// @icon https://www.google.com/s2/favicons?sz=64&domain=archiveofourown.org
// @include *://archiveofourown.org/*works/*
// @include *://archiveofourown.org/*chapters/*
// @exclude *://archiveofourown.org/*new/*
// @exclude *://archiveofourown.org/*edit/*
// @exclude *://archiveofourown.org/works/new*
// @namespace https://greasyfork.org/en/scripts/532676-luvnotes-ao3-comment-notepad/
// @version 0.1.3
// @run-at document-end
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// ==/UserScript==
const VERSION = "0.1.3";
const URL =
"https://greasyfork.org/en/scripts/532676-luvnotes-ao3-comment-notepad";
const primary = "#0275d8";
const success = "rgba(0,150,0,0.6)";
const danger = "rgba(255,0,0,0.6)";
const MAX_CHAR_LIMIT = 10000;
const MAX_WIDTH = 500;
const PLACEHOLDER_PROMPTS = [
"I really loved when...",
"My absolute favorite part was...",
"This made me feel...",
"The way you described...",
"I really connected with...",
"I was really impressed by...",
"I laughed when...",
"I cried when...",
"Your writing style is...",
"It was so relatable when...",
];
const FONT_FAMILY_SANS =
"system-ui, 'Lucida Grande', 'Lucida Sans Unicode', 'GNU Unifont', Verdana, Helvetica, sans-serif";
const COMMENT_BOX_POSITION_KEY = "commentBoxPosition";
const HIDE_ANNOUNCEMENTS_ENABLED_KEY = "hideAnnouncementsEnabled";
const HIDE_ANNOUNCEMENTS_TIMESTAMP_KEY = "hideAnnouncementsTimestamp";
const ENABLE_HOVER_QUOTE_KEY = "enableHoverQuote";
const ICON_COLOR_KEY = "iconColor";
const css = `
:root {
--full-width: 100%;
--fill-width: -webkit-fill-available;
--full-height: 100%;
--fill-height: 100%;
--color-primary: ${primary};
--color-success: ${success};
--color-danger: ${danger};
--icon-color: ${primary};
--icon-text-color: white;
--icon-border-color: white;
}
@supports (-moz-appearance: none) {
:root {
--fill-width: -moz-available;
--fill-height: -moz-available;
}
}
@media (min-width: 1375px) {
.float-cmt-btn {
font-size: 1em;
}
#openCmtBtn {
font-size: 1.15em;
padding: 2px 4px;
}
}
@media (min-width: 1575px) {
.float-cmt-btn {
font-size: 1em;
}
#openCmtBtn {
font-size: 1.3em;
padding: 4px 8px;
}
}
@media (min-width: 1850px) {
.float-cmt-btn {
font-size: 1.5em;
}
#openCmtBtn {
padding: 5px 10px;
}
}
#LN-commentBox [type='radio'] {
box-shadow: none;
}
#openCmtBtn {
cursor: pointer;
}
#LN-commentBox .tab-button {
box-shadow: none !important;
}
#LN-commentBox #hoverQuoteBtn {
box-shadow: none !important;
}
#LN-commentBox button {
box-shadow: none !important;
}
#LN-commentBox #addCmtBtn {
font-size: 1em;
color: currentColor !important;
}
#openCmtBtn svg {
stroke: var(--icon-text-color) !important;
}
#formatLinkBtn svg {
width: 1em;
height: 1em;
stroke: currentColor !important;
}
#LN-commentBox {
user-select: none;
min-width: 250px;
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: fixed;
z-index: 100;
top: 0px;
bottom: auto;
left: 10px;
right: auto;
width: 300px;
height: 45%;
background-color: var(--comment-bg-color);
overflow-y: hidden;
overflow-x: hidden;
border-radius: 4px;
border: 1px solid var(--comment-main-border-color);
font-size: 0.8em;
max-width: ${MAX_WIDTH}px;
padding-top: 0px;
color: var(--comment-fg-color);
}
#LN-commentBox .formatting-controls button {
font-size: 1em;
width: 1.2em;
height: 1.2em;
text-align: center;
min-width: fit-content;
display: inline-flex;
align-items: center;
justify-content: center;
}
#LN-commentBox input[type="radio"] + label,
#LN-commentBox input[type="checkbox"] + label {
padding-left: 0.5em !important;
font-size: 0.8em !important;
}
#LN-commentBox #previewPanel {
font-size: 1em;
height: var(--fill-height, 100%);
padding: 0.5em;
}
#LN-commentBox .comment-box-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: var(--fill-width, 100%);
justify-content: space-between;
position: relative;
border-top: 1px solid var(--comment-main-border-color);
padding: 0.5em;
}
#LN-commentBox .resize-handle {
width: 15px;
height: 15px;
position: absolute;
right: 0;
top: 0;
z-index: 101;
border-radius: 0 4px 0 0;
opacity: 0.7;
cursor: nesw-resize;
}
#LN-commentBox #commentBoxTitle {
cursor: move;
}
#LN-commentBox .top-controls {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
max-width: 98%;
user-select: none;
padding: 3px;
border-radius: 3px;
margin: 0 0 2px 0;
}
#LN-commentBox .char-count {
font-size: .8em;
margin: 2px 0;
}
#LN-commentBox #draftsPanel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: var(--fill-width, 100%);
padding: 0.25em;
}
#LN-commentBox .drafts-list-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: var(--fill-width, 100%);
max-height: calc(var(--fill-height, 100%) - 50px);
margin: 0.5em;
overflow-y: auto;
}
#LN-commentBox .float-box {
min-height: 40%;
max-height: 60%;
width: var(--fill-width, 98%);
max-width: 98%;
cursor: text;
margin: 0 0 2px 0;
background: var(--comment-box-bg-color);
color: var(--comment-fg-color);
border: 1px solid var(--comment-border-color);
resize: none;
flex: 1;
}
#LN-commentBox button {
color: var(--comment-fg-color) !important;
}
#LN-commentBox button:hover {
cursor: pointer;
}
#LN-commentBox .float-box a:hover {
color: var(--link-hover-color) !important;
}
.float-cmt-btn {
margin: 0 2px;
font-size: 0.8em;
}
.float-cmt-btn, .hover-quote-button {
padding: 2px 4px;
background: var(--comment-button-bg-color);
color: var(--comment-button-fg-color);
border: 1px solid var(--comment-border-color);
transition: background-color 0.15s ease-in-out;
cursor: pointer;
display: inline-flex;
align-items: center;
}
.float-cmt-btn:hover, .hover-quote-button:hover {
filter: brightness(1.2);
}
.float-cmt-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: rgba(0,0,0,0.1);
border-color: rgba(0,0,0,0.1);
}
.float-cmt-btn:disabled:hover {
cursor: not-allowed;
}
#LN-commentBox button:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: rgba(0,0,0,0.1);
border-color: rgba(0,0,0,0.1);
}
#LN-commentBox .radio-div {
display: flex;
align-items: center;
white-space: nowrap;
margin-right: 5px;
}
#LN-commentBox .radio-div label {
margin: 0 3px 0 1px;
color: var(--comment-fg-color);
}
#LN-commentBox .radio-div input[type="radio"] {
margin: 0 2px;
padding: 0;
}
#LN-commentBox {
font-family: ${FONT_FAMILY_SANS} !important;
}
#LN-commentBox .preview-area {
height: var(--fill-height, 100%);
background-color: var(--comment-box-bg-color);
color: var(--comment-fg-color);
padding: 10px;
border-radius: 3px;
margin-top: 5px;
max-height: 300px;
overflow-y: scroll;
border: 1px solid var(--comment-border-color);
font-family: system-ui, "Lucida Grande", "Lucida Sans Unicode", "GNU Unifont", Verdana, Helvetica, sans-serif;
line-height: 1.5;
font-size: 0.875em;
width: var(--fill-width, 100%);
}
#LN-commentBox .preview-area blockquote {
border-left: 2px solid #ccc;
margin-left: 0;
padding-left: 0.5em;
}
#LN-commentBox .preview-area a {
color: var(--color-danger);
text-decoration: none;
}
#LN-commentBox .preview-area a:hover {
text-decoration: underline;
}
#LN-commentBox .window-header {
display: flex;
justify-content: space-between;
width: 100%;
position: relative;
flex-shrink: 0;
}
#LN-commentBox .close-btn {
position: absolute;
border-radius: 3px;
padding: 3px 5px;
cursor: pointer;
font-size: 0.85em;
font-weight: bold;
background: var(--comment-button-bg-color);
color: var(--comment-button-fg-color);
border: 1px solid var(--comment-border-color);
margin: 4px;
}
#LN-commentBox .close-btn:hover {
background: var(--comment-button-hover-bg-color);
}
#LN-commentBox #insCmtBtn {
display: inline-flex;
align-items: center;
justify-content: center;
}
#LN-commentBox #insCmtBtn svg {
display: inline-block;
width: 14px;
height: 14px;
fill: var(--comment-button-fg-color) !important;
margin-bottom: -2px;
}
#insCmtBtn:disabled svg {
opacity: 0.5;
fill: var(--comment-button-fg-color);
vertical-align: middle;
}
#openCmtBtn svg {
display: inline-block;
width: 100%;
height: 100%;
}
.close-btn svg {
display: inline-block;
width: 14px;
height: 14px;
fill: var(--comment-button-fg-color);
vertical-align: middle;
}
#openCmtBtn, #hoverQuoteBtn {
background: var(--icon-color);
border: 1px solid var(--icon-border-color);
transition: opacity 0.15s ease-in-out;
color: var(--icon-text-color);
}
#openCmtBtn:hover {
opacity: 0.8;
}
#LN-commentBox .float-box:focus {
color: var(--comment-fg-color) !important;
background-color: var(--comment-box-bg-color) !important;
border-color: #0275d8;
outline: none;
box-shadow: 0 0 0 1px #0275d8;
}
#LN-commentBox .status-bar {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-top: 3px;
}
#LN-commentBox #colorsDiv {
display: flex;
gap: 1em;
justify-content: flex-start;
}
#LN-commentBox .theme-controls {
display: flex;
flex-direction: column;
}
#LN-commentBox .theme-controls span {
margin-left: 5px;
}
.theme-select {
margin-left: 5px;
background-color: var(--comment-button-bg-color);
color: var(--comment-button-fg-color);
border: 1px solid var(--comment-border-color);
border-radius: 3px;
padding: 2px;
}
.theme-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
background-color: var(--comment-button-hover-bg-color);
}
#LN-commentBox #draftsListArea {
display: none;
max-height: 70%;
overflow: auto;
border: 1px solid var(--comment-border-color);
background-color: var(--comment-box-bg-color);
color: var(--comment-fg-color);
padding: 10px;
margin-top: 5px;
border-radius: 3px;
font-size: 0.9em;
width: var(--fill-width, 100%);
flex: 1;
}
#LN-commentBox .draft-item {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px dashed var(--comment-border-color);
padding: 5px 0;
margin-bottom: 5px;
}
#LN-commentBox .draft-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
#LN-commentBox .draft-info {
display: flex;
flex-direction: column;
flex-grow: 1;
margin-right: 10px;
overflow: hidden;
text-decoration: none;
}
#LN-commentBox .draft-info a {
color: var(--comment-fg-color);
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
border-bottom: none;
overflow: hidden;
display: block;
}
#LN-commentBox .draft-info .draft-date {
font-size: 0.8em;
opacity: 0.8;
margin-top: 2px;
}
#LN-commentBox .draft-delete-btn {
padding: 3px 6px;
font-size: 0.8em;
cursor: pointer;
background-color: rgba(200, 0, 0, 0.7);
color: white;
border: none;
border-radius: 3px;
flex: 0 0 auto;
}
#LN-commentBox .draft-delete-btn:hover {
background-color: rgba(220, 0, 0, 0.8);
}
#LN-commentBox .tab-container {
display: flex;
margin-bottom: 5px;
border-bottom: 1px solid var(--comment-border-color);
}
#LN-commentBox .tab-button {
padding: 5px 10px;
cursor: pointer;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--comment-fg-color);
margin-right: 2px;
font-size: 0.9em;
transition: border-color 0.2s ease-in-out;
}
#LN-commentBox .tab-button:hover {
background-color: var(--comment-button-hover-bg-color);
}
#LN-commentBox .tab-button.active {
border-bottom: 2px solid var(--comment-fg-color);
font-weight: bold;
}
#LN-commentBox .edit-controls-div {
display: flex;
justify-content: space-between;
width: 100%;
margin-top: 5px;
}
#LN-commentBox .edit-controls-div > div {
display: flex;
align-items: center;
}
#LN-commentBox #internalQuoteBtn {
margin-right: 5px;
}
#LN-commentBox .button-success {
background: ${success} !important;
}
#LN-commentBox #settingsArea {
display: none;
overflow: auto;
border: 1px solid var(--comment-border-color);
background-color: var(--comment-box-bg-color);
color: var(--comment-fg-color);
margin-top: 1em;
padding: 0.5em;
border-radius: 3px;
font-size: 0.9em;
width: var(--fill-width, 100%);
flex: 1;
}
#LN-commentBox #settingsPanel {
height: var(--fill-height, 100%);
margin-top: 5px;
padding: 0.5em;
display: flex;
flex-direction: column;
gap: 5px;
}
.draft-badge {
position: absolute;
top: -3px;
right: -3px;
width: 8px;
height: 8px;
background-color: red;
border-radius: 50%;
border: 1px solid white;
z-index: 1;
}
#LN-commentBox .hover-quote-button {
z-index: 9999 !important;
padding: 5px 10px !important;
font-size: 1em !important;
cursor: pointer !important;
color: white !important;
border: 2px solid black !important;
border-radius: 3px !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5) !important;
white-space: nowrap !important;
opacity: 1 !important;
visibility: visible !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
font-weight: bold !important;
top: 50px !important;
left: 50px !important;
}
#LN-commentBox .hover-quote-button:hover {
background-color: var(--comment-button-hover-bg-color) !important;
}
#LN-commentBox .hover-quote-button svg {
display: inline-block !important;
width: 14px !important;
height: 14px !important;
fill: var(--comment-button-fg-color) !important;
vertical-align: middle !important;
margin-bottom: -2px !important;
}
#LN-commentBox .theme-dynamic .hover-quote-button {
background: var(--icon-color) !important;
color: var(--comment-button-fg-color) !important;
border-color: rgba(0, 0, 0, 0.2) !important;
}
#LN-commentBox .theme-dynamic .hover-quote-button:hover {
background: rgba(2, 117, 216, 0.8) !important;
color: white !important;
}
#LN-commentBox .theme-dynamic-dark .hover-quote-button {
background: var(--comment-button-bg-color) !important;
color: var(--comment-button-fg-color) !important;
border-color: var(--comment-border-color) !important;
}
#workskin {
position: relative !important;
}
.quote-icon {
width: 0.8em;
height: 0.8em;
fill: currentColor;
stroke: none;
}
`;
// SVG icons from https://lucide.dev
const quoteSvg = () => {
return `<svg class="quote-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-quote-icon lucide-quote"><path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/></svg>`;
};
const linkSvg = () => {
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link-icon lucide-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
};
const openIconSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle-heart-icon lucide-message-circle-heart"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/><path d="M15.8 9.2a2.5 2.5 0 0 0-3.5 0l-.3.4-.35-.3a2.42 2.42 0 1 0-3.2 3.6l3.6 3.5 3.6-3.5c1.2-1.2 1.1-2.7.2-3.7"/></svg>
`;
(function () {
"use strict";
/* ---- THEME-RELATED CODE ---- */
/* you could add a custom theme here if you like!
by default the script will attempt to create a theme based on the page's background,
so it will match your AO3 site skin */
const ThemeManager = (() => {
const themes = {
light: {
bg: "rgba(248, 248, 248, 1)",
fg: "rgba(30, 30, 30, 1)",
border: "rgba(200, 200, 200, 1)",
boxBg: "rgba(255, 255, 255, 1)",
headerBg: "rgba(230, 230, 230, 1)",
titleBarBg: "rgba(230, 230, 230, 1)",
buttonHoverBg: "rgba(220, 220, 220, 1)",
buttonBg: "rgba(235, 235, 235, 1)",
buttonFg: "rgba(30, 30, 30, 1)",
mainBorder: "rgba(0,0,0,0.3)",
linkHoverColor: "rgba(30, 30, 30, 0.2)",
iconColor: "rgba(30, 30, 30, 1)",
},
dark: {
bg: "rgba(45, 45, 45, 1)",
fg: "rgba(235, 235, 235, 1)",
border: "rgba(70, 70, 70, 1)",
boxBg: "rgba(55, 55, 55, 1)",
headerBg: "rgba(60, 60, 60, 1)",
titleBarBg: "rgba(40, 40, 40, 1)",
buttonHoverBg: "rgba(80, 80, 80, 1)",
buttonBg: "rgba(65, 65, 65, 1)",
buttonFg: "rgba(235, 235, 235, 1)",
mainBorder: "rgba(255,255,255,0.3)",
linkHoverColor: "rgba(235, 235, 235, 0.2)",
iconColor: "rgba(235, 235, 235, 1)",
},
};
let currentThemeColors = {};
const parseRgba = (rgbaString) => {
if (!rgbaString) return null;
const match = rgbaString.match(
/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/
);
if (!match) return null;
return {
r: parseInt(match[1]),
g: parseInt(match[2]),
b: parseInt(match[3]),
a: match[4] ? parseFloat(match[4]) : 1,
};
};
const toRgbaString = (rgbaObject) => {
if (!rgbaObject) return null;
return `rgba(${rgbaObject.r}, ${rgbaObject.g}, ${rgbaObject.b}, ${rgbaObject.a})`;
};
const adjustLightness = (value, factor) => {
return Math.max(0, Math.min(255, Math.round(value * factor)));
};
const adjustRgbColor = (rgbObject, factor) => {
if (!rgbObject) return null;
return {
r: adjustLightness(rgbObject.r, factor),
g: adjustLightness(rgbObject.g, factor),
b: adjustLightness(rgbObject.b, factor),
};
};
const isColorLight = (rgbObject) => {
if (!rgbObject) return true;
const luminance =
(0.299 * rgbObject.r + 0.587 * rgbObject.g + 0.114 * rgbObject.b) / 255;
return luminance > 0.5;
};
const getContrastColor = (colorString) => {
if (colorString.startsWith("#")) {
const r = parseInt(colorString.substring(1, 3), 16);
const g = parseInt(colorString.substring(3, 5), 16);
const b = parseInt(colorString.substring(5, 7), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "rgba(0, 0, 0, 1)" : "rgba(255, 255, 255, 1)";
}
const parsedColor = parseRgba(colorString);
if (parsedColor) {
return isColorLight(parsedColor)
? "rgba(0, 0, 0, 1)"
: "rgba(255, 255, 255, 1)";
}
return "rgba(255, 255, 255, 1)";
};
const parseHex = (hexString) => {
const r = parseInt(hexString.substring(1, 3), 16);
const g = parseInt(hexString.substring(3, 5), 16);
const b = parseInt(hexString.substring(5, 7), 16);
return { r, g, b };
};
const parseColorString = (colorString) => {
if (colorString.startsWith("#")) {
return parseHex(colorString);
}
return parseRgba(colorString);
};
const getBorderColor = (colorString) => {
const parsedColor = parseColorString(colorString);
const isLight = isColorLight(parsedColor);
const rgb = isLight
? adjustRgbColor(parsedColor, 0.5)
: adjustRgbColor(parsedColor, 1.2);
return toRgbaString({ ...rgb, a: 1 });
};
const generateDynamicTheme = (baseBgString, baseFgString) => {
const baseBg = parseRgba(baseBgString);
const baseFg = parseRgba(baseFgString);
if (!baseBg || !baseFg) {
return themes.dark;
}
const isBgLight = isColorLight(baseBg);
const bgFactor = isBgLight ? 0.98 : 1.5;
const borderFactor = isBgLight ? 0.85 : 1.6;
const headerFactor = isBgLight ? 0.9 : 1.6;
const buttonFactor = isBgLight ? 0.92 : 1.3;
const hoverFactor = isBgLight ? 0.88 : 1.4;
const dynamicTheme = {
bg: toRgbaString({
...adjustRgbColor(baseBg, isBgLight ? 0.95 : 1.2),
a: 1,
}),
fg: toRgbaString({ ...baseFg, a: 1 }),
border: toRgbaString({ ...adjustRgbColor(baseBg, borderFactor), a: 1 }),
boxBg: toRgbaString({ ...adjustRgbColor(baseBg, bgFactor), a: 1 }),
headerBg: toRgbaString({
...adjustRgbColor(baseBg, headerFactor),
a: 1,
}),
titleBarBg: toRgbaString({
...adjustRgbColor(baseBg, headerFactor),
a: 1,
}),
buttonBg: toRgbaString({
...adjustRgbColor(baseBg, buttonFactor),
a: 1,
}),
buttonHoverBg: toRgbaString({
...adjustRgbColor(baseBg, hoverFactor),
a: 1,
}),
buttonFg: toRgbaString({ ...baseFg, a: 1 }),
linkHoverColor: toRgbaString({ ...baseFg, a: 1 }),
mainBorder: isBgLight ? "rgba(0,0,0,0.4)" : "rgba(255,255,255,0.3)",
iconColor: primary,
};
return dynamicTheme;
};
const getPageBaseColors = () => {
const mainElement = document.querySelector("#main");
const bodyElement = document.querySelector("body");
if (mainElement && bodyElement) {
const mainStyles = window.getComputedStyle(mainElement);
const bodyStyles = window.getComputedStyle(bodyElement);
let bgColor = mainStyles.backgroundColor;
const fgColor = bodyStyles.color;
const parsedMainBg = parseRgba(bgColor);
if (parsedMainBg && parsedMainBg.a < 0.1) {
bgColor = bodyStyles.backgroundColor;
const parsedBodyBg = parseRgba(bgColor);
if (parsedBodyBg && parsedBodyBg.a < 0.1) {
bgColor = "rgba(255, 255, 255, 1)";
}
}
return { bgColor, fgColor };
} else {
console.error("#main or body element not found.");
return {
bgColor: "rgba(255, 255, 255, 1)",
fgColor: "rgba(0, 0, 0, 1)",
};
}
};
const updateThemeStyles = (
theme,
themeName,
iconColor,
isBgLight = null
) => {
currentThemeColors = theme;
const root = document.documentElement;
root.style.setProperty("--comment-bg-color", currentThemeColors.bg);
root.style.setProperty("--comment-fg-color", currentThemeColors.fg);
root.style.setProperty(
"--comment-border-color",
currentThemeColors.border
);
root.style.setProperty(
"--comment-box-bg-color",
currentThemeColors.boxBg
);
root.style.setProperty(
"--comment-header-bg-color",
currentThemeColors.headerBg
);
root.style.setProperty(
"--comment-title-bar-bg-color",
currentThemeColors.titleBarBg
);
root.style.setProperty(
"--comment-button-hover-bg-color",
currentThemeColors.buttonHoverBg
);
root.style.setProperty(
"--comment-button-bg-color",
currentThemeColors.buttonBg
);
root.style.setProperty(
"--comment-button-fg-color",
currentThemeColors.buttonFg
);
root.style.setProperty(
"--comment-main-border-color",
currentThemeColors.mainBorder || themes.dark.mainBorder
);
root.style.setProperty(
"--link-hover-color",
currentThemeColors.linkHoverColor || themes.dark.linkHoverColor
);
root.style.setProperty("--icon-color", iconColor);
const iconTextColor = getContrastColor(iconColor);
root.style.setProperty("--icon-text-color", iconTextColor);
const iconBorderColor = getBorderColor(iconColor);
root.style.setProperty("--icon-border-color", iconBorderColor);
const mainDiv = document.getElementById("LN-commentBox");
if (mainDiv) {
mainDiv.classList.remove(
"theme-light",
"theme-dark",
"theme-dynamic",
"theme-dynamic-light",
"theme-dynamic-dark"
);
mainDiv.classList.add(`theme-${themeName}`);
if (themeName === "dynamic" && isBgLight !== null) {
mainDiv.classList.add(
isBgLight ? "theme-dynamic-light" : "theme-dynamic-dark"
);
}
}
};
const applyTheme = async () => {
const manualPref = await GM.getValue("manualThemePreference", "dynamic");
const iconPref = await GM.getValue(ICON_COLOR_KEY, primary);
let themeToApply;
let themeName;
let appliedPreference = manualPref;
let isBgLight = null;
if (manualPref === "dynamic") {
const { bgColor, fgColor } = getPageBaseColors();
if (bgColor && fgColor) {
const baseBg = parseRgba(bgColor);
if (baseBg) {
isBgLight = isColorLight(baseBg);
}
themeToApply = generateDynamicTheme(bgColor, fgColor);
themeName = "dynamic";
} else {
const useDarkSystem =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
themeToApply = useDarkSystem ? themes.dark : themes.light;
themeName = useDarkSystem ? "dark" : "light";
appliedPreference = null;
}
} else if (manualPref === "dark") {
themeToApply = themes.dark;
themeName = "dark";
isBgLight = false;
} else if (manualPref === "light") {
themeToApply = themes.light;
themeName = "light";
isBgLight = true;
} else {
const useDarkSystem =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
themeToApply = useDarkSystem ? themes.dark : themes.light;
themeName = useDarkSystem ? "dark" : "light";
appliedPreference = null;
isBgLight = !useDarkSystem;
}
updateThemeStyles(themeToApply, themeName, iconPref, isBgLight);
const themeSelect = document.getElementById("themeSelectDropdown");
if (themeSelect) {
if (appliedPreference) {
themeSelect.value = appliedPreference;
} else {
themeSelect.value = themeName;
}
}
};
const setThemeManually = async (themeName) => {
const iconPref = await GM.getValue(ICON_COLOR_KEY, primary);
let themeToApply;
if (themeName === "light") {
themeToApply = themes.light;
await GM.setValue("manualThemePreference", "light");
} else if (themeName === "dark") {
themeToApply = themes.dark;
await GM.setValue("manualThemePreference", "dark");
} else if (themeName === "dynamic") {
const { bgColor, fgColor } = getPageBaseColors();
if (bgColor && fgColor) {
themeToApply = generateDynamicTheme(bgColor, fgColor);
await GM.setValue("manualThemePreference", "dynamic");
} else {
applyTheme();
return;
}
} else {
return;
}
updateThemeStyles(themeToApply, themeName, iconPref);
};
return {
applyTheme,
setThemeManually,
getContrastColor,
getBorderColor,
getCurrentThemeColors: () => currentThemeColors,
};
})();
async function applySavedCommentBoxPosition(element) {
const savedPosition = await GM.getValue(COMMENT_BOX_POSITION_KEY, null);
if (savedPosition) {
try {
const position = JSON.parse(savedPosition);
if (position.top) element.style.top = position.top;
if (position.left) element.style.left = position.left;
if (position.left && position.left !== "auto") {
element.style.right = "auto";
} else if (position.right && position.right !== "auto") {
element.style.left = "auto";
element.style.right = position.right;
}
if (position.top && position.top !== "auto") {
element.style.bottom = "auto";
} else if (position.bottom && position.bottom !== "auto") {
element.style.top = "auto";
element.style.bottom = position.bottom;
}
} catch (e) {
console.error("Error applying saved comment box position:", e);
}
} else {
element.style.top = "10px";
element.style.left = "10px";
element.style.right = "auto";
element.style.bottom = "auto";
}
}
let curURL = document.URL;
if (curURL.includes("#")) {
curURL = document.URL.slice(0, document.URL.indexOf("#"));
}
let newURL = curURL;
const DRAFTS_STORAGE_KEY = "allCommentDrafts";
let currentWorkId = null;
async function checkWorkDraftExists(workId) {
if (!workId) return false;
const drafts = await getAllDrafts();
for (const url in drafts) {
const draftUrlMatch =
url.match(/\/works\/(\d+)/) || url.match(/\/chapters\/(\d+)/);
if (
draftUrlMatch &&
draftUrlMatch[1] === workId &&
drafts[url]?.text?.trim()
) {
return true;
}
}
return false;
}
async function updateOpenButtonBadge() {
if (typeof currentWorkId === "undefined" || !currentWorkId) return;
const openBtn = document.getElementById("openCmtBtn");
if (!openBtn) return;
const badgeId = "openCmtBtnBadge";
let badge = openBtn.querySelector(`#${badgeId}`);
const draftExists = await checkWorkDraftExists(currentWorkId);
if (draftExists) {
if (!badge) {
badge = document.createElement("span");
badge.id = badgeId;
badge.className = "draft-badge";
openBtn.appendChild(badge);
openBtn.style.overflow = "visible";
}
} else {
if (badge) {
badge.remove();
}
}
}
const addStyles = () => {
const styles = document.createElement("style");
styles.id = "comment-box-styles";
styles.innerHTML = css + "\n" + addMediaStyles();
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", async () => {
const manualPref = await GM.getValue("manualThemePreference", null);
if (!manualPref) {
ThemeManager.applyTheme();
}
});
return styles;
};
const addMediaStyles = () => {
const mediaCssString = Object.entries(mediaStyles)
.map(([mediaQuery, selectors]) => {
const selectorRules = Object.entries(selectors)
.map(([selector, properties]) => {
const propertyRules = Object.entries(properties)
.map(([key, value]) => ` ${key}: ${value};`)
.join("\n");
return `${selector} {\n${propertyRules}\n}`;
})
.join("\n\n");
return `${mediaQuery} {\n${selectorRules}\n}`;
})
.join("\n\n");
return mediaCssString;
};
const mediaStyles = {
"@media (min-width: 1375px)": {
".float-cmt-btn": {
"font-size": "1em",
},
"#openCmtBtn": {
"font-size": "1.15em",
padding: "2px 4px",
},
},
"@media (min-width: 1575px)": {
".float-cmt-btn": {
"font-size": "1em",
},
"#openCmtBtn": {
"font-size": "1.3em",
padding: "4px 8px",
},
},
"@media (min-width: 1850px)": {
".float-cmt-btn": {
"font-size": "1.1em",
},
"#openCmtBtn": {
padding: "5px 10px",
},
},
};
/* DRAFT STORAGE */
async function getAllDrafts() {
try {
const draftsJson = await GM.getValue(DRAFTS_STORAGE_KEY, "{}");
return JSON.parse(draftsJson);
} catch (e) {
console.error("Error parsing drafts JSON:", e);
return {};
}
}
async function saveDraft(url, text) {
if (!url) return;
try {
const drafts = await getAllDrafts();
const titleElement = document.querySelector("h2.title.heading");
const authorElement = document.querySelector("h3.byline");
const chapterTitleElement = document.querySelector(
"div#chapters h3.title"
);
const workTitle = titleElement
? titleElement.textContent.trim()
: "Unknown Title";
const workAuthor = authorElement
? authorElement.textContent.trim()
: "Unknown Author";
const chapterTitle = chapterTitleElement
? chapterTitleElement.textContent.trim()
: null;
let scope = "full";
const chapterRadio = document.getElementById("chapterCmt");
if (url.includes("chapters") && chapterRadio && chapterRadio.checked) {
scope = "chapter";
} else if (!url.includes("chapters")) {
scope = "full";
}
const chapterId = url.match(/\/chapters\/(\d+)/)?.[1];
const workId = url.match(/\/works\/(\d+)/)?.[1];
const urlToUse =
chapterId && !workId
? url.replace(
/\/chapters\/(\d+)/,
`/works/${currentWorkId}/chapters/${chapterId}`
)
: url;
drafts[urlToUse] = {
text: text,
lastEdited: Date.now(),
title: workTitle,
author: workAuthor,
chapterTitle: chapterTitle,
scope: scope,
};
await GM.setValue(DRAFTS_STORAGE_KEY, JSON.stringify(drafts));
} catch (error) {
console.error("Error saving draft:", error);
}
}
async function getDraft(url) {
if (!url) return null;
const drafts = await getAllDrafts();
return drafts[url] || null;
}
async function deleteDraft(url) {
if (!url) return;
try {
const drafts = await getAllDrafts();
if (drafts[url]) {
delete drafts[url];
await GM.setValue(DRAFTS_STORAGE_KEY, JSON.stringify(drafts));
}
} catch (error) {
console.error("Error deleting draft:", error);
}
}
const createBox = () => {
const textBox = document.createElement("textarea");
textBox.placeholder =
PLACEHOLDER_PROMPTS[
Math.floor(Math.random() * PLACEHOLDER_PROMPTS.length)
];
textBox.className = "float-box";
textBox.rows = 10;
textBox.addEventListener("keyup", async () => {
const text = textBox.value;
await saveDraft(newURL, text);
const addBtn = document.querySelector("#addCmtBtn");
if (addBtn) {
addBtn.disabled = text.trim().length === 0;
if (!addBtn.classList.contains("button-success") && !addBtn.disabled) {
addBtn.textContent = "Add to comment ⇲";
addBtn.title = "Add to AO3's comment box";
}
}
updateCharacterCount();
await updateOpenButtonBadge();
});
textBox.addEventListener("input", () => {
const addBtn = document.querySelector("#addCmtBtn");
if (addBtn) {
addBtn.disabled = textBox.value.trim().length === 0;
}
});
return textBox;
};
/* ---- UI ELEMENTS ---- */
let wasDragged = false;
const DRAG_COOLDOWN = 200;
const createStyledButton = ({
id,
className,
text,
title,
innerHTML,
onClick,
disabled = false,
styles = {},
attributes = {},
}) => {
const button = document.createElement("button");
Object.assign(button, {
id,
className,
textContent: text,
disabled,
});
if (title) button.title = title;
if (innerHTML) button.innerHTML = innerHTML;
if (onClick) button.addEventListener("click", onClick);
Object.assign(button.style, styles);
Object.entries(attributes).forEach(([key, value]) => {
button.setAttribute(key, value);
});
return button;
};
const createHoverQuoteButton = ({ id, styles = {} }) => {
const quoteButton = createStyledButton({
id,
className: "hover-quote-button",
innerHTML: quoteSvg() + '<span style="margin-left: 4px;">Quote</span>',
title: "Add selection as blockquote",
attributes: { "aria-label": "Quote selected text" },
styles: { display: "none", ...styles },
onClick: async () => {
const selection = window.getSelection();
const selectedText = selection?.toString()?.trim();
if (selectedText) {
const quotedText = `<blockquote>${selectedText}</blockquote>`;
const draggableBox = document.getElementById("LN-commentBox");
const openBtn = document.getElementById("openCmtBtn");
if (!draggableBox) {
if (openBtn) {
openBtn.click();
}
await new Promise((resolve) => setTimeout(resolve, 50));
} else if (draggableBox.style.display === "none") {
draggableBox.style.display = "flex";
if (openBtn) openBtn.style.display = "none";
}
switchToEditTab();
setTimeout(async () => {
const floatBox = document.querySelector(".float-box");
if (floatBox) {
const currentText = floatBox.value;
const separator = currentText && currentText.trim() ? "\n\n" : "";
floatBox.focus();
if (
!document.execCommand(
"insertText",
false,
(currentText ? separator : "") + quotedText
)
) {
const newText = `${currentText}${separator}${quotedText}`;
floatBox.value = newText;
}
await saveDraft(newURL, floatBox.value);
updateCharacterCount();
await updateOpenButtonBadge();
triggerInputEvent(floatBox);
floatBox.focus();
floatBox.setSelectionRange(
floatBox.value.length,
floatBox.value.length
);
}
}, 50);
quoteButton.style.setProperty("display", "none", "important");
selection?.removeAllRanges();
}
},
});
return quoteButton;
};
const createButton = () => {
const newButton = createStyledButton({
id: "openCmtBtn",
innerHTML: openIconSvg,
title: "Open comment box",
styles: {
display: "flex",
alignItems: "center",
justifyContent: "center",
lineHeight: "1",
},
onClick: () => {
if (wasDragged) {
return;
}
const div = document.getElementById("LN-commentBox");
if (!div) return;
div.style.display = "flex";
newButton.style.display = "none";
const textBox = div.querySelector(".float-box");
if (textBox) {
textBox.focus();
textBox.scrollTop = textBox.scrollHeight;
}
},
});
return newButton;
};
const createThemeControls = () => {
const controlsDiv = document.createElement("div");
controlsDiv.className = "theme-controls";
const themeLabel = document.createElement("span");
themeLabel.textContent = "Theme:";
themeLabel.style.marginRight = "3px";
const themeSelect = document.createElement("select");
themeSelect.id = "themeSelectDropdown";
themeSelect.className = "theme-select";
const themesToCreate = {
light: "Light",
dark: "Dark",
dynamic: "Match page",
};
Object.entries(themesToCreate).forEach(([value, text]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
themeSelect.appendChild(option);
});
themeSelect.addEventListener("change", async (event) => {
await ThemeManager.setThemeManually(event.target.value);
});
controlsDiv.appendChild(themeLabel);
controlsDiv.appendChild(themeSelect);
return controlsDiv;
};
const closeCommentBox = async (mainDiv) => {
mainDiv.style.display = "none";
const openBtn = document.querySelector("#openCmtBtn");
if (openBtn) {
openBtn.style.display = "block";
}
await updateOpenButtonBadge();
};
const createWindowHeader = (mainDiv) => {
const windowHeader = document.createElement("div");
windowHeader.className = "window-header";
windowHeader.id = "commentBoxHeader";
const closeButton = createStyledButton({
className: "close-btn",
text: "✕",
title: "Close window",
attributes: { "aria-label": "Close comment box" },
onClick: async () => {
await closeCommentBox(mainDiv);
},
});
const headerTitle = document.createElement("div");
headerTitle.textContent = "Comment drafts";
headerTitle.style.fontWeight = "bold";
headerTitle.style.textAlign = "center";
headerTitle.style.lineHeight = "24px";
headerTitle.style.userSelect = "none";
headerTitle.style.margin = "4px";
headerTitle.style.flexBasis = "100%";
headerTitle.id = "commentBoxTitle";
windowHeader.appendChild(closeButton);
windowHeader.appendChild(headerTitle);
const resizeHandle = document.createElement("div");
resizeHandle.className = "resize-handle";
windowHeader.appendChild(resizeHandle);
return windowHeader;
};
const createTopControlsBar = () => {
const topControls = document.createElement("div");
topControls.className = "top-controls";
topControls.appendChild(chapterRadio());
return topControls;
};
const createTabNavigation = () => {
const tabContainer = document.createElement("div");
tabContainer.className = "tab-container";
tabContainer.setAttribute("role", "tablist");
tabContainer.setAttribute("aria-labelledby", "commentBoxTitle");
const editTabButton = createStyledButton({
text: "Edit",
className: "tab-button active",
id: "editTab",
attributes: {
role: "tab",
"aria-selected": "true",
"aria-controls": "editPanel",
},
});
const previewTabButton = createStyledButton({
text: "Preview",
className: "tab-button",
id: "previewTab",
attributes: {
role: "tab",
"aria-selected": "false",
"aria-controls": "previewPanel",
},
});
const draftsTabButton = createStyledButton({
text: "Drafts",
className: "tab-button",
id: "draftsTab",
styles: { marginLeft: "auto" },
attributes: {
role: "tab",
"aria-selected": "false",
"aria-controls": "draftsPanel",
},
});
const settingsTabButton = createStyledButton({
text: "Settings",
className: "tab-button",
id: "settingsTab",
attributes: {
role: "tab",
"aria-selected": "false",
"aria-controls": "settingsPanel",
},
});
tabContainer.appendChild(editTabButton);
tabContainer.appendChild(previewTabButton);
tabContainer.appendChild(draftsTabButton);
tabContainer.appendChild(settingsTabButton);
return {
tabContainer,
editTabButton,
previewTabButton,
draftsTabButton,
settingsTabButton,
};
};
const createEditControlsBar = (textBox) => {
const editControlsDiv = document.createElement("div");
editControlsDiv.className = "edit-controls-div";
editControlsDiv.id = "editControls";
editControlsDiv.style.display = "flex";
editControlsDiv.style.justifyContent = "space-between";
const leftControls = document.createElement("div");
leftControls.style.display = "flex";
leftControls.style.alignItems = "center";
const internalQuoteButton = createInternalQuoteButton(textBox);
leftControls.appendChild(internalQuoteButton);
const rightControls = document.createElement("div");
rightControls.appendChild(addButton());
editControlsDiv.appendChild(leftControls);
editControlsDiv.appendChild(rightControls);
return editControlsDiv;
};
const createStatusBar = (formattingControls) => {
const charCountElement = document.createElement("div");
charCountElement.className = "char-count";
charCountElement.textContent = `Characters left: ${MAX_CHAR_LIMIT}`;
charCountElement.setAttribute("aria-live", "polite");
const statusBar = document.createElement("div");
statusBar.className = "status-bar";
statusBar.appendChild(formattingControls);
return { statusBar, charCountElement };
};
function switchToEditTab() {
const editTabButton = document.getElementById("editTab");
if (!editTabButton) return false;
if (!editTabButton.classList.contains("active")) {
editTabButton.click();
}
return true;
}
function triggerInputEvent(element) {
if (!element) return;
const event = new Event("input", {
bubbles: true,
cancelable: true,
});
element.dispatchEvent(event);
}
function setupHoverQuoteButton() {
const workskinElement = document.getElementById("workskin");
if (!workskinElement) {
console.error("Could not find #workskin for hover quote button setup!");
return null;
}
const hoverQuoteButton = createHoverQuoteButton({
id: "hoverQuoteBtn",
styles: { padding: "4px 6px !important" },
onClick: async () => {
const selection = window.getSelection();
const selectedText = selection?.toString()?.trim();
if (selectedText) {
const quotedText = `<blockquote>${selectedText}</blockquote>`;
const draggableBox = document.getElementById("LN-commentBox");
const openBtn = document.getElementById("openCmtBtn");
if (!draggableBox) {
if (openBtn) {
openBtn.click();
}
await new Promise((resolve) => setTimeout(resolve, 50));
} else if (draggableBox.style.display === "none") {
draggableBox.style.display = "flex";
if (openBtn) openBtn.style.display = "none";
}
switchToEditTab();
setTimeout(async () => {
const floatBox = document.querySelector(".float-box");
if (floatBox) {
const currentText = floatBox.value;
const separator = currentText && currentText.trim() ? "\n\n" : "";
const newText = `${currentText}${separator}${quotedText}`;
floatBox.value = newText;
await saveDraft(newURL, newText);
updateCharacterCount();
await updateOpenButtonBadge();
triggerInputEvent(floatBox);
floatBox.focus();
floatBox.setSelectionRange(newText.length, newText.length);
}
}, 50);
hoverQuoteButton.style.setProperty("display", "none", "important");
selection?.removeAllRanges();
}
},
});
hoverQuoteButton.style.display = "none";
hoverQuoteButton.style.position = "absolute";
hoverQuoteButton.style.zIndex = "9999";
document.body.appendChild(hoverQuoteButton);
let currentSelectionRange = null;
document.addEventListener("mouseup", async () => {
const enableHoverQuote = await GM.getValue(ENABLE_HOVER_QUOTE_KEY, true);
if (!enableHoverQuote) {
hoverQuoteButton.style.setProperty("display", "none", "important");
return;
}
const currentSelection = window.getSelection();
const selectionText = currentSelection?.toString()?.trim();
if (
!currentSelection ||
currentSelection.isCollapsed ||
currentSelection.rangeCount === 0 ||
!selectionText
) {
hoverQuoteButton.style.setProperty("display", "none", "important");
currentSelectionRange = null;
return;
}
let container = currentSelection.getRangeAt(0).commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentNode;
}
const isInWorkskin = workskinElement.contains(container);
if (!isInWorkskin) {
hoverQuoteButton.style.setProperty("display", "none", "important");
currentSelectionRange = null;
return;
}
currentSelectionRange = currentSelection.getRangeAt(0);
positionHoverQuoteButton(currentSelectionRange, hoverQuoteButton);
setTimeout(() => {
if (window.getSelection().toString().trim()) {
hoverQuoteButton.style.setProperty(
"display",
"inline-flex",
"important"
);
hoverQuoteButton.style.setProperty("opacity", "1", "important");
hoverQuoteButton.style.setProperty(
"visibility",
"visible",
"important"
);
}
}, 10);
});
window.addEventListener(
"scroll",
() => {
if (
currentSelectionRange &&
hoverQuoteButton.style.display !== "none"
) {
positionHoverQuoteButton(currentSelectionRange, hoverQuoteButton);
}
},
{ passive: true }
);
function positionHoverQuoteButton(range, button) {
const rect = range.getBoundingClientRect();
const offset = 16;
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const buttonTop = Math.min(
Math.max(
rect.bottom + window.pageYOffset + offset,
window.pageYOffset + 50
),
window.pageYOffset + viewportHeight - 50
);
const buttonLeft = Math.min(
Math.max(
rect.left + window.pageXOffset + offset,
window.pageXOffset + 50
),
window.pageXOffset + viewportWidth - 150
);
button.style.top = `${buttonTop}px`;
button.style.left = `${buttonLeft}px`;
}
document.addEventListener("mousedown", (event) => {
if (
hoverQuoteButton.style.display === "inline-flex" &&
!hoverQuoteButton.contains(event.target)
) {
const selection = window.getSelection();
if (!selection.isCollapsed && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const MARGIN = 10;
if (
event.clientX < rect.left - MARGIN ||
event.clientX > rect.right + MARGIN ||
event.clientY < rect.top - MARGIN ||
event.clientY > rect.bottom + MARGIN
) {
if (!workskinElement.contains(event.target)) {
hoverQuoteButton.style.setProperty(
"display",
"none",
"important"
);
currentSelectionRange = null;
}
}
} else {
hoverQuoteButton.style.setProperty("display", "none", "important");
currentSelectionRange = null;
}
}
});
document.addEventListener("selectionchange", () => {
const selection = window.getSelection();
const selectionText = selection?.toString()?.trim();
if (!selectionText) {
hoverQuoteButton.style.setProperty("display", "none", "important");
currentSelectionRange = null;
}
});
return hoverQuoteButton;
}
const createInternalQuoteButton = (textBox) => {
const isValidSelection = (selection) => {
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
let container = selection.getRangeAt(0).commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentNode;
}
const userStuff = document.getElementById("workskin");
return (
userStuff &&
userStuff.contains(container) &&
selection.toString().trim().length > 0
);
};
const quoteButton = createStyledButton({
id: "internalQuoteBtn",
className: "float-cmt-btn format-btn internal-quote-btn",
innerHTML: quoteSvg() + '<span style="margin-left: 4px;">Quote</span>',
title: "Add selection as blockquote (Ctrl+Q - Not implemented)",
attributes: { "aria-label": "Quote selected text from formatting bar" },
onClick: async () => {
const selection = window.getSelection();
if (!isValidSelection(selection)) {
return;
}
switchToEditTab();
const selectedText = selection.toString().trim();
const quotedText = `<blockquote>${selectedText}</blockquote>`;
setTimeout(async () => {
const currentText = textBox.value;
const separator = currentText && currentText.trim() ? "\n\n" : "";
textBox.focus();
if (
!document.execCommand(
"insertText",
false,
(currentText ? separator : "") + quotedText
)
) {
const newText = `${currentText}${separator}${quotedText}`;
textBox.value = newText;
}
await saveDraft(newURL, textBox.value);
updateCharacterCount();
await updateOpenButtonBadge();
triggerInputEvent(textBox);
textBox.focus();
textBox.setSelectionRange(textBox.value.length, textBox.value.length);
}, 50);
selection.removeAllRanges();
},
});
const checkInternalQuoteSelection = () => {
const internalQuoteButton = document.getElementById("internalQuoteBtn");
if (
!internalQuoteButton ||
internalQuoteButton.style.display === "none"
) {
return;
}
internalQuoteButton.disabled = !isValidSelection(window.getSelection());
};
document.addEventListener("mouseup", checkInternalQuoteSelection);
document.addEventListener("keyup", checkInternalQuoteSelection);
textBox.addEventListener("focus", checkInternalQuoteSelection);
document.addEventListener("selectionchange", checkInternalQuoteSelection);
setTimeout(() => {
const settingsCheckbox = document.getElementById(
"disableHoverQuoteCheckbox"
);
if (settingsCheckbox) {
settingsCheckbox.removeEventListener(
"change",
checkInternalQuoteSelection
);
settingsCheckbox.addEventListener(
"change",
checkInternalQuoteSelection
);
}
}, 0);
checkInternalQuoteSelection();
return quoteButton;
};
const createFormattingControls = (textBox) => {
const controlsDiv = document.createElement("div");
controlsDiv.className = "formatting-controls";
const formatButtons = {};
const applyFormat = async (tag) => {
const start = textBox.selectionStart;
const end = textBox.selectionEnd;
const isEmptySelection = start === end;
const selectedText = isEmptySelection
? ""
: textBox.value.substring(start, end);
const formattedText = isEmptySelection
? `<${tag}></${tag}>`
: `<${tag}>${selectedText}</${tag}>`;
textBox.focus();
if (!document.execCommand("insertText", false, formattedText)) {
const beforeText = textBox.value.substring(0, start);
const afterText = textBox.value.substring(end);
textBox.value = beforeText + formattedText + afterText;
}
const newStartPos = start + tag.length + 2;
const newEndPos = isEmptySelection
? newStartPos
: end + tag.length * 2 + 5;
textBox.setSelectionRange(newStartPos, newEndPos);
await saveDraft(newURL, textBox.value);
updateCharacterCount();
await updateOpenButtonBadge();
};
formatButtons.bold = createStyledButton({
id: "formatBoldBtn",
className: "float-cmt-btn format-btn",
innerHTML:
"<span style='font-weight: bold; font-family: serif;'>B</span>",
title: "Bold (Ctrl+B)",
styles: { fontWeight: "bold", fontFamily: "serif" },
onClick: () => applyFormat("strong"),
});
formatButtons.italic = createStyledButton({
id: "formatItalicBtn",
className: "float-cmt-btn format-btn",
innerHTML:
"<span style='font-style: italic; font-family: serif;'>I</span>",
title: "Italic (Ctrl+I)",
styles: { fontStyle: "italic", fontFamily: "serif" },
onClick: () => applyFormat("em"),
});
formatButtons.strike = createStyledButton({
id: "formatStrikeBtn",
className: "float-cmt-btn format-btn",
innerHTML:
"<span style='text-decoration: line-through; font-family: serif;'>S</span>",
title: "Strikethrough (Ctrl+S)",
styles: { textDecoration: "line-through", fontFamily: "serif" },
onClick: () => applyFormat("del"),
});
formatButtons.link = createStyledButton({
id: "formatLinkBtn",
className: "float-cmt-btn format-btn",
innerHTML: linkSvg(),
title: "Add link (Ctrl+L)",
onClick: async () => {
const start = textBox.selectionStart;
const end = textBox.selectionEnd;
const selectedText = textBox.value.substring(start, end);
const url = prompt("Enter URL:", "https://");
if (!url) return;
const linkText = selectedText || "link text";
const linkHtml = `<a href="${url}">${linkText}</a>`;
textBox.focus();
if (!document.execCommand("insertText", false, linkHtml)) {
const beforeText = textBox.value.substring(0, start);
const afterText = textBox.value.substring(end);
textBox.value = beforeText + linkHtml + afterText;
}
await saveDraft(newURL, textBox.value);
updateCharacterCount();
await updateOpenButtonBadge();
},
});
controlsDiv.style.marginTop = "5px";
controlsDiv.appendChild(formatButtons.bold);
controlsDiv.appendChild(formatButtons.italic);
controlsDiv.appendChild(formatButtons.strike);
controlsDiv.appendChild(formatButtons.link);
textBox.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === "b" || e.key === "B") {
e.preventDefault();
formatButtons.bold.click();
} else if (e.key === "i" || e.key === "I") {
e.preventDefault();
formatButtons.italic.click();
} else if (e.key === "s" || e.key === "S") {
e.preventDefault();
formatButtons.strike.click();
} else if (e.key === "l" || e.key === "L") {
e.preventDefault();
formatButtons.link.click();
}
}
});
return controlsDiv;
};
const createSettingsArea = () => {
const settingsArea = document.createElement("div");
createButtonColorSelector().then((selector) => {
settingsArea.id = "settingsArea";
settingsArea.style.display = "none";
settingsArea.id = "settingsPanel";
settingsArea.setAttribute("role", "tabpanel");
settingsArea.setAttribute("aria-labelledby", "settingsTab");
const colorsDiv = document.createElement("div");
colorsDiv.id = "colorsDiv";
const themeControlsElement = createThemeControls();
colorsDiv.appendChild(themeControlsElement);
colorsDiv.appendChild(selector);
settingsArea.appendChild(colorsDiv);
const enableHoverQuoteSettingElement = createEnableHoverQuoteSetting();
settingsArea.appendChild(enableHoverQuoteSettingElement);
const hideAnnouncementsSettingElement = createHideAnnouncementsSetting();
settingsArea.appendChild(hideAnnouncementsSettingElement);
const versionInfo = createVersionInfo();
settingsArea.appendChild(versionInfo);
});
return settingsArea;
};
const switchTab = (
targetTabId,
textBox,
previewArea,
draftsListArea,
settingsArea,
editControlsDiv,
charCountElement,
editTabButton,
previewTabButton,
draftsTabButton,
settingsTabButton,
topControls
) => {
[textBox, previewArea, draftsListArea, settingsArea].forEach(
(panel) => (panel.style.display = "none")
);
editControlsDiv.style.display = "none";
charCountElement.style.display = "block";
topControls.style.display = "flex";
const formattingControls = document.querySelector(".formatting-controls");
if (formattingControls) formattingControls.style.display = "none";
const internalQuoteBtn = document.getElementById("internalQuoteBtn");
if (internalQuoteBtn) internalQuoteBtn.style.display = "none";
const tabButtons = [
editTabButton,
previewTabButton,
draftsTabButton,
settingsTabButton,
];
tabButtons.forEach((button) => {
button.classList.remove("active");
button.setAttribute("aria-selected", "false");
});
const chapterToggles = document.querySelectorAll(".chapter-toggle");
chapterToggles.forEach((radio) => (radio.disabled = false));
const radioDiv = document.querySelector(".radio-div");
if (radioDiv) {
if (!curURL.includes("chapters")) {
radioDiv.style.width = "0px";
radioDiv.style.overflow = "hidden";
chapterToggles.forEach((radio) => (radio.disabled = true));
} else {
radioDiv.style.width = "fit-content";
radioDiv.style.overflow = "visible";
}
}
const tabConfig = {
editTab: {
button: editTabButton,
setup: () => {
textBox.style.display = "block";
editControlsDiv.style.display = "flex";
if (formattingControls) formattingControls.style.display = "flex";
if (internalQuoteBtn) {
GM.getValue(ENABLE_HOVER_QUOTE_KEY, true).then((isEnabled) => {
internalQuoteBtn.style.display = isEnabled
? "none"
: "inline-flex";
});
}
},
},
previewTab: {
button: previewTabButton,
setup: () => {
renderPreview(textBox.value);
previewArea.style.display = "block";
editControlsDiv.style.display = "flex";
topControls.style.display = "none";
charCountElement.style.display = "none";
chapterToggles.forEach((radio) => (radio.disabled = true));
},
},
draftsTab: {
button: draftsTabButton,
setup: () => {
displayDraftsList();
draftsListArea.style.display = "flex";
topControls.style.display = "none";
charCountElement.style.display = "none";
chapterToggles.forEach((radio) => (radio.disabled = true));
},
},
settingsTab: {
button: settingsTabButton,
setup: () => {
settingsArea.style.display = "block";
charCountElement.style.display = "none";
topControls.style.display = "none";
chapterToggles.forEach((radio) => (radio.disabled = true));
refreshSettingsCheckboxes();
},
},
};
if (tabConfig[targetTabId]) {
const { button, setup } = tabConfig[targetTabId];
button.classList.add("active");
button.setAttribute("aria-selected", "true");
setup();
}
};
const getInitialBoxStyleProps = () => {
const MIN_WIDTH = 300;
const DEFAULT_HEIGHT = "45%";
const DEFAULT_TOP = "10px";
const DEFAULT_LEFT = "10px";
return {
top: DEFAULT_TOP,
left: DEFAULT_LEFT,
width: MIN_WIDTH + "px",
height: DEFAULT_HEIGHT,
bottom: "auto",
right: "auto",
transform: "none",
};
};
const createMainDiv = () => {
const newDiv = document.createElement("div");
newDiv.className = "float-div";
newDiv.id = "LN-commentBox";
newDiv.setAttribute("role", "dialog");
newDiv.setAttribute("aria-modal", "false");
newDiv.setAttribute("aria-labelledby", "commentBoxTitle");
const initialStyles = getInitialBoxStyleProps();
Object.assign(newDiv.style, initialStyles);
const innerContainer = document.createElement("div");
innerContainer.className = "comment-box-container";
const windowHeader = createWindowHeader(newDiv);
const {
tabContainer,
editTabButton,
previewTabButton,
draftsTabButton,
settingsTabButton,
} = createTabNavigation();
const textBox = createBox();
const previewArea = document.createElement("div");
previewArea.className = "preview-area";
const draftsListArea = document.createElement("div");
draftsListArea.id = "draftsListArea";
textBox.id = "editPanel";
textBox.setAttribute("role", "tabpanel");
textBox.setAttribute("aria-labelledby", "editTab");
previewArea.id = "previewPanel";
previewArea.setAttribute("role", "tabpanel");
previewArea.setAttribute("aria-labelledby", "previewTab");
draftsListArea.id = "draftsPanel";
draftsListArea.setAttribute("role", "tabpanel");
draftsListArea.setAttribute("aria-labelledby", "draftsTab");
const editControlsDiv = createEditControlsBar(textBox);
const formattingControls = createFormattingControls(textBox);
const { statusBar, charCountElement } = createStatusBar(formattingControls);
const topControls = createTopControlsBar();
const settingsArea = createSettingsArea();
statusBar.appendChild(charCountElement);
innerContainer.appendChild(tabContainer);
innerContainer.appendChild(topControls);
innerContainer.appendChild(textBox);
innerContainer.appendChild(previewArea);
innerContainer.appendChild(draftsListArea);
innerContainer.appendChild(settingsArea);
innerContainer.appendChild(statusBar);
innerContainer.appendChild(editControlsDiv);
newDiv.appendChild(windowHeader);
newDiv.appendChild(innerContainer);
const switchArgs = [
textBox,
previewArea,
draftsListArea,
settingsArea,
editControlsDiv,
charCountElement,
editTabButton,
previewTabButton,
draftsTabButton,
settingsTabButton,
topControls,
];
editTabButton.addEventListener("click", () =>
switchTab("editTab", ...switchArgs)
);
previewTabButton.addEventListener("click", () =>
switchTab("previewTab", ...switchArgs)
);
draftsTabButton.addEventListener("click", () =>
switchTab("draftsTab", ...switchArgs)
);
settingsTabButton.addEventListener("click", () =>
switchTab("settingsTab", ...switchArgs)
);
tabContainer.addEventListener("keydown", (e) => {
const tabs = Array.from(tabContainer.querySelectorAll('[role="tab"]'));
let currentTab = document.activeElement;
let currentIndex = tabs.indexOf(currentTab);
if (currentIndex === -1) return;
let nextIndex;
if (e.key === "ArrowRight") {
nextIndex = (currentIndex + 1) % tabs.length;
} else if (e.key === "ArrowLeft") {
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
} else {
return;
}
e.preventDefault();
const nextTab = tabs[nextIndex];
nextTab.focus();
switchTab(nextTab.id, ...switchArgs);
});
switchTab("editTab", ...switchArgs);
return newDiv;
};
const chapterRadio = () => {
const radioDiv = document.createElement("div");
radioDiv.className = "radio-div";
const radioFull = document.createElement("input");
const radioChapter = document.createElement("input");
[radioFull, radioChapter].forEach((radio) => {
radio.type = "radio";
radio.name = "chapters";
radio.className = "chapter-toggle";
});
radioFull.id = "entireCmt";
radioChapter.id = "chapterCmt";
const labelFull = document.createElement("label");
const labelChapter = document.createElement("label");
labelFull.setAttribute("for", "entireCmt");
labelFull.textContent = "Full Work";
labelChapter.setAttribute("for", "chapterCmt");
labelChapter.textContent = "Chapter";
if (curURL.includes("chapters")) {
radioFull.checked = false;
radioChapter.checked = true;
radioDiv.style.width = "fit-content";
radioDiv.style.overflow = "visible";
} else {
radioDiv.style.width = "0px";
radioDiv.style.overflow = "hidden";
radioFull.disabled = true;
radioChapter.disabled = true;
}
radioFull.addEventListener("click", () => {
if (newURL.includes("chapters")) {
newURL = curURL.slice(0, curURL.indexOf("/chapters"));
addStoredText();
}
});
radioChapter.addEventListener("click", () => {
if (!newURL.includes("chapters")) {
newURL = curURL;
addStoredText();
}
});
radioDiv.appendChild(radioFull);
radioDiv.appendChild(labelFull);
radioDiv.appendChild(radioChapter);
radioDiv.appendChild(labelChapter);
return radioDiv;
};
const addButton = () => {
const realCmtBox = document.querySelector(
"textarea[id^='comment_content_for']"
);
const newButton = createStyledButton({
id: "addCmtBtn",
className: "float-cmt-btn",
text: "Add to comment ⇲",
title: "Add to AO3's comment box",
onClick: async () => {
if (newButton.classList.contains("button-success")) {
return;
}
realCmtBox.value = document.querySelector(".float-box").value;
realCmtBox.scrollIntoView({ behavior: "smooth", block: "center" });
newButton.classList.add("button-success");
newButton.textContent = "Added!";
newButton.setAttribute(
"aria-label",
"Add draft content to main AO3 comment box"
);
if (document.getElementById("deleteDraftBtn")) {
return;
}
const deleteConfirmDiv = document.createElement("div");
deleteConfirmDiv.id = "deleteDraftBtn";
deleteConfirmDiv.style.display = "flex";
deleteConfirmDiv.style.alignItems = "center";
deleteConfirmDiv.style.marginLeft = "8px";
const deleteText = document.createElement("span");
deleteText.textContent = "Delete draft?";
deleteText.style.marginRight = "4px";
const yesButton = document.createElement("button");
yesButton.className = "float-cmt-btn";
yesButton.textContent = "Yes";
yesButton.style.marginRight = "4px";
const noButton = document.createElement("button");
noButton.className = "float-cmt-btn";
noButton.textContent = "No";
deleteConfirmDiv.appendChild(deleteText);
deleteConfirmDiv.appendChild(yesButton);
deleteConfirmDiv.appendChild(noButton);
const editControlsDiv = document.getElementById("editControls");
editControlsDiv.insertBefore(
deleteConfirmDiv,
document.getElementById("delCmtBtn")
);
yesButton.addEventListener("click", async () => {
const draft = await getDraft(newURL);
if (draft) {
await deleteDraft(newURL);
document.querySelector(".float-box").value = "";
updateCharacterCount();
newButton.disabled = true;
await updateOpenButtonBadge();
deleteConfirmDiv.remove();
switchToEditTab();
setTimeout(() => {
const commentBox = document.getElementById("LN-commentBox");
if (commentBox) {
commentBox.style.display = "none";
const openBtn = document.getElementById("openCmtBtn");
if (openBtn) {
openBtn.style.display = "block";
}
}
}, 50);
}
});
noButton.addEventListener("click", () => {
deleteConfirmDiv.remove();
});
setTimeout(() => {
newButton.classList.remove("button-success");
if (!newButton.disabled) {
newButton.textContent = "Add to comment ⇲";
}
if (document.contains(deleteConfirmDiv)) {
deleteConfirmDiv.remove();
}
}, 5000);
},
disabled: true,
});
return newButton;
};
function renderPreview(text) {
const previewArea = document.querySelector(".preview-area");
if (!previewArea) return;
previewArea.innerHTML = "";
const tempDiv = document.createElement("div");
tempDiv.innerHTML = text;
while (tempDiv.firstChild) {
previewArea.appendChild(tempDiv.firstChild);
}
}
const addStoredText = async () => {
const textBox = document.querySelector(".float-box");
if (curURL.includes("full")) {
newURL = curURL.slice(0, curURL.indexOf("?"));
}
const storedDraft = await getDraft(newURL);
const storedText = storedDraft ? storedDraft.text : "";
textBox.value = storedText;
renderPreview(storedText);
updateCharacterCount();
const addBtn = document.querySelector("#addCmtBtn");
if (addBtn) {
const hasText = textBox.value.trim().length > 0;
addBtn.disabled = !hasText;
if (hasText) {
addBtn.textContent = "Add to comment ⇲";
addBtn.title = "Add to AO3's comment box";
}
}
};
const updateCharacterCount = () => {
const charCount = document.querySelector(".char-count");
const textBox = document.querySelector(".float-box");
if (charCount && textBox) {
const text = textBox.value || "";
const remainingChars = MAX_CHAR_LIMIT - text.length;
charCount.textContent = `Characters left: ${remainingChars}`;
}
};
const OPEN_BUTTON_POSITION_KEY = "openButtonPosition";
async function applySavedOpenButtonPosition(element) {
const savedPosition = await GM.getValue(OPEN_BUTTON_POSITION_KEY, null);
if (savedPosition) {
try {
const position = JSON.parse(savedPosition);
if (position.top) element.style.top = position.top;
if (position.left) element.style.left = position.left;
if (position.left && position.left !== "auto") {
element.style.right = "auto";
} else if (position.right && position.right !== "auto") {
element.style.left = "auto";
element.style.right = position.right;
}
if (position.top && position.top !== "auto") {
element.style.bottom = "auto";
} else if (position.bottom && position.bottom !== "auto") {
element.style.top = "auto";
element.style.bottom = position.bottom;
}
} catch (e) {
console.error("Error applying saved open button position:", e);
}
} else {
element.style.top = "10px";
element.style.left = "10px";
element.style.right = "auto";
element.style.bottom = "auto";
}
}
function resizeElement(element) {
const resizeHandle = element.querySelector(".resize-handle");
const textBox = element.querySelector(".float-box");
textBox.addEventListener("mousedown", function (e) {
e.stopPropagation();
});
resizeHandle.addEventListener("mousedown", initResize);
let startX, startY, startWidth, startHeight, startTop;
function initResize(e) {
e.preventDefault();
e.stopPropagation();
startX = e.clientX;
startY = e.clientY;
const computedStyle = document.defaultView.getComputedStyle(element);
startWidth = parseInt(computedStyle.width, 10);
startHeight = parseInt(computedStyle.height, 10);
startTop = parseInt(computedStyle.top, 10);
document.addEventListener("mousemove", resize);
document.addEventListener("mouseup", stopResize);
}
function resize(e) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newWidth = startWidth + dx;
const newHeight = startHeight - dy;
const newTop = startTop + dy;
if (newWidth > 100) {
element.style.width = newWidth + "px";
}
if (newHeight > 100) {
element.style.height = newHeight + "px";
element.style.top = newTop + "px";
}
}
async function stopResize() {
document.removeEventListener("mousemove", resize);
document.removeEventListener("mouseup", stopResize);
try {
const position = {
top: element.style.top,
left: element.style.left,
right: element.style.right,
bottom: element.style.bottom,
width: element.style.width,
height: element.style.height,
};
await GM.setValue(COMMENT_BOX_POSITION_KEY, JSON.stringify(position));
} catch (e) {
console.error("Error saving resized position/size:", e);
}
}
}
function dragElement(elmnt, targetToMove, storageKey) {
const elementToMove = targetToMove || elmnt;
let isDragging = false;
let startX, startY, initialLeft, initialTop;
let hasMoved = false;
const moveThreshold = 7;
elmnt.addEventListener("mousedown", startDrag);
function startDrag(e) {
if (
e.target.tagName === "BUTTON" ||
e.target.tagName === "TEXTAREA" ||
e.target.tagName === "INPUT" ||
e.target.tagName === "LABEL" ||
e.target.className === "resize-handle" ||
e.target.className === "close-btn" ||
e.target.tagName === "SELECT" ||
e.target.type === "radio"
) {
return;
}
e.preventDefault();
startX = e.clientX;
startY = e.clientY;
const computedStyle = window.getComputedStyle(elementToMove);
initialLeft = parseInt(computedStyle.left, 10) || 0;
initialTop = parseInt(computedStyle.top, 10) || 0;
isDragging = true;
hasMoved = false;
document.addEventListener("mousemove", drag);
document.addEventListener("mouseup", stopDrag);
}
function drag(e) {
if (!isDragging) return;
e.preventDefault();
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (
!hasMoved &&
(Math.abs(dx) > moveThreshold || Math.abs(dy) > moveThreshold)
) {
hasMoved = true;
}
elementToMove.style.left = initialLeft + dx + "px";
elementToMove.style.top = initialTop + dy + "px";
elementToMove.style.right = "auto";
elementToMove.style.bottom = "auto";
}
async function stopDrag() {
if (!isDragging) return;
isDragging = false;
document.removeEventListener("mousemove", drag);
document.removeEventListener("mouseup", stopDrag);
if (hasMoved) {
wasDragged = true;
setTimeout(() => {
wasDragged = false;
}, DRAG_COOLDOWN);
}
if (storageKey === OPEN_BUTTON_POSITION_KEY) {
try {
const position = {
top: elementToMove.style.top,
left: elementToMove.style.left,
right: elementToMove.style.right,
bottom: elementToMove.style.bottom,
};
await GM.setValue(storageKey, JSON.stringify(position));
} catch (e) {
console.error("Error saving dragged position:", e);
}
}
}
}
async function displayDraftsList() {
const draftsArea = document.getElementById("draftsPanel");
if (!draftsArea) {
console.error("Drafts area element (draftsPanel) not found!");
return;
}
draftsArea.innerHTML = "";
const draftsListWrapper = document.createElement("div");
draftsListWrapper.className = "drafts-list-wrapper";
draftsListWrapper.id = "draftsListWrapper";
draftsArea.appendChild(draftsListWrapper);
const drafts = await getAllDrafts();
const draftEntries = Object.entries(drafts);
if (draftEntries.length === 0) {
draftsListWrapper.innerHTML = "<p>No saved drafts found.</p>";
} else {
draftEntries.sort(([, a], [, b]) => b.lastEdited - a.lastEdited);
draftEntries.forEach(([url, draftData]) => {
const itemDiv = document.createElement("div");
itemDiv.className = "draft-item";
itemDiv.dataset.url = url;
const infoDiv = document.createElement("div");
infoDiv.className = "draft-info";
const titleLink = document.createElement("a");
titleLink.href = url;
titleLink.textContent = draftData.title || "Unknown Title";
titleLink.style.fontWeight = "bold";
titleLink.title = `Go to: ${draftData.title || "Unknown Title"} by ${
draftData.author || "Unknown Author"
}`;
titleLink.style.color = "var(--comment-fg-color)";
titleLink.style.textDecoration = "underline";
titleLink.style.marginRight = "5px";
const authorSpan = document.createElement("span");
authorSpan.innerHTML = `<small>by ${
draftData.author || "Unknown Author"
}</small>`;
authorSpan.style.fontSize = "0.9em";
const scopeChapterSpan = document.createElement("span");
scopeChapterSpan.style.fontSize = "0.9em";
scopeChapterSpan.style.fontStyle = "italic";
let scopeText = "";
if (draftData.scope === "full") {
scopeText = "(Full Work)";
} else if (draftData.scope === "chapter" && draftData.chapterTitle) {
scopeText = ` ${draftData.chapterTitle}`;
} else if (draftData.scope === "chapter") {
scopeText = "(Chapter)";
}
scopeChapterSpan.textContent = scopeText;
const titleContainer = document.createElement("div");
titleContainer.style.overflow = "hidden";
titleContainer.style.textOverflow = "ellipsis";
titleContainer.style.marginBottom = "3px";
titleContainer.style.display = "flex";
titleContainer.style.flexWrap = "wrap";
titleContainer.style.alignItems = "baseline";
titleContainer.appendChild(titleLink);
titleContainer.appendChild(authorSpan);
const chapterContainer = document.createElement("div");
chapterContainer.style.marginBottom = "3px";
if (scopeText) {
chapterContainer.appendChild(scopeChapterSpan);
}
const dateSpan = document.createElement("span");
dateSpan.className = "draft-date";
const date = new Date(draftData.lastEdited);
dateSpan.textContent = `Last edited: ${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
infoDiv.appendChild(titleContainer);
infoDiv.appendChild(chapterContainer);
infoDiv.appendChild(dateSpan);
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "Delete";
deleteBtn.className = "draft-delete-btn";
deleteBtn.title = "Delete this draft";
deleteBtn.addEventListener("click", async () => {
const confirmMsg = `Delete draft for "${
draftData.title || "Unknown Title"
}" by ${draftData.author || "Unknown Author"}?`;
if (confirm(confirmMsg)) {
await deleteDraft(url);
itemDiv.remove();
const textBox = document.querySelector(".float-box");
if (url === newURL && textBox) {
textBox.value = "";
updateCharacterCount();
await saveDraft(newURL, "");
const addBtn = document.querySelector("#addCmtBtn");
if (addBtn) addBtn.disabled = true;
}
if (
draftsListWrapper.querySelectorAll(".draft-item").length === 0
) {
draftsListWrapper.innerHTML = "<p>No saved drafts found.</p>";
const deleteAllContainer = draftsArea.querySelector(
".delete-all-container"
);
if (deleteAllContainer) deleteAllContainer.style.display = "none";
}
await updateOpenButtonBadge();
}
});
itemDiv.appendChild(infoDiv);
itemDiv.appendChild(deleteBtn);
draftsListWrapper.appendChild(itemDiv);
});
}
if (draftEntries.length > 0) {
const deleteAllContainer = document.createElement("div");
deleteAllContainer.className = "delete-all-container";
deleteAllContainer.style.textAlign = "right";
deleteAllContainer.style.marginTop = "5px";
if (draftEntries.length > 1) {
const deleteAllBtn = createStyledButton({
text: "Delete all",
className: "float-cmt-btn",
styles: {
backgroundColor: danger,
color: "white",
borderColor: "rgba(150,0,0,0.8)",
},
title: "Delete all saved drafts",
attributes: { "aria-label": "Delete all saved comment drafts" },
onClick: async () => {
if (
confirm(
"Are you sure you want to delete ALL saved drafts? This action cannot be undone."
)
) {
const draftsBeforeDelete = await getAllDrafts();
try {
await GM.setValue(DRAFTS_STORAGE_KEY, "{}");
await displayDraftsList();
const textBox = document.querySelector(".float-box");
if (textBox && draftsBeforeDelete[newURL]) {
textBox.value = "";
updateCharacterCount();
const addBtn = document.querySelector("#addCmtBtn");
if (addBtn) addBtn.disabled = true;
}
await updateOpenButtonBadge();
} catch (error) {
console.error("Error deleting all drafts:", error);
alert(
"Failed to delete all drafts. Check the console for errors."
);
}
}
},
});
deleteAllBtn.addEventListener("mouseenter", () => {
deleteAllBtn.style.backgroundColor = danger;
});
deleteAllBtn.addEventListener("mouseleave", () => {
deleteAllBtn.style.backgroundColor = danger;
});
deleteAllContainer.appendChild(deleteAllBtn);
draftsArea.appendChild(deleteAllContainer);
} else {
deleteAllContainer.style.display = "none";
draftsArea.appendChild(deleteAllContainer);
}
}
}
const init = async () => {
const workUrlMatch = curURL.match(/\/works\/(\d+)/);
const chapterUrlMatch = curURL.match(/\/chapters\/(\d+)/);
if (workUrlMatch) {
currentWorkId = workUrlMatch[1];
}
if (chapterUrlMatch) {
const title = document.querySelector("h3.title a");
if (title) {
const urlMatch = title.href.match(/\/works\/(\d+)/);
if (urlMatch) {
currentWorkId = urlMatch[1];
}
}
}
const body = document.body;
const openButton = createButton();
openButton.style.position = "fixed";
openButton.style.zIndex = "102";
openButton.style.padding = "5px 8px";
body.appendChild(openButton);
await applySavedOpenButtonPosition(openButton);
dragElement(openButton, openButton, OPEN_BUTTON_POSITION_KEY);
body.appendChild(addStyles());
await ThemeManager.applyTheme();
await checkAndApplyHideAnnouncements();
await checkAndApplyEnableHoverQuote();
setupHoverQuoteButton();
const mainDiv = createMainDiv();
mainDiv.style.display = "none";
body.appendChild(mainDiv);
await addStoredText();
await updateOpenButtonBadge();
const windowHeader = mainDiv.querySelector(".window-header");
if (windowHeader) {
dragElement(windowHeader, mainDiv);
}
resizeElement(mainDiv);
await applySavedCommentBoxPosition(mainDiv);
document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") {
const commentBox = document.getElementById("LN-commentBox");
if (commentBox && commentBox.style.display !== "none") {
await closeCommentBox(commentBox);
}
}
});
};
function removeAnnouncements() {
const announcements = document.querySelectorAll("div.event.announcement");
if (announcements.length > 0) {
announcements.forEach((announcement) => announcement.remove());
}
}
async function handleHideAnnouncementsChange(event) {
const isChecked = event.target.checked;
await GM.setValue(HIDE_ANNOUNCEMENTS_ENABLED_KEY, isChecked);
if (isChecked) {
await GM.setValue(HIDE_ANNOUNCEMENTS_TIMESTAMP_KEY, Date.now());
removeAnnouncements();
} else {
await GM.setValue(HIDE_ANNOUNCEMENTS_TIMESTAMP_KEY, 0);
}
}
function createHideAnnouncementsSetting() {
const settingDiv = document.createElement("div");
settingDiv.style.marginTop = "10px";
settingDiv.style.display = "flex";
settingDiv.style.alignItems = "center";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = "hideAnnouncementsCheckbox";
checkbox.style.marginRight = "5px";
checkbox.style.boxShadow = "none";
const label = document.createElement("label");
label.textContent = "Hide AO3 announcement banner";
const smallText = document.createElement("small");
smallText.textContent = " (expires in 1 week)";
smallText.style.color = "var(--link-color)";
smallText.style.fontSize = "0.8em";
smallText.style.marginLeft = "5px";
label.appendChild(smallText);
label.htmlFor = "hideAnnouncementsCheckbox";
label.style.cursor = "pointer";
checkbox.addEventListener("change", handleHideAnnouncementsChange);
settingDiv.appendChild(checkbox);
settingDiv.appendChild(label);
return settingDiv;
}
async function checkAndApplyHideAnnouncements() {
const isEnabled = await GM.getValue(HIDE_ANNOUNCEMENTS_ENABLED_KEY, false);
const enabledTimestamp = await GM.getValue(
HIDE_ANNOUNCEMENTS_TIMESTAMP_KEY,
0
);
const oneWeekMs = 7 * 24 * 60 * 60 * 1000;
const now = Date.now();
let shouldBeEnabled = isEnabled;
if (
isEnabled &&
enabledTimestamp > 0 &&
now - enabledTimestamp > oneWeekMs
) {
await GM.setValue(HIDE_ANNOUNCEMENTS_ENABLED_KEY, false);
await GM.setValue(HIDE_ANNOUNCEMENTS_TIMESTAMP_KEY, 0);
shouldBeEnabled = false;
}
const checkbox = document.getElementById("hideAnnouncementsCheckbox");
if (checkbox) {
checkbox.checked = shouldBeEnabled;
}
if (shouldBeEnabled) {
requestAnimationFrame(removeAnnouncements);
}
}
async function handleEnableHoverQuoteChange(event) {
const isChecked = event.target.checked;
await GM.setValue(ENABLE_HOVER_QUOTE_KEY, isChecked);
const internalQuoteButton = document.getElementById("internalQuoteBtn");
const hoverQuoteButton = document.getElementById("hoverQuoteBtn");
if (!isChecked) {
if (internalQuoteButton) {
internalQuoteButton.style.display = "inline-flex";
}
if (hoverQuoteButton)
hoverQuoteButton.style.setProperty("display", "none", "important");
} else {
if (internalQuoteButton) {
internalQuoteButton.style.display = "none";
}
}
}
function createEnableHoverQuoteSetting() {
const settingDiv = document.createElement("div");
settingDiv.style.marginTop = "10px";
settingDiv.style.display = "flex";
settingDiv.style.alignItems = "center";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = "enableHoverQuoteCheckbox";
checkbox.style.marginRight = "5px";
checkbox.style.boxShadow = "none";
GM.getValue(ENABLE_HOVER_QUOTE_KEY, true).then((isEnabled) => {
checkbox.checked = isEnabled;
});
const label = document.createElement("label");
label.textContent = "Show inline quote button on highlight";
label.htmlFor = "enableHoverQuoteCheckbox";
label.style.cursor = "pointer";
checkbox.addEventListener("change", handleEnableHoverQuoteChange);
settingDiv.appendChild(checkbox);
settingDiv.appendChild(label);
return settingDiv;
}
async function createButtonColorSelector() {
const handleButtonColorChange = async (event) => {
const newColor = event.target.value;
GM.setValue(ICON_COLOR_KEY, newColor);
await ThemeManager.applyTheme();
};
const iconPref = await GM.getValue(ICON_COLOR_KEY, primary);
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.value = iconPref;
colorInput.addEventListener("change", handleButtonColorChange);
const settingDiv = document.createElement("div");
settingDiv.style.display = "flex";
settingDiv.style.flexDirection = "column";
settingDiv.style.alignItems = "center";
const label = document.createElement("label");
label.textContent = "Button color";
settingDiv.appendChild(label);
settingDiv.appendChild(colorInput);
return settingDiv;
}
async function checkAndApplyEnableHoverQuote() {
const isEnabled = await GM.getValue(ENABLE_HOVER_QUOTE_KEY, true);
const checkbox = document.getElementById("enableHoverQuoteCheckbox");
const internalQuoteButton = document.getElementById("internalQuoteBtn");
const hoverQuoteButton = document.getElementById("hoverQuoteBtn");
if (checkbox) {
checkbox.checked = isEnabled;
}
if (!isEnabled) {
if (internalQuoteButton) {
internalQuoteButton.style.display = "inline-flex";
}
if (hoverQuoteButton)
hoverQuoteButton.style.setProperty("display", "none", "important");
} else {
if (internalQuoteButton) internalQuoteButton.style.display = "none";
}
}
async function refreshSettingsCheckboxes() {
const enableHoverQuoteCheckbox = document.getElementById(
"enableHoverQuoteCheckbox"
);
if (enableHoverQuoteCheckbox) {
const isHoverQuoteEnabled = await GM.getValue(
ENABLE_HOVER_QUOTE_KEY,
true
);
enableHoverQuoteCheckbox.checked = isHoverQuoteEnabled;
}
const hideAnnouncementsCheckbox = document.getElementById(
"hideAnnouncementsCheckbox"
);
if (hideAnnouncementsCheckbox) {
const isHideAnnouncementsEnabled = await GM.getValue(
HIDE_ANNOUNCEMENTS_ENABLED_KEY,
false
);
hideAnnouncementsCheckbox.checked = isHideAnnouncementsEnabled;
}
const themeSelect = document.getElementById("themeSelectDropdown");
if (themeSelect) {
const savedTheme = await GM.getValue("manualThemePreference", "dynamic");
themeSelect.value = savedTheme;
}
}
const createVersionInfo = () => {
const versionDiv = document.createElement("div");
const versionSpan = document.createElement("span");
const link = document.createElement("a");
link.href = URL;
link.target = "_blank";
link.textContent = `luvnotes v${VERSION} ♡`;
link.style.fontSize = "0.8em";
link.style.color = "var(--link-color)";
link.style.textDecoration = "underline";
link.style.border = "none";
link.style.cursor = "pointer";
versionDiv.appendChild(versionSpan);
versionDiv.appendChild(link);
versionDiv.style.position = "absolute";
versionDiv.style.bottom = "1em";
versionDiv.style.right = "1em";
return versionDiv;
};
init();
})();