Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 37 additions & 27 deletions lib/lexical/nodes/misc/heading.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HeadingNode } from '@lexical/rich-text'
import { slug } from 'github-slugger'
import { setNodeIndentFromDOM, $applyNodeReplacement, $createParagraphNode } from 'lexical'
import { setNodeIndentFromDOM, $applyNodeReplacement, $createParagraphNode, $isTextNode, $isLineBreakNode } from 'lexical'

const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
// identifies the decorative anchor link; the sn-heading__link class is styling only
Expand All @@ -27,13 +27,14 @@ function isGoogleDocsTitle (domNode) {
return false
}

function createHeadingAnchorLink (domElement, headingId, textContent) {
// empty anchor; the reconciler (getDOMSlot) / exportDOM (append) fill it with the
// heading's children so the whole heading becomes a permalink
function createHeadingAnchorLink (domElement, headingId) {
const doc = domElement.ownerDocument
const link = doc.createElement('a')
link.setAttribute(HEADING_LINK_ATTRIBUTE, 'true')
link.className = 'sn-heading__link'
link.setAttribute('href', `#${headingId}`)
link.textContent = textContent
return link
}

Expand All @@ -42,20 +43,17 @@ function setHeadingId (domElement, headingId) {
else domElement.removeAttribute('id')
}

function tryUpdateHeadingAnchorLink (domElement, headingId, textContent) {
// returns the anchor element if this heading is rendered as a permalink, null otherwise
function getHeadingAnchorLink (domElement) {
const first = domElement.firstChild
if (first && first.nodeName === 'A' && first.hasAttribute(HEADING_LINK_ATTRIBUTE)) {
first.setAttribute('href', `#${headingId}`)
first.textContent = textContent
return true
}
return false
return first && first.nodeName === 'A' && first.hasAttribute(HEADING_LINK_ATTRIBUTE) ? first : null
}

function upsertHeadingAnchorLink (domElement, headingId, textContent) {
if (tryUpdateHeadingAnchorLink(domElement, headingId, textContent)) return
domElement.insertBefore(createHeadingAnchorLink(domElement, headingId, textContent), domElement.firstChild)
// a heading is only wrapped in an anchor when its content is plain text
function $headingHasOnlyTextContent (node) {
return node.getChildren().every(child => $isTextNode(child) || $isLineBreakNode(child))
}

// re-implements HeadingNode with slug support
export class SNHeadingNode extends HeadingNode {
static getType () {
Expand All @@ -73,29 +71,38 @@ export class SNHeadingNode extends HeadingNode {
// headings are not links by default, because lexical creates a span
// so if we were to append a link to the element, it would render as sibling
// instead of wrapping the text in a link.
// the workaround used here is to use CSS to make this clickable.
createDOM (config, editor) {
const element = super.createDOM(config, editor)
// anchor navigation
const headingId = this.getSlug()
if (headingId) {
setHeadingId(element, headingId)

if (editor && !editor.isEditable()) {
upsertHeadingAnchorLink(element, headingId, this.getTextContent())
// in read mode, wrap the heading text in a real anchor (filled by getDOMSlot)
if (editor && !editor.isEditable() && $headingHasOnlyTextContent(this)) {
element.appendChild(createHeadingAnchorLink(element, headingId))
}
}

return element
}

// route the heading's children into the anchor wrapper when present, so the
// reconciler reconciles them inside the <a> instead of directly in the <hN>
getDOMSlot (element) {
const link = getHeadingAnchorLink(element)
const slot = super.getDOMSlot(element)
return link ? slot.withElement(link) : slot
}

// update ID (and the anchor href) on content changes
updateDOM (prevNode, dom, config) {
// update ID on content changes
const prevSlug = prevNode.getSlug()
const currentSlug = this.getSlug()
if (prevSlug !== currentSlug) {
setHeadingId(dom, currentSlug)
tryUpdateHeadingAnchorLink(dom, currentSlug, this.getTextContent())
const link = getHeadingAnchorLink(dom)
if (link) link.setAttribute('href', `#${currentSlug}`)
}
return super.updateDOM(prevNode, dom, config)
}
Expand All @@ -104,12 +111,14 @@ export class SNHeadingNode extends HeadingNode {
const { element } = super.exportDOM(editor)
const headingId = this.getSlug()

if (headingId) {
setHeadingId(element, headingId)
upsertHeadingAnchorLink(element, headingId, this.getTextContent())
}
if (!headingId) return { element }
setHeadingId(element, headingId)

return { element }
// keep the SSR HTML placeholder aligned with the reader
if (!$headingHasOnlyTextContent(this)) return { element }
const link = createHeadingAnchorLink(element, headingId)
element.appendChild(link)
return { element, append: (fragment) => link.append(fragment) }
}

// override
Expand Down Expand Up @@ -156,14 +165,15 @@ export class SNHeadingNode extends HeadingNode {

return {
...headingConverters,
// the reader renders a decorative anchor link inside headings (createDOM,
// read-only) that is not part of the editor state. Skip it (and its text)
// so importing from HTML doesn't import it as a duplicate link.
// the reader wraps heading text in a decorative permalink anchor (createDOM,
// read-only) that is not part of the editor state. Unwrap it on import, drop
// the <a> but keep its text children (they hoist into the heading), so HTML
// doesn't round-trip into a real link node.
// runs regardless of which heading converter created the parent.
a: node => {
if (!node.hasAttribute(HEADING_LINK_ATTRIBUTE)) return null
return {
conversion: () => ({ node: null, forChild: () => null }),
conversion: () => ({ node: null }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy heading import duplicates text

Medium Severity

Removing forChild: () => null from the decorative heading-link import means text inside the permalink a is imported as well as sibling heading content. HTML produced by the previous sibling-anchor export (anchor text plus reconciled spans) can round-trip into duplicated heading text when pasted or imported.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e1ec893. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We render the lexical state and the HTML dynamically, this can't happen.

priority: 3
}
},
Expand Down
17 changes: 6 additions & 11 deletions styles/text.scss
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@
margin-top: 0.75rem;
margin-bottom: 0.5rem;
font-size: 1rem;
position: relative;
}

.topLevel h1.sn-heading {
Expand All @@ -146,19 +145,14 @@
font-size: 1.15rem;
}

/* stays on top of the heading */
/* the heading text itself is the permalink; look like a heading, hint on hover */
.sn-heading__link {
width: fit-content;
position: absolute;
inset: 0;
opacity: 0;
z-index: 1;
user-select: text;
color: inherit;
text-decoration: none;
}

.sn-heading__link:has(~ .sn-link),
.sn-link ~ .sn-heading__link {
display: none;
.sn-heading__link:hover {
color: inherit;
}

/* blocks */
Expand All @@ -173,6 +167,7 @@
margin-bottom: 0;
}
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: var(--theme-borderColor) var(--theme-commentBg);
}
Expand Down
Loading