1
Fork 0

make the 🔗 button copy branch links to clipboard

to accomplish this, I generalised emoji tooltips to a shared Tooltip class.
in the long run I'd like to transform all existing `title=""` tooltips into these for stylistic consistency with the rest of the website, but this is good enough for now.

I also ended up cleaning up some old code from before the /b rework.
This commit is contained in:
リキ萌 2025-01-11 00:15:29 +01:00
parent c537eb844f
commit 74baa61122
8 changed files with 208 additions and 138 deletions
crates/treehouse/src/html
static
template

View file

@ -57,13 +57,13 @@ pub fn branch_to_html(
let linked_branch = if let Content::ResolvedLink(file_id) = &branch.attributes.content { let linked_branch = if let Content::ResolvedLink(file_id) = &branch.attributes.content {
let path = treehouse.tree_path(*file_id).expect(".tree file expected"); let path = treehouse.tree_path(*file_id).expect(".tree file expected");
format!(" data-th-link=\"{}\"", EscapeHtml(path.as_str())) format!(" th-link=\"{}\"", EscapeHtml(path.as_str()))
} else { } else {
String::new() String::new()
}; };
let do_not_persist = if branch.attributes.do_not_persist { let do_not_persist = if branch.attributes.do_not_persist {
" data-th-do-not-persist=\"\"" " th-do-not-persist"
} else { } else {
"" ""
}; };
@ -165,7 +165,7 @@ pub fn branch_to_html(
} else { } else {
write!( write!(
s, s,
"<a class=\"icon icon-permalink\" href=\"/b?{}\" title=\"permalink\"></a>", "<a th-p class=\"icon icon-permalink\" href=\"/b?{}\" title=\"permalink\"></a>",
EscapeAttribute(&branch.named_id) EscapeAttribute(&branch.named_id)
) )
.unwrap(); .unwrap();

View file

@ -611,21 +611,26 @@ img[data-cast~="emoji"] {
object-fit: contain; object-fit: contain;
} }
/* And also style emoji tooltips. */ /* Tooltips */
th-emoji-tooltip { th-overlays {
display: flex; position: absolute;
flex-direction: column; left: 0;
align-items: center; top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
th-tooltip {
display: block;
position: fixed; position: fixed;
transform: translateX(-50%) translateY(-10%) scale(0.8);
width: max-content; width: max-content;
z-index: 100; z-index: 100;
background-color: var(--background-color-tooltip); background-color: var(--background-color-tooltip);
padding: 0.8rem; padding: 0.4rem 0.8rem;
margin-top: 0.8rem;
border-radius: 0.6rem; border-radius: 0.6rem;
transition: transition:
@ -635,28 +640,52 @@ th-emoji-tooltip {
opacity: 0%; opacity: 0%;
filter: blur(0.3rem); filter: blur(0.3rem);
pointer-events: none; pointer-events: none;
}
th-emoji-tooltip.transitioned-in { font-size: 0.9em;
opacity: 100%;
filter: blur(0); &[th-side="bottom"] {
transform: translateX(-50%) translateY(-10%) scale(0.8);
&.transitioned-in {
transform: translateX(-50%) scale(1); transform: translateX(-50%) scale(1);
} }
}
th-emoji-tooltip img { &[th-side="left"] {
transform: translateX(-90%) translateY(-50%) scale(0.8);
&.transitioned-in {
transform: translateX(-100%) translateY(-50%);
}
}
}
th-tooltip.transitioned-in {
opacity: 100%;
filter: blur(0);
}
th-tooltip.tooltip-emoji {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.8rem;
margin-top: 0.8rem;
& > img {
display: block; display: block;
max-width: 7.2rem; max-width: 7.2rem;
max-height: 7.2rem; max-height: 7.2rem;
} }
th-emoji-tooltip p { & > p {
--recursive-wght: 550;
color: var(--text-color); color: var(--text-color);
font-size: 0.9em;
margin: 0; margin: 0;
padding-top: 0.6rem; padding-top: 0.6rem;
line-height: 1; line-height: 1;
} }
}
.th-emoji-unknown { .th-emoji-unknown {
text-decoration: 0.1rem underline var(--error-color); text-decoration: 0.1rem underline var(--error-color);

View file

@ -239,7 +239,7 @@ th-bc {
.tree details:not([open])>summary>th-bc>:last-child, .tree details:not([open])>summary>th-bc>:last-child,
/* NOTE: Linked branches have a slightly different structure (extra <noscript> tag) and therefore /* NOTE: Linked branches have a slightly different structure (extra <noscript> tag) and therefore
:last-child does not work. */ :last-child does not work. */
.tree li[data-th-link]>details:not([open])>summary>th-bc>:nth-last-child(2) { .tree li[th-link]>details:not([open])>summary>th-bc>:nth-last-child(2) {
&::after { &::after {
content: "\00A0"; content: "\00A0";
display: inline-block; display: inline-block;

View file

@ -1,103 +1,21 @@
// Emoji zoom-in functionality. // Emoji zoom-in functionality.
import { addSpell } from "treehouse/spells.js"; import { addSpell } from "treehouse/spells.js";
import { attachTooltip, Tooltip } from "treehouse/overlay.js";
class EmojiTooltip extends HTMLElement { function createEmojiTooltip(emoji, element) {
constructor(emoji, element, { onClosed }) { let tooltip = new Tooltip(element, "bottom");
super(); tooltip.classList.add("tooltip-emoji");
this.emoji = emoji; let img = tooltip.appendChild(new Image());
this.emojiElement = element; img.src = element.src;
this.onClosed = onClosed;
}
connectedCallback() { let description = tooltip.appendChild(document.createElement("p"));
this.role = "tooltip"; description.textContent = emoji.emojiName;
this.image = new Image();
this.image.src = this.emojiElement.src;
this.description = document.createElement("p");
this.description.textContent = `${this.emoji.emojiName}`;
let emojiBoundingBox = this.emojiElement.getBoundingClientRect();
this.style.left = `${emojiBoundingBox.left + emojiBoundingBox.width / 2}px`;
this.style.top = `calc(${emojiBoundingBox.top}px + 1.5em)`;
this.fullyOpaque = false;
this.addEventListener("transitionend", event => {
if (event.propertyName == "opacity") {
this.fullyOpaque = !this.fullyOpaque;
if (!this.fullyOpaque) {
this.onClosed();
}
}
});
// Timeout is zero because we just want to execute this later, to be definitely sure
// the transition plays out.
setTimeout(() => this.classList.add("transitioned-in"), 0);
this.appendChild(this.image);
this.appendChild(this.description);
}
close() {
this.classList.remove("transitioned-in");
}
}
customElements.define("th-emoji-tooltip", EmojiTooltip);
let emojiTooltips = null;
class EmojiTooltips extends HTMLElement {
constructor() {
super();
this.tooltips = new Set();
this.abortController = new AbortController();
}
connectedCallback() {
emojiTooltips = this;
addEventListener(
"wheel",
event => emojiTooltips.closeTooltips(event),
{ signal: this.abortController.signal },
);
}
disconnectedCallback() {
this.abortController.abort();
}
openTooltip(emoji, element) {
let tooltip = new EmojiTooltip(emoji, element, {
onClosed: () => {
this.removeChild(tooltip);
this.tooltips.delete(tooltip);
},
});
this.appendChild(tooltip);
this.tooltips.add(tooltip);
return tooltip; return tooltip;
} }
closeTooltip(tooltip) {
tooltip.close();
}
closeTooltips() {
for (let tooltip of this.tooltips) {
tooltip.close();
}
}
}
customElements.define("th-emoji-tooltips", EmojiTooltips);
class Emoji { class Emoji {
constructor(element) { constructor(element) {
this.emojiName = element.title; this.emojiName = element.title;
@ -106,18 +24,7 @@ class Emoji {
// so remove the title. // so remove the title.
element.title = ""; element.title = "";
element.addEventListener("mouseenter", () => this.openTooltip(element)); attachTooltip(element, () => createEmojiTooltip(this, element)).showOnHover();
element.addEventListener("mouseleave", () => this.closeTooltip());
element.addEventListener("scroll", () => this.closeTooltip());
}
openTooltip(element) {
this.tooltip = emojiTooltips.openTooltip(this, element);
}
closeTooltip() {
emojiTooltips.closeTooltip(this.tooltip);
this.tooltip = null;
} }
} }

116
static/js/overlay.js Normal file
View file

@ -0,0 +1,116 @@
export class Overlay extends HTMLElement {}
/** @type Overlays */
export let overlays = null;
export class Overlays extends HTMLElement {
overlays = new Set();
connectedCallback() {
overlays = this;
}
disconnectedCallback() {
overlays = null;
}
open(overlay) {
this.appendChild(overlay);
this.overlays.add(overlay);
return overlay;
}
close(overlay) {
this.removeChild(overlay);
this.overlays.delete(overlay);
}
}
customElements.define("th-overlays", Overlays);
export class Tooltip extends Overlay {
constructor(element, side) {
super();
this.element = element;
this.side = side;
}
connectedCallback() {
this.role = "tooltip";
this.setAttribute("th-side", this.side);
let bb = this.element.getBoundingClientRect();
switch (this.side) {
// NOTE: The elements are positioned directly at (width / 2) or (height / 2), because
// they are transformed to the centre over on the CSS side.
case "bottom":
this.style.left = `${bb.left + bb.width / 2}px`;
this.style.top = `${bb.bottom}px`;
break;
case "left":
this.style.left = `${bb.left}px`;
this.style.top = `${bb.top + bb.height / 2}px`;
break;
default:
console.error(`th-tooltip: unknown attachment side ${this.side}`);
break;
}
this.addEventListener("transitionend", (event) => {
if (event.propertyName == "opacity") {
let style = getComputedStyle(this);
if (style.opacity < 0.01) {
this.dispatchEvent(new Event(".close"));
}
}
});
// Timeout is zero because we just want to execute this later, to be definitely sure
// the transition plays out.
setTimeout(() => this.classList.add("transitioned-in"), 0);
}
close() {
this.classList.remove("transitioned-in");
// NOTE: In case there is no transition, we may need to trigger the close event immediately.
let style = getComputedStyle(this);
if (style.opacity < 0.01) {
this.dispatchEvent(new Event(".close"));
}
}
}
customElements.define("th-tooltip", Tooltip);
export function attachTooltip(element, makeTooltip) {
let show = () => {
let tooltip = overlays.open(makeTooltip(element));
let abortController = new AbortController();
tooltip.addEventListener(".close", () => {
overlays.close(tooltip);
abortController.abort();
console.log("closing tooltip");
});
window.addEventListener("wheel", () => tooltip.close(), {
signal: abortController.signal,
passive: true,
});
element.addEventListener("mouseleave", () => tooltip.close(), {
signal: abortController.signal,
});
};
return {
show,
showOnHover() {
element.addEventListener("mouseenter", show);
return this;
},
};
}

View file

@ -2,6 +2,7 @@
import { addSpell } from "treehouse/spells.js"; import { addSpell } from "treehouse/spells.js";
import * as ulid from "treehouse/ulid.js"; import * as ulid from "treehouse/ulid.js";
import { attachTooltip, Tooltip } from "treehouse/overlay.js";
/* Branch persistence */ /* Branch persistence */
@ -38,7 +39,7 @@ export class Branch {
this.branchContent = this.contentContainer.childNodes[1]; this.branchContent = this.contentContainer.childNodes[1];
this.buttonBar = this.contentContainer.childNodes[2]; this.buttonBar = this.contentContainer.childNodes[2];
let doPersist = !element.hasAttribute("data-th-do-not-persist"); let doPersist = !element.hasAttribute("th-do-not-persist");
let isOpen = branchIsOpen(element.id); let isOpen = branchIsOpen(element.id);
if (doPersist && isOpen !== undefined) { if (doPersist && isOpen !== undefined) {
this.details.open = isOpen; this.details.open = isOpen;
@ -52,6 +53,22 @@ export class Branch {
this.namedID = element.id.replace(/^b-/, ""); this.namedID = element.id.replace(/^b-/, "");
Branch.branchesByNamedID.set(this.namedID, element); Branch.branchesByNamedID.set(this.namedID, element);
let permalinkButton = this.buttonBar.querySelector("a[th-p]");
if (permalinkButton != null) {
permalinkButton.title = "copy permalink";
permalinkButton.addEventListener("click", (event) => {
event.preventDefault(); // do not navigate the link
navigator.clipboard.writeText(
new URL(permalinkButton.href, window.location).toString(),
);
attachTooltip(permalinkButton, () => {
let tooltip = new Tooltip(permalinkButton, "left");
tooltip.append("permalink copied to clipboard!");
return tooltip;
}).show();
});
}
if (ulid.isCanonicalUlid(this.namedID)) { if (ulid.isCanonicalUlid(this.namedID)) {
let timestamp = ulid.getTimestamp(this.namedID); let timestamp = ulid.getTimestamp(this.namedID);
let date = document.createElement("span"); let date = document.createElement("span");
@ -76,7 +93,7 @@ class LinkedBranch extends Branch {
constructor(element) { constructor(element) {
super(element); super(element);
this.linkedTree = element.getAttribute("data-th-link"); this.linkedTree = element.getAttribute("th-link");
LinkedBranch.byLink.set(this.linkedTree, this); LinkedBranch.byLink.set(this.linkedTree, this);
this.loadingText = document.createElement("p"); this.loadingText = document.createElement("p");
@ -135,7 +152,7 @@ class LinkedBranch extends Branch {
} }
} }
loadTree() { loadTree(_why) {
if (!this.loading) { if (!this.loading) {
this.loading = this.loadTreePromise(); this.loading = this.loadTreePromise();
} }
@ -157,16 +174,16 @@ function expandDetailsRecursively(element) {
} }
function getCurrentlyHighlightedBranch() { function getCurrentlyHighlightedBranch() {
if (window.location.pathname == "/b" && window.location.search.length > 0) { if (window.location.hash.length > 0) {
let shortID = window.location.search.substring(1);
return Branch.branchesByNamedID.get(shortID).id;
} else {
return window.location.hash.substring(1); return window.location.hash.substring(1);
} }
} }
async function highlightCurrentBranch() { async function highlightCurrentBranch() {
let branch = document.getElementById(getCurrentlyHighlightedBranch()); let branchId = getCurrentlyHighlightedBranch();
if (branchId == null) return;
let branch = document.getElementById(branchId);
if (branch != null) { if (branch != null) {
branch.scrollIntoView(); branch.scrollIntoView();

View file

@ -27,7 +27,7 @@
{{> components/_footer.hbs }} {{> components/_footer.hbs }}
{{/if}} {{/if}}
<th-emoji-tooltips></th-emoji-tooltips> <th-overlays></th-overlays>
<th-command-line></th-command-line> <th-command-line></th-command-line>
</body> </body>

View file

@ -45,6 +45,7 @@ clever to do while browser vendors figure that out, we'll just have to do a cach
import "treehouse/tree.js"; import "treehouse/tree.js";
import "treehouse/emoji.js"; import "treehouse/emoji.js";
import "treehouse/command-line.js"; import "treehouse/command-line.js";
import "treehouse/overlay.js";
</script> </script>
<meta property="og:site_name" content="{{ config.user.title }}"> <meta property="og:site_name" content="{{ config.user.title }}">