Last active
March 5, 2025 06:17
-
-
Save gibson042/6e42cb4341f6a308af5e8515cceb3d5a to your computer and use it in GitHub Desktop.
Confluence Fixer user script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Confluence Fixer | |
// @namespace https://github.com/gibson042 | |
// @description Fixes Confluence annoyances (inline comment jumps and page reloads, stacked notifications, hidden anchors and excerpts, etc.). | |
// @source https://gist.github.com/gibson042/6e42cb4341f6a308af5e8515cceb3d5a | |
// @updateURL https://gist.github.com/gibson042/6e42cb4341f6a308af5e8515cceb3d5a/raw/confluence-fixer.user.js | |
// @downloadURL https://gist.github.com/gibson042/6e42cb4341f6a308af5e8515cceb3d5a/raw/confluence-fixer.user.js | |
// @version 0.6.0 | |
// @date 2023-09-08 | |
// @author Richard Gibson <@gmail.com> | |
// ==/UserScript== | |
// | |
// **COPYRIGHT NOTICE** | |
// | |
// To the extent possible under law, the author(s) have dedicated all copyright | |
// and related and neighboring rights to this software to the public domain | |
// worldwide. This software is distributed without any warranty. | |
// For the CC0 Public Domain Dedication, see | |
// <https://creativecommons.org/publicdomain/zero/1.0/>. | |
// | |
// **END COPYRIGHT NOTICE** | |
// | |
// | |
// Changelog: | |
// 0.6.0 (2023-09-08) | |
// * New: CC0 Public Domain Dedication. | |
// 0.5.3 (2022-03-21) | |
// * Improved: Better user script manager compatibility. | |
// 0.5.2 (2022-01-27) | |
// * Fixed: Missing return value from compareNodes. | |
// 0.5.1 (2022-01-15) | |
// * Fixed: Corrected CSS typos. | |
// 0.5.0 (2022-01-14) | |
// * New: Catch and override show-comment page reloads with in-place content replacement. | |
// 0.4.1 (2021-04-12) | |
// * Improved: Added styling to indicate embedded excerpt foreign content (which blocks inline comments). | |
// 0.4.0 (2020-03-31) | |
// * New: Removed notification stacking to allow reviewing and visiting new comments in arbitrary order. | |
// 0.3.0 (2020-03-16) | |
// * New: Added a fix for inline comment jumping. | |
// 0.2.0 (2018-07-09) | |
// * New: Added styling to make anchors visible, since Stylish can't be trusted. | |
// 0.1.0 (2018-06-26) | |
// original release | |
(function() { | |
"use strict"; | |
const ID="gibson042-confluence-fixer", | |
DEFAULT_PORTS={"http:": 80, "https:": 443}, | |
D=document, L=location, | |
AEL="addEventListener", AC="appendChild", QS="querySelector", QSA=QS+"All", | |
CDP_DIS=Node.DOCUMENT_POSITION_DISCONNECTED, CDP_BEFORE=Node.DOCUMENT_POSITION_PRECEDING; | |
D.head.insertAdjacentHTML('beforeend', ` | |
<style type="text/css" class="${ID}"> | |
/* Make anchors visible. */ | |
.confluence-anchor-link[id][data-hasbody="false"]::before { | |
color: black; | |
background: #eee; | |
font-weight: bold; | |
content: '⚓'; | |
} | |
.confluence-anchor-link[id][data-hasbody="false"]:hover::before { | |
font-style: italic; | |
content: '⚓ #' attr(id); | |
} | |
.confluence-anchor-link[id][data-hasbody="false"]::after { | |
content: ' '; | |
} | |
/* Indicate embedded excerpt foreign content (which blocks inline comments). */ | |
div[data-macro-name="multiexcerpt-include"], div[data-contains-macro-name~="excerpt-include"], | |
span[data-macro-name="multiexcerpt-include"], span[data-contains-macro-name~="excerpt-include"] { | |
/* Fix rendering for <span>s that contain block-level content. */ | |
display: block; | |
position: relative; | |
/* background-color matches .aui-message-error */ | |
background-color: #ffebe6; | |
border: 1px solid red !important; | |
box-shadow: 5px 5px 3px silver; | |
} | |
div[data-macro-name="multiexcerpt-include"]::before, div[data-contains-macro-name~="excerpt-include"]::before, | |
span[data-macro-name="multiexcerpt-include"]::before, span[data-contains-macro-name~="excerpt-include"]::before { | |
position: absolute; | |
left: -3px; | |
margin-top: -0.7em; | |
border: 1px dashed slategray; | |
padding: 0 0.5ex 2px; | |
font: italic small-caps smaller/100% sans-serif; | |
color: darkslategray; | |
background: lightgoldenrodyellow; | |
content: "embedded content"; | |
} | |
/* Allow menus to cover notifications. */ | |
#action-menu { | |
z-index: 5000; | |
} | |
/* Improve unread notification visibility. */ | |
#mw-container .mw-notification-item.unread { | |
background-color: hsl(216deg 66% 92%); | |
} | |
/* Unstack floating notifications. */ | |
#aui-flag-container > .aui-flag-stack > .aui-flag { | |
position: static !important; | |
margin-bottom: 0 !important; | |
transform: none !important; | |
} | |
/* ...and correspondingly update appearance/behavior. */ | |
#aui-flag-container { | |
pointer-events: auto; | |
user-select: none; | |
/* Limit size. */ | |
max-height: calc(max(19em, 33vh)); | |
overflow: auto; | |
/* Provide better visual boundaries. */ | |
border-left: 1px solid hsl(0deg 0% 67% / 75%); | |
box-shadow: hsl(0deg 0% 67% / 50%) 5px 5px 7px; | |
/* Further reduce space consumption. */ | |
transform-origin: right top; | |
transform: translateX(15px) perspective(100vh) translateZ(-30vh) rotateX(18deg); | |
transition: transform 0.5s; | |
} | |
#aui-flag-container:active { | |
/* Flatten on mousedown. */ | |
transition-delay: 0.1s; | |
transform: none; | |
} | |
/* Improve the notification "leave" transition. */ | |
#aui-flag-container > .aui-flag-stack > .aui-flag[aria-hidden=true] { | |
opacity: inherit; | |
max-height: 0; | |
filter: contrast(0%) brightness(150%); | |
transition: max-height .5s .5s, filter 1s; | |
} | |
</style> | |
`); | |
// Fix bad links with an invalid href hostname "localhost". | |
D[AEL]("mouseover", function( { target: a } ) { | |
try { | |
const url = new URL(a.href); | |
if( url.hostname === "localhost" ) { | |
a.href = Object.assign(url, { | |
protocol: L.protocol, | |
host: L.host, | |
// A portless host doesn't override port, so make that explicit. | |
port: L.port || DEFAULT_PORTS[L.protocol] | |
}); | |
} | |
} catch ( x ) {} | |
}, true); | |
// Bring comments back into view after bad navigation. | |
D[AEL]("click", function(evt) { | |
// Abort if the target element is not a comment navigation button. | |
let navButton = evt.target.closest("#ic-nav-next, #ic-nav-previous"); | |
if ( !navButton ) return; | |
if ( navButton.closest("#ic-display-comment-view, #ic-comment-container") == null ) { | |
for ( let el = navButton; el; el = el.parentNode ) { | |
if ( el === D ) return; | |
} | |
} | |
// Bring the new current navigation button into view with `focus()` | |
// and/or `scrollIntoView({block: "center"})`. | |
setTimeout(() => { | |
navButton = D.getElementById(navButton.id); | |
navButton.scrollIntoView({block: "center", behavior: "smooth"}); | |
navButton.focus(); | |
}, 100); | |
}); | |
// Add attributes to the relevant enclosing ancestor of each excerpt-include marker. | |
// (`<span class="conf-macro output-inline" data-hasbody="false" data-macro-name="excerpt-include"> </span>`) | |
let mutationQueue = null; | |
function onMutation ( mutations ) { | |
const isEntryPoint = mutationQueue == null; | |
if ( isEntryPoint ) mutationQueue = []; | |
try { | |
// Enqueue mutations that add content. | |
mutationQueue.push(...mutations.filter(m => m.addedNodes.length > 0)); | |
// In case of reentrancy, abort after adding new mutations to the queue. | |
if ( !isEntryPoint ) return; | |
for ( const mutation of mutationQueue ) { | |
for ( const elMarker of mutation.target[QSA](".output-inline[data-macro-name='excerpt-include']") ) { | |
const elContainer = elMarker.parentNode.closest("div, span"); | |
if ( !elContainer ) continue; | |
const curList = (elContainer.getAttribute("data-contains-macro-name") || "").match(/[^ ]+/g) || []; | |
if ( curList.includes("excerpt-include") ) continue; | |
elContainer.setAttribute("data-contains-macro-name", curList.concat("excerpt-include").join(" ")); | |
} | |
} | |
} finally { | |
if ( isEntryPoint ) mutationQueue = null; | |
} | |
} | |
(new MutationObserver(onMutation)).observe(D.body, {childList: true, subtree: true}); | |
// Catch and override show-comment page reloads by making them special in-page navigations. | |
must((...args)=>require(...args))(["confluence/meta","confluence/api/event"], must((meta,events)=>{ | |
if(D[ID]) return; | |
D[ID]=true; | |
const getUrl=meta.Links.canonical, | |
reHijack=RegExp(`^##${ID}=(?<u>[^#]*?[?&]focusedCommentId=(?<commentId>.*?)(&|#|$)|[?&](?<q>.*)|.*)`); | |
meta.Links.canonical=()=>`##${ID}=`; | |
let click={timeStamp:-Infinity, screenX:0, screenY:0, detail:0}, elCloseable; | |
D[AEL]("click", evt=>{ | |
/*force reload on quintuple-click*/ | |
if (evt.timeStamp - click.timeStamp >= 300 || | |
evt.screenX != click.screenX || evt.screenY != click.screenY) { | |
for(let k in click) click[k]=evt[k]; | |
click.detail=1; | |
} else { | |
click.timeStamp=evt.timeStamp; | |
if (++click.detail==5) reloadContent(); | |
} | |
/*capture a clicked closeable*/ | |
if(elCloseable=evt.target.closest(".aui-message.closeable")) setTimeout(()=>{elCloseable=null}, 150); | |
}); | |
window[AEL]("hashchange", must(()=>{ | |
const elTarget=elCloseable, m=reHijack.exec(L.hash)?.groups; | |
if(!m) return; | |
if(m.commentId!=null){ | |
history.back(); | |
reloadContent().then(()=>{ | |
events.trigger("qr:show-new-thread", m.commentId); | |
elTarget?.querySelector(".icon-close,.aui-close-button")?.click(); | |
}).catch(panic); | |
}else if(m.q!=null){ | |
L.replace(getUrl.call(meta.Links).replace(/\?.*|$/, q => q ? q+"&" : "?") + m.q); | |
}else{ | |
L.replace(m.u); | |
} | |
})); | |
})); | |
// reloadContent reloads a Confluence page in-place, | |
// picking up new inline comments without destroying new-comment notifications. | |
async function reloadContent(){ | |
const M=D[QS]("#main-content"), | |
tmp=D.createDocumentFragment()[AC](make("html")), | |
resp=await fetch(L.href); | |
if(!resp.ok) throw {message:`HTTP ${resp.status} (${resp.statusText})`, response:resp}; | |
tmp.innerHTML=await resp.text(); | |
/*replace old content*/ | |
for(let s of ["#main-content", ".page-metadata"]) D[QS](s).innerHTML=tmp[QS](s).innerHTML; | |
/*refresh TOC*/ | |
M[QSA](".client-side-toc-macro").forEach(el=>{ | |
if(/\S/.test(el.textContent)) return; | |
let list=el[AC](make("ul")), li, depth=1; | |
M[QSA](el.getAttribute("data-headerelements") || "H1,H2,H3").forEach(h=>{ | |
const hD=+h.nodeName.slice(1); | |
while(hD>depth++){ if(!li) li=list[AC](make("li", {className:"toc-empty-item"})); list=li[AC](make("ul")); li=null; } | |
while(hD<(--depth)) list=list.parentNode.parentNode; | |
li=list[AC](make("li")); | |
li[AC](make("a", {className:"toc-link", href:"#"+h.id})).textContent=h.textContent; | |
}); | |
}); | |
/*reload diagrams etc.*/ | |
[...M[QSA]("script")].forEach(el=>{ | |
const el2=make("script", {text: el.text}); | |
for(let a of el.attributes) el2.setAttributeNodeNS(a.cloneNode()); | |
console.log(ID, "evaluating script", el); | |
el.replaceWith(el2); | |
}); | |
/*fix expand/collapse macros*/ | |
const toggleConfig={ | |
".expand-control": [ | |
{ classes: ["right", "down"].map(s => "aui-iconfont-chevron-"+s) }, | |
{ closest: ".expand-container", elements: ":scope > .expand-content", classes: ["expand-hidden"] }, | |
], | |
".codeHeader .collapse-source": [ | |
{ | |
closest: ".conf-macro.code,[data-macro-name=code],[data-hasbody=true]", | |
elements: ".expand-control-icon,.collapsed,.expanded", | |
classes: ["collapsed", "expanded"], | |
forceIndex: elC=>+elC.querySelector(".hide-toolbar")?.classList.toggle("show-border-top"), | |
}, | |
], | |
}; | |
Object.entries(toggleConfig).forEach(([sel, rules]) => { | |
M[QSA](sel).forEach(elToggle=>{ | |
elToggle.style.display=""; | |
elToggle[AEL]("click", evt=>{ | |
for (let {closest, elements, classes, forceIndex} of rules) { | |
const elC = closest ? elToggle.closest(closest) : elToggle, | |
classIndex = elC && forceIndex ? forceIndex(elC, elToggle) : null; | |
if (!elC) return; | |
for (let el of elC[QSA](elements ?? "."+classes.join(",."))) { | |
classes.forEach((c,i)=>el.classList.toggle(...[c].concat(classIndex==null ? [] : i==classIndex))); | |
} | |
} | |
}); | |
}); | |
}); | |
/*reactivate syntax highlighting*/ | |
try { window.SyntaxHighlighter.highlight(); }catch(ex){} | |
/*fix comment-to-DOM refs*/ | |
const commentCaches=[...new Set(Object.values(Backbone._events).flat().map(v=>v?.ctx?.collection).filter(c=>c?.getCommentsOnPage))]; | |
commentCaches.flatMap(c=>c.models).forEach(o=>o.highlight && o._setHighlights(o.highlight.attr("data-ref"))); | |
/*load new comments*/ | |
await Promise.all(commentCaches.map(c => | |
c.fetch({cache:false,remove:false,merge:false}).then(()=>{ | |
c.models.sort((a,b)=>compareNodes(a.highlight?.[0], b.highlight?.[0])) | |
}) | |
)); | |
} | |
/*helpers*/ | |
function compareNodes(a,b){ const bPos=a?.compareDocumentPosition(b || a) || CDP_DIS; return a===b ? 0 : (bPos & CDP_DIS)===0 ? (bPos & CDP_BEFORE) || -1 : !a - !b; } | |
function make(name,props={}){ return Object.assign(D.createElement(name), props); } | |
function must(fn){ return function(){ try{ return fn.apply(this,arguments) }catch(ex){ panic(ex) } }; } | |
function panic(err){ console.error(err); alert(err.message); throw err; } | |
})() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment