diff --git a/crates/treehouse/src/html/tree.rs b/crates/treehouse/src/html/tree.rs index 816c1e2..7647239 100644 --- a/crates/treehouse/src/html/tree.rs +++ b/crates/treehouse/src/html/tree.rs @@ -57,13 +57,13 @@ pub fn branch_to_html( let linked_branch = if let Content::ResolvedLink(file_id) = &branch.attributes.content { 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 { String::new() }; let do_not_persist = if branch.attributes.do_not_persist { - " data-th-do-not-persist=\"\"" + " th-do-not-persist" } else { "" }; @@ -165,7 +165,7 @@ pub fn branch_to_html( } else { write!( 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) ) .unwrap(); diff --git a/static/css/main.css b/static/css/main.css index ad3ceea..40e59ee 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -611,21 +611,26 @@ img[data-cast~="emoji"] { object-fit: contain; } -/* And also style emoji tooltips. */ +/* Tooltips */ -th-emoji-tooltip { - display: flex; - flex-direction: column; - align-items: center; +th-overlays { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +th-tooltip { + display: block; position: fixed; - transform: translateX(-50%) translateY(-10%) scale(0.8); width: max-content; z-index: 100; background-color: var(--background-color-tooltip); - padding: 0.8rem; - margin-top: 0.8rem; + padding: 0.4rem 0.8rem; border-radius: 0.6rem; transition: @@ -635,27 +640,51 @@ th-emoji-tooltip { opacity: 0%; filter: blur(0.3rem); pointer-events: none; + + font-size: 0.9em; + + &[th-side="bottom"] { + transform: translateX(-50%) translateY(-10%) scale(0.8); + + &.transitioned-in { + transform: translateX(-50%) scale(1); + } + } + + &[th-side="left"] { + transform: translateX(-90%) translateY(-50%) scale(0.8); + + &.transitioned-in { + transform: translateX(-100%) translateY(-50%); + } + } } -th-emoji-tooltip.transitioned-in { +th-tooltip.transitioned-in { opacity: 100%; filter: blur(0); - transform: translateX(-50%) scale(1); } -th-emoji-tooltip img { - display: block; - max-width: 7.2rem; - max-height: 7.2rem; -} +th-tooltip.tooltip-emoji { + display: flex; + flex-direction: column; + align-items: center; -th-emoji-tooltip p { - --recursive-wght: 550; - color: var(--text-color); - font-size: 0.9em; - margin: 0; - padding-top: 0.6rem; - line-height: 1; + padding: 0.8rem; + margin-top: 0.8rem; + + & > img { + display: block; + max-width: 7.2rem; + max-height: 7.2rem; + } + + & > p { + color: var(--text-color); + margin: 0; + padding-top: 0.6rem; + line-height: 1; + } } .th-emoji-unknown { diff --git a/static/css/tree.css b/static/css/tree.css index 9d5ba19..b26444c 100644 --- a/static/css/tree.css +++ b/static/css/tree.css @@ -239,7 +239,7 @@ th-bc { .tree details:not([open])>summary>th-bc>:last-child, /* NOTE: Linked branches have a slightly different structure (extra <noscript> tag) and therefore :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 { content: "\00A0"; display: inline-block; diff --git a/static/js/emoji.js b/static/js/emoji.js index d82fb89..13a04cf 100644 --- a/static/js/emoji.js +++ b/static/js/emoji.js @@ -1,103 +1,21 @@ // Emoji zoom-in functionality. import { addSpell } from "treehouse/spells.js"; +import { attachTooltip, Tooltip } from "treehouse/overlay.js"; -class EmojiTooltip extends HTMLElement { - constructor(emoji, element, { onClosed }) { - super(); +function createEmojiTooltip(emoji, element) { + let tooltip = new Tooltip(element, "bottom"); + tooltip.classList.add("tooltip-emoji"); - this.emoji = emoji; - this.emojiElement = element; - this.onClosed = onClosed; - } + let img = tooltip.appendChild(new Image()); + img.src = element.src; - connectedCallback() { - this.role = "tooltip"; + let description = tooltip.appendChild(document.createElement("p")); + 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"); - } + return tooltip; } -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; - } - - closeTooltip(tooltip) { - tooltip.close(); - } - - closeTooltips() { - for (let tooltip of this.tooltips) { - tooltip.close(); - } - } -} - -customElements.define("th-emoji-tooltips", EmojiTooltips); - class Emoji { constructor(element) { this.emojiName = element.title; @@ -106,18 +24,7 @@ class Emoji { // so remove the title. element.title = ""; - element.addEventListener("mouseenter", () => this.openTooltip(element)); - 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; + attachTooltip(element, () => createEmojiTooltip(this, element)).showOnHover(); } } diff --git a/static/js/overlay.js b/static/js/overlay.js new file mode 100644 index 0000000..fd2c70d --- /dev/null +++ b/static/js/overlay.js @@ -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; + }, + }; +} diff --git a/static/js/tree.js b/static/js/tree.js index 0750253..658147e 100644 --- a/static/js/tree.js +++ b/static/js/tree.js @@ -2,6 +2,7 @@ import { addSpell } from "treehouse/spells.js"; import * as ulid from "treehouse/ulid.js"; +import { attachTooltip, Tooltip } from "treehouse/overlay.js"; /* Branch persistence */ @@ -38,7 +39,7 @@ export class Branch { this.branchContent = this.contentContainer.childNodes[1]; 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); if (doPersist && isOpen !== undefined) { this.details.open = isOpen; @@ -52,6 +53,22 @@ export class Branch { this.namedID = element.id.replace(/^b-/, ""); 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)) { let timestamp = ulid.getTimestamp(this.namedID); let date = document.createElement("span"); @@ -76,7 +93,7 @@ class LinkedBranch extends Branch { constructor(element) { super(element); - this.linkedTree = element.getAttribute("data-th-link"); + this.linkedTree = element.getAttribute("th-link"); LinkedBranch.byLink.set(this.linkedTree, this); this.loadingText = document.createElement("p"); @@ -135,7 +152,7 @@ class LinkedBranch extends Branch { } } - loadTree() { + loadTree(_why) { if (!this.loading) { this.loading = this.loadTreePromise(); } @@ -157,16 +174,16 @@ function expandDetailsRecursively(element) { } function getCurrentlyHighlightedBranch() { - if (window.location.pathname == "/b" && window.location.search.length > 0) { - let shortID = window.location.search.substring(1); - return Branch.branchesByNamedID.get(shortID).id; - } else { + if (window.location.hash.length > 0) { return window.location.hash.substring(1); } } async function highlightCurrentBranch() { - let branch = document.getElementById(getCurrentlyHighlightedBranch()); + let branchId = getCurrentlyHighlightedBranch(); + if (branchId == null) return; + + let branch = document.getElementById(branchId); if (branch != null) { branch.scrollIntoView(); diff --git a/template/_tree.hbs b/template/_tree.hbs index 8f2991b..f395225 100644 --- a/template/_tree.hbs +++ b/template/_tree.hbs @@ -27,7 +27,7 @@ {{> components/_footer.hbs }} {{/if}} - <th-emoji-tooltips></th-emoji-tooltips> + <th-overlays></th-overlays> <th-command-line></th-command-line> </body> diff --git a/template/components/_head.hbs b/template/components/_head.hbs index 091d470..cf19b2e 100644 --- a/template/components/_head.hbs +++ b/template/components/_head.hbs @@ -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/emoji.js"; import "treehouse/command-line.js"; + import "treehouse/overlay.js"; </script> <meta property="og:site_name" content="{{ config.user.title }}">