| new file mode 100644 |
| @@ -0,0 +1,189 @@ |
| 1 | +/* |
| 2 | + Select a passage of a post, get a small bar offering to pass it on: to |
| 3 | + Bluesky as a prefilled post, or as a link that lands on that very passage. |
| 4 | + |
| 5 | + The link is a text fragment (#:~:text=start,end), which is the browser's |
| 6 | + own way of pointing inside a page: it scrolls to the words and highlights |
| 7 | + them, with nothing on this side having to understand the URL. A browser |
| 8 | + that does not know the syntax ignores the hash and opens the top of the |
| 9 | + post, which is where a plain link would have landed anyway. |
| 10 | + |
| 11 | + The bar itself is rendered by _default/single.html, hidden, with every word |
| 12 | + it says already in the page's language - so no string is spelled out here. |
| 13 | + hx-boost morphs a fresh copy of it in on every navigation, which is why it |
| 14 | + is looked up each time rather than held on to, and why every listener is |
| 15 | + on the document rather than on the bar. |
| 16 | +*/ |
| 17 | +(() => { |
| 18 | + const BODY = ".post-body"; |
| 19 | + const BAR = ".quote-share"; |
| 20 | + // Bluesky's post limit, in graphemes. The composer counts a link at its |
| 21 | + // shortened, displayed length (host and the start of the path), so the |
| 22 | + // link is charged a flat allowance rather than its full length. |
| 23 | + const POST_LIMIT = 300; |
| 24 | + const LINK_ALLOWANCE = 32; |
| 25 | + // A passage up to this many words is quoted whole in the fragment; past |
| 26 | + // it, the fragment names only its first and last few words. |
| 27 | + const EXACT_WORDS = 10; |
| 28 | + const EDGE_WORDS = 4; |
| 29 | + const SHOWN = 1900; |
| 30 | + let raf = 0, hide; |
| 31 | + |
| 32 | + // The live selection, if it is a non-empty one inside a post and the page |
| 33 | + // has a bar to offer - null in every other case, which hides the bar. |
| 34 | + const current = () => { |
| 35 | + const sel = document.getSelection(); |
| 36 | + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null; |
| 37 | + const range = sel.getRangeAt(0); |
| 38 | + let node = range.commonAncestorContainer; |
| 39 | + if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement; |
| 40 | + if (!node?.closest(BODY)) return null; |
| 41 | + const bar = document.querySelector(BAR); |
| 42 | + const text = sel.toString().replace(//g, "").trim(); |
| 43 | + return bar && text ? {sel, range, text, bar} : null; |
| 44 | + }; |
| 45 | + |
| 46 | + // Whether the selection was dragged right to left. `direction` is recent |
| 47 | + // (Chrome only, at the time of writing); the node comparison is the same |
| 48 | + // question asked the long way round. |
| 49 | + const backward = (sel) => { |
| 50 | + if (sel.direction) return sel.direction === "backward"; |
| 51 | + if (sel.anchorNode === sel.focusNode) return sel.focusOffset < sel.anchorOffset; |
| 52 | + return !!(sel.anchorNode.compareDocumentPosition(sel.focusNode) & Node.DOCUMENT_POSITION_PRECEDING); |
| 53 | + }; |
| 54 | + |
| 55 | + // Next to the end the reader is holding, the way Leaflet does it: under |
| 56 | + // the last line of a selection dragged forwards, over the first line of |
| 57 | + // one dragged backwards. Whichever side has no room gives way to the |
| 58 | + // other, and the bar never runs off either edge of the viewport. |
| 59 | + const place = ({sel, range, bar}) => { |
| 60 | + const rects = range.getClientRects(); |
| 61 | + const all = range.getBoundingClientRect(); |
| 62 | + const back = backward(sel); |
| 63 | + const edge = (back ? rects[0] : rects[rects.length - 1]) || all; |
| 64 | + const w = bar.offsetWidth, h = bar.offsetHeight, gap = 8; |
| 65 | + let top = back ? edge.top - h - gap : edge.bottom + gap; |
| 66 | + if (top + h > innerHeight - gap) top = all.top - h - gap; |
| 67 | + if (top < gap) top = Math.min(all.bottom + gap, innerHeight - h - gap); |
| 68 | + const x = back ? edge.left : edge.right; |
| 69 | + const left = Math.max(gap, Math.min(x - w / 2, innerWidth - w - gap)); |
| 70 | + bar.style.top = `${Math.round(top)}px`; |
| 71 | + bar.style.left = `${Math.round(left)}px`; |
| 72 | + }; |
| 73 | + |
| 74 | + const update = () => { |
| 75 | + const now = current(); |
| 76 | + for (const bar of document.querySelectorAll(BAR)) { |
| 77 | + if (bar !== now?.bar) bar.hidden = true; |
| 78 | + } |
| 79 | + if (!now) return; |
| 80 | + now.bar.hidden = false; |
| 81 | + place(now); |
| 82 | + }; |
| 83 | + |
| 84 | + // Text fragments only match whole words, so a selection that starts or |
| 85 | + // ends halfway through one would point at nothing. When it does, the |
| 86 | + // stray piece of word is left out of the fragment - the quote still says |
| 87 | + // what the reader chose. |
| 88 | + const WORD = /[\p{L}\p{N}]/u; |
| 89 | + const midWord = (node, offset) => |
| 90 | + node.nodeType === Node.TEXT_NODE && |
| 91 | + WORD.test(node.data.charAt(offset - 1)) && WORD.test(node.data.charAt(offset)); |
| 92 | + |
| 93 | + // -, & and , are the fragment syntax's own punctuation and have to be |
| 94 | + // escaped; encodeURIComponent already takes care of the other two. |
| 95 | + const enc = (s) => encodeURIComponent(s).replace(/-/g, "%2D"); |
| 96 | + |
| 97 | + const fragment = ({range, text}) => { |
| 98 | + // A fragment's start and end each have to sit within one block, so a |
| 99 | + // passage running over several paragraphs is named by its first and last. |
| 100 | + // split() with a capture group keeps the whitespace in between, so the |
| 101 | + // no-break spaces the build puts in reach the fragment as they are in the |
| 102 | + // page. Words sit at the even indexes. With a single line, first and last |
| 103 | + // are the same array, and both trims below apply to it. |
| 104 | + const lines = text.split(/\n+/).map((l) => l.trim()).filter(Boolean).map((l) => l.split(/(\s+)/)); |
| 105 | + const first = lines[0], last = lines[lines.length - 1]; |
| 106 | + if (midWord(range.startContainer, range.startOffset) && first.length > 1) first.splice(0, 2); |
| 107 | + if (midWord(range.endContainer, range.endOffset) && last.length > 1) last.splice(-2); |
| 108 | + if (lines.length === 1 && (first.length + 1) / 2 <= EXACT_WORDS) return enc(first.join("")); |
| 109 | + const start = first.slice(0, EDGE_WORDS * 2 - 1).join(""); |
| 110 | + const end = last.slice(-(EDGE_WORDS * 2 - 1)).join(""); |
| 111 | + return `${enc(start)},${enc(end)}`; |
| 112 | + }; |
| 113 | + |
| 114 | + // location, not <link rel="canonical">: hx-boost leaves the first page's |
| 115 | + // canonical behind in <head> for the rest of the visit. |
| 116 | + const link = (now) => `${location.origin}${location.pathname}#:~:text=${fragment(now)}`; |
| 117 | + |
| 118 | + const graphemes = (s) => |
| 119 | + typeof Intl.Segmenter === "function" |
| 120 | + ? [...new Intl.Segmenter().segment(s)].map((g) => g.segment) |
| 121 | + : [...s]; |
| 122 | + |
| 123 | + const post = (now) => { |
| 124 | + const {bar, text} = now; |
| 125 | + const open = bar.dataset.quoteOpen || "“", close = bar.dataset.quoteClose || "”"; |
| 126 | + let quote = text.replace(/\s*\n\s*/g, "\n"); |
| 127 | + const room = POST_LIMIT - LINK_ALLOWANCE - graphemes(open + close).length - 2; |
| 128 | + const g = graphemes(quote); |
| 129 | + if (g.length > room) quote = g.slice(0, room - 1).join("").trimEnd() + "…"; |
| 130 | + return `${open}${quote}${close}\n\n${link(now)}`; |
| 131 | + }; |
| 132 | + |
| 133 | + // The same corner toast the feed page uses (style.scss's .copy-toast). |
| 134 | + const toast = (message) => { |
| 135 | + let el = document.querySelector(".copy-toast"); |
| 136 | + if (!el) { |
| 137 | + el = document.createElement("div"); |
| 138 | + el.className = "copy-toast"; |
| 139 | + el.setAttribute("role", "status"); |
| 140 | + document.body.appendChild(el); |
| 141 | + } |
| 142 | + el.textContent = message; |
| 143 | + el.classList.remove("is-on"); |
| 144 | + void el.getBoundingClientRect().width; |
| 145 | + el.classList.add("is-on"); |
| 146 | + clearTimeout(hide); |
| 147 | + hide = setTimeout(() => el.classList.remove("is-on"), SHOWN); |
| 148 | + }; |
| 149 | + |
| 150 | + const actions = { |
| 151 | + bluesky(now) { |
| 152 | + const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(post(now))}`; |
| 153 | + window.open(url, "_blank", "noopener,noreferrer"); |
| 154 | + }, |
| 155 | + async link(now) { |
| 156 | + try { |
| 157 | + await navigator.clipboard.writeText(link(now)); |
| 158 | + toast(now.bar.dataset.quoteCopied || "Copied"); |
| 159 | + } catch { |
| 160 | + // No clipboard (insecure context, refused permission): the page's |
| 161 | + // own address bar is the next best thing to copy from. |
| 162 | + history.replaceState(history.state, "", link(now)); |
| 163 | + } |
| 164 | + }, |
| 165 | + }; |
| 166 | + |
| 167 | + document.addEventListener("selectionchange", update); |
| 168 | + // Scrolling moves the selection and not the fixed bar, so the bar follows, |
| 169 | + // once per frame at most. |
| 170 | + const follow = () => { |
| 171 | + raf ||= requestAnimationFrame(() => { raf = 0; const now = current(); if (now && !now.bar.hidden) place(now); }); |
| 172 | + }; |
| 173 | + addEventListener("scroll", follow, {capture: true, passive: true}); |
| 174 | + addEventListener("resize", follow); |
| 175 | + |
| 176 | + // Pressing a button must not be what clears the selection it acts on. |
| 177 | + document.addEventListener("mousedown", (e) => { |
| 178 | + if (e.target.closest?.(BAR)) e.preventDefault(); |
| 179 | + }); |
| 180 | + document.addEventListener("click", (e) => { |
| 181 | + const button = e.target.closest?.(`${BAR} [data-quote-action]`); |
| 182 | + const now = button && current(); |
| 183 | + if (now) actions[button.dataset.quoteAction]?.(now); |
| 184 | + }); |
| 185 | + document.addEventListener("keydown", (e) => { |
| 186 | + if (e.key !== "Escape") return; |
| 187 | + for (const bar of document.querySelectorAll(BAR)) bar.hidden = true; |
| 188 | + }); |
| 189 | +})(); |