-
Notifications
You must be signed in to change notification settings - Fork 66
bug: list items are cut off on left edge inside table (SD-1836) #1996
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 |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ import { | |
| type SdtBoundaryOptions, | ||
| } from '../utils/sdt-helpers.js'; | ||
| import { normalizeZIndex } from '@superdoc/pm-adapter/utilities.js'; | ||
| import { computeTabWidth } from '../utils/marker-helpers.js'; | ||
|
|
||
| /** | ||
| * Default gap between list marker and text content in pixels. | ||
|
|
@@ -68,6 +69,7 @@ type WordLayoutMarker = { | |
| /** Letter spacing in pixels */ | ||
| letterSpacing?: number; | ||
| }; | ||
| suffix?: 'tab' | 'space' | 'nothing'; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -100,6 +102,12 @@ type MarkerRenderParams = { | |
| markerMeasure: ParagraphMeasure['marker']; | ||
| /** Left indent in pixels */ | ||
| indentLeftPx: number; | ||
| /** Hanging indent in pixels */ | ||
| hangingIndentPx: number; | ||
| /** First line indent in pixels */ | ||
| firstLineIndentPx: number; | ||
| /** Array of explicit tab stop positions in pixels. */ | ||
| tabsPx?: number[]; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -145,30 +153,39 @@ type TableCellIndentParams = { | |
| * @returns Container element with marker and line as children | ||
| */ | ||
| function renderListMarker(params: MarkerRenderParams): HTMLElement { | ||
| const { doc, lineEl, markerLayout, markerMeasure, indentLeftPx } = params; | ||
| const { doc, lineEl, markerLayout, markerMeasure, indentLeftPx, hangingIndentPx, firstLineIndentPx, tabsPx } = params; | ||
|
|
||
| const markerJustification = markerLayout?.justification ?? 'left'; | ||
|
|
||
| // Extract marker box width with fallback chain: layout -> measure -> 0 | ||
| const markerBoxWidth = | ||
| (typeof markerLayout?.markerBoxWidthPx === 'number' ? markerLayout.markerBoxWidthPx : undefined) ?? | ||
| markerMeasure?.markerWidth ?? | ||
| 0; | ||
| const anchorPoint = indentLeftPx - hangingIndentPx + firstLineIndentPx; | ||
|
|
||
| // Extract gutter width with fallback chain: layout -> measure -> default gap | ||
| const gutter = | ||
| (typeof markerLayout?.gutterWidthPx === 'number' ? markerLayout.gutterWidthPx : undefined) ?? | ||
| markerMeasure?.gutterWidth ?? | ||
| LIST_MARKER_GAP; | ||
|
|
||
| // Calculate marker start position based on justification | ||
| const markerStartPos = | ||
| markerJustification === 'left' | ||
| ? indentLeftPx | ||
| : ((typeof markerLayout?.markerX === 'number' ? markerLayout.markerX : undefined) ?? indentLeftPx); | ||
| const markerJustification = markerLayout?.justification ?? 'left'; | ||
| const markerTextWidth = markerMeasure?.markerTextWidth ?? 0; | ||
|
|
||
| let markerStartPos: number, currentPos: number; | ||
| if (markerJustification === 'left') { | ||
| markerStartPos = anchorPoint; | ||
| currentPos = markerStartPos + markerTextWidth; | ||
| } else if (markerJustification === 'right') { | ||
| markerStartPos = anchorPoint - markerTextWidth; | ||
| currentPos = anchorPoint; | ||
| } else { | ||
| markerStartPos = anchorPoint - markerTextWidth / 2; | ||
| currentPos = markerStartPos + markerTextWidth; | ||
| } | ||
|
|
||
| // Marker left position is marker start minus the width of the marker box | ||
| const markerLeftPos = markerStartPos - markerBoxWidth; | ||
| const suffix = markerLayout?.suffix ?? 'tab'; | ||
| let listTabWidth = 0; | ||
| if (suffix === 'tab') { | ||
| listTabWidth = computeTabWidth( | ||
| currentPos, | ||
| markerJustification, | ||
| tabsPx, | ||
| hangingIndentPx, | ||
| firstLineIndentPx, | ||
| indentLeftPx, | ||
| ); | ||
| } else if (suffix === 'space') { | ||
| listTabWidth = 4; | ||
| } | ||
|
|
||
| // Create container to hold both marker and line | ||
| const lineContainer = doc.createElement('div'); | ||
|
|
@@ -193,17 +210,39 @@ function renderListMarker(params: MarkerRenderParams): HTMLElement { | |
| markerEl.style.letterSpacing = `${markerLayout.run.letterSpacing}px`; | ||
| } | ||
|
|
||
| // Position marker absolutely within the container | ||
| markerEl.style.position = 'absolute'; | ||
| markerEl.style.left = `${markerLeftPos}px`; | ||
| markerEl.style.width = `${markerBoxWidth}px`; | ||
| markerEl.style.textAlign = markerJustification; | ||
| markerEl.style.paddingRight = `${gutter}px`; | ||
|
|
||
| // Align text start to the marker start position (gutter spacing comes from marker padding) | ||
| lineEl.style.paddingLeft = `${markerStartPos}px`; | ||
| // Position marker within the container | ||
| if (['center', 'right'].includes(markerJustification)) { | ||
|
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.
This block only applies marker/text offset logic for Useful? React with 👍 / 👎.
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. left-justified markers start at maybe |
||
| lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft || '0') + currentPos + listTabWidth + 'px'; | ||
|
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.
should we drop |
||
| if (markerJustification === 'right') { | ||
| markerEl.style.position = 'absolute'; | ||
| markerEl.style.left = `${markerStartPos}px`; | ||
| } else { | ||
| markerEl.style.position = 'absolute'; | ||
| markerEl.style.left = `${markerStartPos - markerTextWidth / 2}px`; | ||
| } | ||
| } | ||
| lineEl.style.display = 'inline-block'; | ||
|
|
||
| // Add separator | ||
| let separatorEl; | ||
| if (suffix === 'tab') { | ||
| separatorEl = doc.createElement('span'); | ||
| separatorEl.className = 'superdoc-tab'; | ||
| separatorEl.innerHTML = ' '; | ||
| separatorEl.style.display = 'inline-block'; | ||
| separatorEl.style.wordSpacing = '0px'; | ||
| separatorEl.style.width = `${listTabWidth}px`; | ||
| } else if (suffix === 'space') { | ||
| separatorEl = doc.createElement('span'); | ||
| separatorEl.className = 'superdoc-marker-suffix-space'; | ||
| separatorEl.style.wordSpacing = '0px'; | ||
| separatorEl.textContent = '\u00A0'; | ||
| } | ||
|
|
||
| lineContainer.appendChild(markerEl); | ||
| if (separatorEl) { | ||
| lineContainer.appendChild(separatorEl); | ||
| } | ||
| lineContainer.appendChild(lineEl); | ||
|
|
||
| return lineContainer; | ||
|
|
@@ -887,6 +926,10 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen | |
| markerMeasure?.indentLeft ?? | ||
| wordLayout?.indentLeftPx ?? | ||
| (block.attrs?.indent && typeof block.attrs.indent.left === 'number' ? block.attrs.indent.left : 0); | ||
| const hangingIndentPx = | ||
| block.attrs?.indent && typeof block.attrs.indent.hanging === 'number' ? block.attrs.indent.hanging : 0; | ||
| const firstLineIndentPx = | ||
| block.attrs?.indent && typeof block.attrs.indent.firstLine === 'number' ? block.attrs.indent.firstLine : 0; | ||
| const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; | ||
|
|
||
| // Calculate the global line indices for this block | ||
|
|
@@ -910,6 +953,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen | |
| // Create wrapper for this paragraph's SDT metadata | ||
| // Use absolute positioning within the content container to stack blocks vertically | ||
| const paraWrapper = doc.createElement('div'); | ||
| paraWrapper.classList.add('superdoc-table-paragraph'); | ||
|
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.
|
||
| paraWrapper.style.position = 'relative'; | ||
| paraWrapper.style.left = '0'; | ||
| paraWrapper.style.width = '100%'; | ||
|
|
@@ -978,6 +1022,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen | |
| markerLayout, | ||
| markerMeasure, | ||
| indentLeftPx, | ||
| hangingIndentPx, | ||
| firstLineIndentPx, | ||
| }); | ||
|
Comment on lines
1023
to
1027
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.
Useful? React with 👍 / 👎. |
||
| renderedLines.push({ el: lineContainer, top: lineTop, height: line.lineHeight }); | ||
| paraWrapper.appendChild(lineContainer); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /** | ||
|
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. extraction is justified (2 call sites) but the export has no JSDoc. let's add a one-liner describing what it does. |
||
| * Default tab interval in pixels (0.5 inch at 96 DPI). | ||
| * Used when calculating tab stops for list markers that extend past the implicit tab stop. | ||
| * This matches Microsoft Word's default tab interval behavior. | ||
| */ | ||
| const DEFAULT_TAB_INTERVAL_PX = 48; | ||
|
|
||
| export const computeTabWidth = ( | ||
| currentPos: number, | ||
| justification: string, | ||
| tabs: number[] | undefined, | ||
| hangingIndent: number | undefined, | ||
| firstLineIndent: number | undefined, | ||
| leftIndent: number, | ||
| ): number => { | ||
| const nextDefaultTabStop = currentPos + DEFAULT_TAB_INTERVAL_PX - (currentPos % DEFAULT_TAB_INTERVAL_PX); | ||
| let tabWidth: number; | ||
| if ((justification ?? 'left') === 'left') { | ||
|
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.
|
||
| // Check for explicit tab stops past current position | ||
| const explicitTabs = [...(tabs ?? [])]; | ||
| if (hangingIndent && hangingIndent > 0) { | ||
| // Account for hanging indent by adding an implicit tab stop at (left + hanging) | ||
| const implicitTabPos = leftIndent; // paraIndentLeft already accounts for hanging | ||
| explicitTabs.push(implicitTabPos); | ||
| // Sort tab stops to maintain order | ||
| explicitTabs.sort((a, b) => { | ||
| if (typeof a === 'number' && typeof b === 'number') { | ||
|
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.
|
||
| return a - b; | ||
| } | ||
| return 0; | ||
| }); | ||
| } | ||
| let targetTabStop: number | undefined; | ||
|
|
||
| if (Array.isArray(explicitTabs) && explicitTabs.length > 0) { | ||
| // Find the first tab stop that's past the current position | ||
| for (const tab of explicitTabs) { | ||
| if (typeof tab === 'number' && tab > currentPos) { | ||
| targetTabStop = tab; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (targetTabStop === undefined) { | ||
| // advance to next default 48px tab interval, matching Word behavior. | ||
| targetTabStop = nextDefaultTabStop; | ||
| } | ||
| tabWidth = targetTabStop - currentPos; | ||
| } else if (justification === 'right') { | ||
| if (firstLineIndent != null && firstLineIndent > 0) { | ||
| tabWidth = nextDefaultTabStop - currentPos; | ||
| } else { | ||
| tabWidth = hangingIndent ?? 0; | ||
| } | ||
| } else { | ||
| tabWidth = nextDefaultTabStop - currentPos; | ||
| } | ||
| return tabWidth; | ||
| }; | ||
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.
center justification computes
left = anchorPoint - markerTextWidth(half-width subtracted twice), identical to right-justified positioning.renderer.tsline 2305 has the same math -- intentional, or pre-existing bug?