-
Notifications
You must be signed in to change notification settings - Fork 145
SD-2663 - feature: support table of contents hovering #3333
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
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 |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ export function applyTocMetadata( | |
| gallery?: string | null; | ||
| uniqueId?: string | null; | ||
| instruction?: string | null; | ||
| tocId?: string | null; | ||
| }, | ||
| ): void { | ||
| blocks.forEach((block) => { | ||
|
|
@@ -53,6 +54,9 @@ export function applyTocMetadata( | |
| if (metadata.instruction) { | ||
| block.attrs.tocInstruction = metadata.instruction; | ||
| } | ||
| if (metadata.tocId) { | ||
|
Contributor
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. the |
||
| block.attrs.tocId = metadata.tocId; | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
@@ -95,6 +99,11 @@ export function processTocChildren( | |
| docPartObjectId?: string; | ||
| tocInstruction?: string; | ||
| sdtMetadata?: SdtMetadata; | ||
| /** | ||
| * Stable id shared by every paragraph in the TOC. Used by the | ||
| * painter/presentation editor to coordinate hover across all entries. | ||
| */ | ||
| tocId?: string; | ||
| }, | ||
| context: { | ||
| nextBlockId: BlockIdGenerator; | ||
|
|
@@ -114,7 +123,7 @@ export function processTocChildren( | |
| }, | ||
| ): void { | ||
| const paragraphConverter = context.converters.paragraphToFlowBlocks; | ||
| const { docPartGallery, docPartObjectId, tocInstruction } = metadata; | ||
| const { docPartGallery, docPartObjectId, tocInstruction, tocId } = metadata; | ||
| const { blocks, recordBlockKind } = outputArrays; | ||
|
|
||
| children.forEach((child) => { | ||
|
|
@@ -147,6 +156,7 @@ export function processTocChildren( | |
| gallery: docPartGallery, | ||
| uniqueId: docPartObjectId, | ||
| instruction: tocInstruction, | ||
| tocId, | ||
| }); | ||
| applySdtMetadataToParagraphBlocks( | ||
| paragraphBlocks.filter((b) => b.kind === 'paragraph') as ParagraphBlock[], | ||
|
|
@@ -166,7 +176,13 @@ export function processTocChildren( | |
|
|
||
| processTocChildren( | ||
| child.content, | ||
| { docPartGallery, docPartObjectId, tocInstruction: finalInstruction, sdtMetadata: metadata.sdtMetadata }, | ||
| { | ||
| docPartGallery, | ||
| docPartObjectId, | ||
| tocInstruction: finalInstruction, | ||
| sdtMetadata: metadata.sdtMetadata, | ||
| tocId, | ||
| }, | ||
| context, | ||
| outputArrays, | ||
| ); | ||
|
|
@@ -189,12 +205,14 @@ export function processTocChildren( | |
| export function handleTableOfContentsNode(node: PMNode, context: NodeHandlerContext): void { | ||
| if (!Array.isArray(node.content)) return; | ||
|
|
||
| // Direct tableOfContents nodes have no enclosing SDT — use sdBlockId as the | ||
| // hover group key, and omit gallery so applyTocMetadata doesn't fabricate one. | ||
| const sdBlockId = (node.attrs as { sdBlockId?: unknown } | undefined)?.sdBlockId; | ||
| processTocChildren( | ||
| node.content, | ||
| { | ||
| // No enclosing SDT — omit gallery so applyTocMetadata does not fabricate | ||
| // a docPartObject sdt entry on each TOC paragraph. | ||
| tocInstruction: getNodeInstruction(node), | ||
| tocId: typeof sdBlockId === 'string' ? sdBlockId : undefined, | ||
| }, | ||
| { | ||
| nextBlockId: context.nextBlockId, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -564,6 +564,14 @@ export class PresentationEditor extends EventEmitter { | |
| id: string | null; | ||
| elements: HTMLElement[]; | ||
| } | null = null; | ||
| /** | ||
| * TOC group hover state — mirror of #lastHoveredStructuredContentBlock | ||
| * for paragraph fragments sharing the same `data-toc-id`. | ||
| */ | ||
| #lastHoveredTocGroup: { | ||
| id: string; | ||
| elements: HTMLElement[]; | ||
| } | null = null; | ||
|
|
||
| // Remote cursor/presence state management | ||
| /** Manager for remote cursor rendering and awareness subscriptions */ | ||
|
|
@@ -671,9 +679,12 @@ export class PresentationEditor extends EventEmitter { | |
| ensureEditorFieldAnnotationInteractionStyles(doc); | ||
| ensureEditorMovableObjectInteractionStyles(doc); | ||
|
|
||
| // Add event listeners for structured content hover coordination | ||
| // Hover coordination — structured-content blocks and TOC entries each | ||
| // group their fragments by id so the whole control greys out together. | ||
| this.#painterHost.addEventListener('mouseover', this.#handleStructuredContentBlockMouseEnter); | ||
| this.#painterHost.addEventListener('mouseout', this.#handleStructuredContentBlockMouseLeave); | ||
| this.#painterHost.addEventListener('mouseover', this.#handleTocEntryMouseEnter); | ||
| this.#painterHost.addEventListener('mouseout', this.#handleTocEntryMouseLeave); | ||
|
|
||
| const win = this.#visibleHost?.ownerDocument?.defaultView ?? window; | ||
| this.#domIndexObserverManager = new DomPositionIndexObserverManager({ | ||
|
|
@@ -6739,6 +6750,117 @@ export class PresentationEditor extends EventEmitter { | |
| this.#lastHoveredStructuredContentBlock = { id, elements }; | ||
| } | ||
|
|
||
| /** TOC analogue of {@link #handleStructuredContentBlockMouseEnter}, keyed by `data-toc-id`. */ | ||
| #handleTocEntryMouseEnter = (event: MouseEvent) => { | ||
|
Contributor
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. none of the new hover logic has tests — group highlight on enter, cross-group clear on leave, same-group re-entry guard, |
||
| const target = event.target as HTMLElement | null; | ||
| const entry = target?.closest?.(`.${DOM_CLASS_NAMES.TOC_ENTRY}`) as HTMLElement | null; | ||
| if (!entry) return; | ||
|
|
||
| const tocId = entry.dataset.tocId; | ||
| if (!tocId) return; | ||
|
|
||
| this.#setHoveredTocGroup(tocId); | ||
| }; | ||
|
|
||
| #handleTocEntryMouseLeave = (event: MouseEvent) => { | ||
| const target = event.target as HTMLElement | null; | ||
| const entry = target?.closest?.(`.${DOM_CLASS_NAMES.TOC_ENTRY}`) as HTMLElement | null; | ||
| if (!entry) return; | ||
|
|
||
| const tocId = entry.dataset.tocId; | ||
| if (!tocId) return; | ||
|
|
||
| const relatedTarget = event.relatedTarget as HTMLElement | null; | ||
| if (relatedTarget) { | ||
| const nextEntry = relatedTarget.closest?.(`.${DOM_CLASS_NAMES.TOC_ENTRY}`) as HTMLElement | null; | ||
| if (nextEntry && nextEntry.dataset.tocId === tocId) { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| this.#clearHoveredTocGroup(); | ||
| }; | ||
|
|
||
| #clearHoveredTocGroup() { | ||
| if (!this.#lastHoveredTocGroup) return; | ||
| this.#lastHoveredTocGroup.elements.forEach((element) => { | ||
| element.classList.remove(DOM_CLASS_NAMES.TOC_GROUP_HOVER); | ||
| element.style.removeProperty('--toc-gap-below'); | ||
| }); | ||
| this.#lastHoveredTocGroup = null; | ||
| } | ||
|
|
||
| #setHoveredTocGroup(id: string) { | ||
|
Contributor
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. the five new methods mirror the SDT pair almost line-for-line ( |
||
| if (this.#lastHoveredTocGroup?.id === id) return; | ||
|
|
||
| this.#clearHoveredTocGroup(); | ||
|
|
||
| const elements = this.#queryTocEntryElementsById(id); | ||
| if (elements.length === 0) return; | ||
|
|
||
| elements.forEach((element) => element.classList.add(DOM_CLASS_NAMES.TOC_GROUP_HOVER)); | ||
| this.#applyTocGapFill(elements); | ||
| this.#lastHoveredTocGroup = { id, elements }; | ||
| } | ||
|
|
||
| /** | ||
| * Each TOC entry is its own absolutely-positioned paragraph fragment, so | ||
| * paragraph spacing leaves an unbacked strip between them. Write the gap to | ||
| * `--toc-gap-below` and let the `::after` rule in styles.ts paint it. | ||
| * Cross-page gaps are skipped so the strip doesn't draw over page breaks. | ||
| */ | ||
| #applyTocGapFill(elements: HTMLElement[]): void { | ||
| if (elements.length < 2) return; | ||
|
|
||
| const measured = elements | ||
| .map((element) => ({ element, rect: element.getBoundingClientRect() })) | ||
| .sort((a, b) => a.rect.top - b.rect.top); | ||
|
|
||
| for (let i = 0; i < measured.length - 1; i++) { | ||
| const current = measured[i]; | ||
| const next = measured[i + 1]; | ||
|
|
||
| const currentPage = current.element.closest('[data-page-index]'); | ||
| if (!currentPage || currentPage !== next.element.closest('[data-page-index]')) continue; | ||
|
|
||
| // Divide by the painter's zoom transform so the strip matches the | ||
| // fragment's untransformed CSS-pixel height. Pad by 1px to cover | ||
| // sub-pixel rounding; the overlap falls on the next (also grey) entry. | ||
| const rawGap = next.rect.top - current.rect.bottom; | ||
| const scaleY = | ||
| current.rect.height && current.element.offsetHeight ? current.rect.height / current.element.offsetHeight : 1; | ||
| const gap = scaleY > 0 ? rawGap / scaleY : rawGap; | ||
| if (gap > 0) { | ||
| current.element.style.setProperty('--toc-gap-below', `${gap + 1}px`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #queryTocEntryElementsById(id: string): HTMLElement[] { | ||
| if (!this.#painterHost) return []; | ||
| const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); | ||
|
Contributor
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. the CSS.escape fallback ladder is inlined here for the third time in this file — there's already a file-scope escapeAttrValue helper around line 10287. can we reuse that here? |
||
| return Array.from( | ||
| this.#painterHost.querySelectorAll<HTMLElement>(`.${DOM_CLASS_NAMES.TOC_ENTRY}[data-toc-id="${escapedId}"]`), | ||
| ); | ||
| } | ||
|
|
||
| /** Restore JS-driven `toc-group-hover` after a repaint, mirror of #reapplySdtGroupHover. */ | ||
| #reapplyTocGroupHover(): void { | ||
| if (!this.#lastHoveredTocGroup) return; | ||
|
|
||
| const { id } = this.#lastHoveredTocGroup; | ||
| const elements = this.#queryTocEntryElementsById(id); | ||
|
|
||
| if (elements.length === 0) { | ||
| this.#lastHoveredTocGroup = null; | ||
| return; | ||
| } | ||
|
|
||
| elements.forEach((element) => element.classList.add(DOM_CLASS_NAMES.TOC_GROUP_HOVER)); | ||
| this.#applyTocGapFill(elements); | ||
| this.#lastHoveredTocGroup = { id, elements }; | ||
| } | ||
|
|
||
| /** | ||
| * Re-applies the sdt-group-hover class after a paint cycle. | ||
| * DOM elements are rebuilt during repaint, so the hover class added by | ||
|
|
@@ -6790,6 +6912,7 @@ export class PresentationEditor extends EventEmitter { | |
| proofingAnnotations: this.#buildProofingAnnotations(), | ||
| rebuildDomPositionIndex: () => this.#rebuildDomPositionIndex(), | ||
| reapplyStructuredContentHover: () => this.#reapplySdtGroupHover(), | ||
| reapplyTocGroupHover: () => this.#reapplyTocGroupHover(), | ||
| }); | ||
| } | ||
|
|
||
|
|
||
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.
the painter still hardcodes
'superdoc-toc-entry'whileDOM_CLASS_NAMES.TOC_ENTRYis right there and used in PresentationEditor. swap so the class name has one source of truth. same thing atEditorInputManager.ts:1365.