diff --git a/sass/assets/css/style.scss b/sass/assets/css/style.scss index 4bf4052..509b52f 100644 --- a/sass/assets/css/style.scss +++ b/sass/assets/css/style.scss @@ -104,6 +104,102 @@ pre,code { background-color: initial; } +.code-copy-wrapper { + position: relative; +} + +.code-copy-button { + position: absolute; + top: 0.65rem; + right: 0.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background-color: rgba(var(--bs-body-bg-rgb), 0.92); + color: var(--bs-secondary-color); + box-shadow: 0 0.15rem 0.5rem rgba(0, 0, 0, 0.12); + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(-0.15rem); + transition: + opacity 0.18s ease, + transform 0.18s ease, + border-color 0.18s ease; +} + +.code-copy-wrapper:hover .code-copy-button, +.code-copy-wrapper:focus-within .code-copy-button { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(0); +} + +.code-copy-button:hover, +.code-copy-button:focus-visible { + border-color: #fff; +} + +.code-copy-button:focus-visible { + outline: 2px solid var(--bs-code-color); + outline-offset: 2px; +} + +.code-copy-button__icon { + display: inline-flex; + align-items: center; + justify-content: center; + transition: filter 0.18s ease, color 0.18s ease; +} + +.code-copy-button:hover .code-copy-button__icon, +.code-copy-button:focus-visible .code-copy-button__icon { + filter: brightness(1.12); + color: #fff; +} + +.code-copy-button__icon svg { + width: 0.95rem; + height: 0.95rem; +} + +.code-copy-button__status { + display: none; + font-size: 0.95rem; + line-height: 1; + font-weight: 700; +} + +.code-copy-button[data-copy-state="idle"] .code-copy-button__icon, +.code-copy-button[data-copy-state="working"] .code-copy-button__icon { + display: inline-flex; +} + +.code-copy-button[data-copy-state="success"] .code-copy-button__icon, +.code-copy-button[data-copy-state="error"] .code-copy-button__icon { + display: none; +} + +.code-copy-button[data-copy-state="success"] .code-copy-button__status--success, +.code-copy-button[data-copy-state="error"] .code-copy-button__status--error { + display: inline-flex; +} + +.code-copy-button[data-copy-state="success"] { + color: var(--bs-secondary-color); +} + +.code-copy-button[data-copy-state="error"] { + color: var(--bs-danger-text-emphasis); + border-color: currentColor; +} + .eiptable .title { width: 67%; } diff --git a/static/assets/scripts/copy-code.mjs b/static/assets/scripts/copy-code.mjs new file mode 100644 index 0000000..f1e3722 --- /dev/null +++ b/static/assets/scripts/copy-code.mjs @@ -0,0 +1,99 @@ +const SUCCESS_RESET_MS = 2000; +const FAILURE_RESET_MS = 2500; +const COPY_ICON = ` + +`; + +const setCopyState = (button, state) => { + button.dataset.copyState = state; +}; + +const resetCopyStateLater = (button, delayMs) => { + if (button._resetTimer) { + window.clearTimeout(button._resetTimer); + } + + button._resetTimer = window.setTimeout(() => { + setCopyState(button, "idle"); + button._resetTimer = null; + }, delayMs); +}; + +const copyText = async (text) => { + if (!navigator.clipboard?.writeText) { + throw new Error("Clipboard API is unavailable"); + } + + await navigator.clipboard.writeText(text); +}; + +const handleCopyClick = async (button, codeBlock) => { + setCopyState(button, "working"); + + try { + await copyText(codeBlock.textContent ?? ""); + setCopyState(button, "success"); + resetCopyStateLater(button, SUCCESS_RESET_MS); + } catch (error) { + console.error("Unable to copy code block contents", error); + setCopyState(button, "error"); + resetCopyStateLater(button, FAILURE_RESET_MS); + } +}; + +const createCopyButton = (codeBlock) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "code-copy-button"; + button.dataset.copyState = "idle"; + button.setAttribute("aria-label", "Copy code to clipboard"); + button.innerHTML = ` + + + + `; + + button.addEventListener("click", () => { + void handleCopyClick(button, codeBlock); + }); + + return button; +}; + +const wrapCodeBlock = (pre) => { + const wrapper = document.createElement("div"); + wrapper.className = "code-copy-wrapper"; + pre.insertAdjacentElement("beforebegin", wrapper); + wrapper.appendChild(pre); + return wrapper; +}; + +const installCopyButtons = () => { + const codeBlocks = document.querySelectorAll("pre.giallo.z-code > code"); + + for (const codeBlock of codeBlocks) { + const pre = codeBlock.parentElement; + if (!pre) { + continue; + } + + if (!pre.hasAttribute("tabindex")) { + pre.tabIndex = 0; + } + + const wrapper = pre.parentElement?.classList.contains("code-copy-wrapper") + ? pre.parentElement + : wrapCodeBlock(pre); + + if (wrapper.querySelector(":scope > .code-copy-button")) { + continue; + } + + wrapper.appendChild(createCopyButton(codeBlock)); + } +}; + +document.addEventListener("DOMContentLoaded", installCopyButtons); diff --git a/templates/base.html b/templates/base.html index 22155e5..38d08a2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -127,9 +127,10 @@ integrity="sha384-/1zmJ1mBdfKIOnwPxpdG6yaRrxP6qu3eVYm0cz2nOx+AcL4d3AqEFrwcqGZVVroG" crossorigin="anonymous" > - - - + + + + {% block nav %}