Fix list indentation with hanging indent for wrapped lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-15 10:38:34 +01:00
parent b5971c9618
commit 147dac1f34
2 changed files with 56 additions and 32 deletions

View File

@@ -194,7 +194,7 @@ function buildDecorations(view: EditorView): DecorationSet {
},
})
// Second pass: line-based decorations for lists and checkboxes
// Second pass: line-based decorations for lists (bullets, checkboxes, ordered)
for (let i = 1; i <= doc.lines; i++) {
const line = doc.line(i)
const text = line.text
@@ -202,39 +202,56 @@ function buildDecorations(view: EditorView): DecorationSet {
const indent = text.length - stripped.length
const lineOnCursor = isOnCursorLine(view, line.from, line.to)
// Skip horizontal rule lines (avoid false match with bullet regex)
if (/^([-*_]\s*){3,}$/.test(stripped)) continue
const level = Math.min(Math.floor(indent / 2), 5)
// Checkbox: "- [ ] " or "- [x] " (with optional leading whitespace)
const checkboxMatch = text.match(/^(\s*)- \[([ x])\] /)
if (checkboxMatch && !lineOnCursor) {
const checked = checkboxMatch[2] === 'x'
const markerStart = line.from + checkboxMatch[1].length
const markerEnd = markerStart + 6 // "- [x] " is 6 chars
const markerPos = line.from + checkboxMatch[1].length
const fullMarkerEnd = line.from + checkboxMatch[0].length
// Replace entire prefix (whitespace + "- [x] ") with checkbox widget
decs.push(
Decoration.replace({
widget: new CheckboxWidget(checked, markerStart),
}).range(markerStart, markerEnd)
widget: new CheckboxWidget(checked, markerPos),
}).range(line.from, fullMarkerEnd)
)
// Add nested guide line decoration
if (indent > 0) {
decs.push(Decoration.line({ class: 'cm-live-list-nested' }).range(line.from))
}
decs.push(Decoration.line({
class: `cm-live-list-item cm-live-list-bullet cm-live-list-level-${level}`,
}).range(line.from))
continue
}
// Unordered list bullet: "- " (with optional leading whitespace)
const bulletMatch = text.match(/^(\s*)- /)
if (bulletMatch && !lineOnCursor) {
const markerStart = line.from + bulletMatch[1].length
const markerEnd = markerStart + 2 // "- " is 2 chars
const fullMarkerEnd = line.from + bulletMatch[0].length
// Replace entire prefix (whitespace + "- ") with bullet widget
decs.push(
Decoration.replace({
widget: new BulletWidget(),
}).range(markerStart, markerEnd)
}).range(line.from, fullMarkerEnd)
)
decs.push(Decoration.line({
class: `cm-live-list-item cm-live-list-bullet cm-live-list-level-${level}`,
}).range(line.from))
continue
}
// Add nested guide line for any indented list item (bullet or checkbox)
if (indent > 0 && /^\s+(- |\* |\d+\. )/.test(text)) {
decs.push(Decoration.line({ class: 'cm-live-list-nested' }).range(line.from))
// Ordered list: "1. " (with optional leading whitespace)
const orderedMatch = text.match(/^(\s*)(\d+)\. /)
if (orderedMatch && !lineOnCursor) {
// Hide leading whitespace — CSS controls indentation
if (indent > 0) {
decs.push(Decoration.replace({}).range(line.from, line.from + indent))
}
decs.push(Decoration.line({
class: `cm-live-list-item cm-live-list-ordered cm-live-list-level-${level}`,
}).range(line.from))
continue
}
}
@@ -308,32 +325,32 @@ const livePreviewTheme = EditorView.theme({
margin: '8px 0',
},
// Bullet widget
// Bullet widget — fixed width for consistent hanging indent
'.cm-live-bullet': {
display: 'inline-block',
width: '20px',
color: '#888',
fontSize: '0.85em',
marginRight: '4px',
},
// Checkbox widget
// Checkbox widget — fixed width matching bullet for consistent indent
'.cm-live-checkbox-wrap': {
display: 'inline-flex',
alignItems: 'center',
marginRight: '4px',
width: '20px',
verticalAlign: 'middle',
},
'.cm-live-checkbox': {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
width: '15px',
height: '15px',
border: '1.5px solid #555',
borderRadius: '3px',
background: 'transparent',
cursor: 'pointer',
verticalAlign: 'middle',
fontSize: '11px',
fontSize: '10px',
lineHeight: '1',
color: 'transparent',
userSelect: 'none',
@@ -347,11 +364,15 @@ const livePreviewTheme = EditorView.theme({
color: '#fff',
},
// Nested list guide line
'.cm-live-list-nested': {
borderLeft: '1.5px solid #2a2a4a',
marginLeft: '8px',
},
// List indentation — hanging indent so wrapped lines align with text, not marker
'.cm-live-list-bullet': { textIndent: '-20px' },
'.cm-live-list-ordered': { textIndent: '-20px' },
'.cm-live-list-level-0': { paddingLeft: '40px' },
'.cm-live-list-level-1': { paddingLeft: '60px' },
'.cm-live-list-level-2': { paddingLeft: '80px' },
'.cm-live-list-level-3': { paddingLeft: '100px' },
'.cm-live-list-level-4': { paddingLeft: '120px' },
'.cm-live-list-level-5': { paddingLeft: '140px' },
})
export function livePreview(): Extension {

View File

@@ -142,14 +142,17 @@ belongs_to:
- [ ] Review analytics from last issue
### Nested Topics
- Content strategy
- Newsletter growth
- Organic subscribers
- Paid acquisition
- Content strategy for growing the newsletter audience through organic channels, referrals, and high-quality evergreen content that people want to share with their engineering teams
- Newsletter growth and subscriber acquisition including all the different channels we use to attract new readers to the publication
- Organic subscribers from search, Twitter, and word of mouth — these are the highest quality subscribers with the best retention rates over time
- Paid acquisition through Facebook ads and newsletter cross-promotions with other engineering publications in the space
- Social media cross-posting
- Technical writing
- Code examples
- Architecture diagrams
1. First ordered item with a really long description that should definitely wrap to the next line when displayed in the editor, testing the hanging indent behavior for numbered lists
2. Second ordered item — shorter
1. Nested ordered item that also has quite a long description to verify that the indentation works correctly for nested numbered lists too
`,
'/Users/luca/Laputa/procedure/run-sponsorships.md': `---
title: Run Sponsorships