diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 0ded574b..eed61186 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1645,6 +1645,81 @@ img.avatar { .markdown-rendered h2:first-child, .markdown-rendered h3:first-child { margin-top: 0; } +.section-heading { + display: flex; + align-items: center; +} + +.section-heading__title { + min-width: 0; +} + +.section-permalink { + display: flex; + flex: 0 0 1.5em; + align-items: center; + justify-content: center; + height: 1.5em; + margin-left: var(--space-xs); + border: 0; + color: var(--color-text-muted); + opacity: 0; + position: relative; + transition: opacity 120ms ease, color 120ms ease; +} + +.markdown-rendered h1:hover > .section-permalink, +.markdown-rendered h2:hover > .section-permalink, +.markdown-rendered h3:hover > .section-permalink, +.section-permalink:focus-visible { + opacity: 1; +} + +.section-permalink:hover, +.section-permalink:focus-visible { + color: var(--color-primary); +} + +.section-permalink[data-copy-state="copied"] { + color: var(--color-success); + opacity: 1; +} + +.section-permalink[data-copy-state="failed"] { + color: var(--color-danger); + opacity: 1; +} + +.section-permalink[data-copy-state]::after { + content: attr(data-copy-message); + position: absolute; + left: calc(100% + var(--space-xs)); + top: 50%; + transform: translateY(-50%); + padding: 0.25rem 0.45rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + box-shadow: var(--shadow-pop); + color: var(--color-text); + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + white-space: nowrap; + z-index: 2; +} + +@media (hover: none) { + .section-permalink { opacity: 1; } +} + +@media (max-width: 640px) { + .section-permalink[data-copy-state]::after { + left: auto; + right: 0; + } +} + .markdown-rendered p { margin-bottom: var(--space-md); line-height: 1.9; diff --git a/engine/app/assets/stylesheets/coplan/deck.css b/engine/app/assets/stylesheets/coplan/deck.css index f5336cbf..aee79919 100644 --- a/engine/app/assets/stylesheets/coplan/deck.css +++ b/engine/app/assets/stylesheets/coplan/deck.css @@ -241,6 +241,10 @@ .deck-slide--title .deck-content h1, .deck-slide--title .deck-content h2, .deck-slide--title .deck-content h3 { + display: grid; + grid-template-columns: minmax(0, auto) auto; + align-items: center; + justify-content: start; font-size: 2.8em; line-height: 1.1; letter-spacing: -0.02em; @@ -256,6 +260,7 @@ display: block; width: 1.6em; height: 0.16em; + grid-column: 1 / -1; border-radius: 0.08em; background: var(--deck-title-accent, var(--deck-accent)); margin-bottom: 0.55em; diff --git a/engine/app/javascript/controllers/coplan/content_nav_controller.js b/engine/app/javascript/controllers/coplan/content_nav_controller.js index 578f8fa6..77379dac 100644 --- a/engine/app/javascript/controllers/coplan/content_nav_controller.js +++ b/engine/app/javascript/controllers/coplan/content_nav_controller.js @@ -52,18 +52,64 @@ export default class extends Controller { this.sidebarTarget.style.display = "" if (this.hasShowBtnTarget) this.showBtnTarget.style.display = "" - const usedIds = new Set() + // Commonmarker supplies an empty self-link inside each heading. Promote + // its generated fragment to the heading itself when that heading does + // not already own a different server-assigned id. Visible or non-self + // links are authored content and stay untouched. + const promotedAnchors = new Map() + this._headings.forEach(heading => { + const anchor = Array.from(heading.querySelectorAll("a.anchor[id]")) + .find(candidate => candidate.textContent.trim() === "" && + candidate.getAttribute("href") === `#${candidate.id}` && + (!heading.id || heading.id === candidate.id)) + if (anchor) promotedAnchors.set(heading, anchor) + }) + + const headingSet = new Set(this._headings) + const promotedAnchorSet = new Set(promotedAnchors.values()) + const usedIds = new Set( + Array.from(document.querySelectorAll("[id]")) + .filter(element => !headingSet.has(element) && !promotedAnchorSet.has(element)) + .map(element => element.id) + ) + const pageUrl = new URL(window.location.href) + pageUrl.search = "" this._headings.forEach((heading, index) => { - let baseId = heading.id || this.slugify(heading.textContent) || `section-${index + 1}` + heading.querySelector(":scope > .section-permalink")?.remove() + const promotedAnchor = promotedAnchors.get(heading) + const headingText = heading.textContent.trim().replace(/\s+/g, " ") + let baseId = heading.id || promotedAnchor?.id || this.slugify(headingText) || `section-${index + 1}` let id = baseId let suffix = 2 while (usedIds.has(id)) { id = `${baseId}-${suffix++}` } heading.id = id + promotedAnchor?.remove() + heading.classList.add("section-heading") usedIds.add(id) + let title = heading.querySelector(":scope > .section-heading__title") + if (!title) { + title = document.createElement("span") + title.className = "section-heading__title" + while (heading.firstChild) title.appendChild(heading.firstChild) + heading.appendChild(title) + } + + const permalink = document.createElement("a") + const sectionUrl = new URL(pageUrl) + sectionUrl.hash = id + permalink.className = "section-permalink" + permalink.href = sectionUrl.href + permalink.dataset.action = "click->coplan--content-nav#copySectionLink" + permalink.dataset.sectionTitle = headingText + permalink.setAttribute("aria-label", `Copy link to ${headingText}`) + permalink.title = "Copy link to this section" + permalink.innerHTML = '' + heading.appendChild(permalink) + const li = document.createElement("li") li.className = `content-nav__item content-nav__item--${heading.tagName.toLowerCase()}` li.dataset.headingId = id @@ -75,7 +121,7 @@ export default class extends Controller { const text = document.createElement("span") text.className = "content-nav__link-text" - text.textContent = heading.textContent + text.textContent = headingText a.appendChild(text) li.appendChild(a) @@ -96,6 +142,9 @@ export default class extends Controller { } setupScrollTracking() { + if (this._scrollHandler) { + window.removeEventListener("scroll", this._scrollHandler) + } if (!this._headings || this._headings.length === 0) return this._scrollHandler = () => { @@ -118,6 +167,12 @@ export default class extends Controller { this._updateActiveFromScroll() } + contentUpdated() { + this._activeHeadingId = null + this.buildToc() + this.setupScrollTracking() + } + _updateActiveFromScroll() { const threshold = 100 let active = null @@ -147,6 +202,35 @@ export default class extends Controller { heading.scrollIntoView({ behavior: "smooth", block: "start" }) } + async copySectionLink(event) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + + event.preventDefault() + const link = event.currentTarget + + try { + await navigator.clipboard.writeText(link.href) + this.flashSectionLink(link, "copied", "Copied link to section") + } catch { + this.flashSectionLink(link, "failed", "Copy failed") + } + } + + flashSectionLink(link, state, label) { + link.dataset.copyState = state + link.dataset.copyMessage = state === "copied" ? "Copied!" : "Copy failed" + link.setAttribute("aria-label", label) + link.title = label + + clearTimeout(link._copyResetTimer) + link._copyResetTimer = setTimeout(() => { + link.removeAttribute("data-copy-state") + link.removeAttribute("data-copy-message") + link.setAttribute("aria-label", `Copy link to ${link.dataset.sectionTitle}`) + link.title = "Copy link to this section" + }, 2000) + } + // The back-matter links (References, Attachments) jump the same way the // outline above does. Turbo counts a same-page fragment link as a full // visit — it refetches and re-renders the page — so the bare anchor read diff --git a/engine/app/javascript/controllers/coplan/live_update_controller.js b/engine/app/javascript/controllers/coplan/live_update_controller.js index 924cb999..79705564 100644 --- a/engine/app/javascript/controllers/coplan/live_update_controller.js +++ b/engine/app/javascript/controllers/coplan/live_update_controller.js @@ -63,6 +63,7 @@ export default class extends Controller { if (incomingRevision) { target.setAttribute("data-coplan--live-update-revision-value", String(incomingRevision)) } + target.dispatchEvent(new CustomEvent("coplan:content-updated", { bubbles: true })) clearStaleBanner() } } diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index fa5bb637..35d9dbb3 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -48,7 +48,7 @@
<% if @plan.current_content.present? %> -
+