-
Notifications
You must be signed in to change notification settings - Fork 12
Add section shortcut links #205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
661be37
dc03396
fb2e9ec
0196a12
50ee32c
bbf6a30
5ac10e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)); | ||
|
Comment on lines
+1693
to
+1696
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On a narrow viewport with a long heading, the flex layout places the shortcut at the card's right edge, and this absolute pseudo-element always opens to its right. The “Copied!”/failure bubble consequently extends beyond the viewport and creates horizontal overflow or partially hidden feedback; position it inward at phone widths or otherwise clamp it to the viewport. AGENTS.md reference: AGENTS.md:L87-L89 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated by Amp 🤖 — at phone widths the feedback bubble is right-aligned to the shortcut so it opens inward. Browser coverage clicks it at 390px and verifies both the right edge and absence of horizontal overflow. |
||
| 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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
Comment on lines
+101
to
+104
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an open plan receives a live revision, AGENTS.md reference: AGENTS.md:L76-L80 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated by Amp 🤖 — live body replacements now dispatch a bubbling content-updated event; the content-nav action rebuilds headings, shortcut links, the outline, and scroll tracking. The checkbox live-update system spec verifies the rebuilt link. |
||
| permalink.href = sectionUrl.href | ||
|
Comment on lines
+102
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When another element outside the H1–H3 outline already owns this generated ID, the shortcut copies a fragment that resolves to that earlier element rather than this heading. For example, the Markdown renderer assigns Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated by Amp 🤖 — generated Commonmarker self-anchors are replaced by the shortcut, and every remaining document ID is reserved before heading IDs are assigned. Browser coverage injects an existing fragment and verifies the heading receives a unique suffixed target. |
||
| 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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>' | ||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the plan was opened from a notification using Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated by Amp 🤖 — shortcut hrefs now start from the canonical plan URL and strip query parameters before adding the section fragment. Coverage opens through a transient thread query and verifies it is absent from the shortcut.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up by Amp 🤖 — this now derives from the current page URL (so historical versions retain their own path), strips its query, and adds only the section fragment. |
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For presentation plans with a title slide,
buildToc()adds this class to the slide's heading, sodisplay: flexturns theh1::beforeaccent bar defined indeck.cssinto a horizontal flex item beside the title instead of the block above it. This visibly breaks the title-slide composition—and can reflow it when Stimulus connects—in both the embedded deck and presentation mode; scope the row layout away from deck title headings or explicitly preserve their vertical accent layout.AGENTS.md reference: AGENTS.md:L87-L89
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated by Amp 🤖 — title-slide headings now use a two-column grid with the accent spanning the full first row, preserving the accent above the title while centering the shortcut beside it. Added browser coverage for both the accent placement and title/link centerlines.