Skip to content

Commit 775b26f

Browse files
authored
Add folding support to code blocks (#12680)
This adds support for defining **folding ranges** in any code block. Folding ranges can be defined by specifying `foldable` on the code block and using `[*` and `*]` lines in the code, and will be transformed to collapsible sections with a toggle marker and indicator line. Folding ranges can be nested. By default folding ranges are open, but can be set to initially closed using `[* -`. When using line numbers, the folding range markers and lines will be shifted to the right of the line numbers.
1 parent c3d4f56 commit 775b26f

File tree

5 files changed

+293
-56
lines changed

5 files changed

+293
-56
lines changed

site/lib/_sass/components/_code.scss

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,111 @@ pre {
142142
span.line {
143143
padding-left: 0.5rem;
144144

145-
&[data-line]::before {
146-
display: inline-block;
147-
content: attr(data-line) "";
148-
width: 2em;
149-
margin-right: 0.5rem;
150-
text-align: right;
151-
color: var(--site-base-fgColor-alt);
145+
&[data-line] {
146+
padding-left: 0;
147+
148+
&::before {
149+
display: inline-block;
150+
content: attr(data-line) "";
151+
width: 2em;
152+
margin-right: 1rem;
153+
text-align: right;
154+
color: var(--site-base-fgColor-alt);
155+
}
156+
}
157+
}
158+
}
159+
160+
&.show-folding-ranges code {
161+
162+
--folding-inset: 0px;
163+
164+
// A folding range.
165+
details {
166+
margin: 0;
167+
position: relative;
168+
169+
--level: 0;
170+
--inset: calc(var(--folding-inset) + 2px + (var(--level) * 1em));
171+
172+
// Vertical line drawn along a folding range.
173+
&::before {
174+
content: "";
175+
display: none;
176+
position: absolute;
177+
// inset + caret width/2 - line width/2
178+
left: calc(var(--inset) + .5em - .5px);
179+
width: 1px;
180+
background: var(--site-inset-borderColor);
181+
height: calc(100% - 1lh);
182+
top: .8lh;
183+
}
184+
185+
summary {
186+
margin: 0;
187+
font-weight: initial;
188+
user-select: initial;
189+
list-style: none;
190+
position: relative;
191+
192+
// Caret at the beginning of the folding range.
193+
span.material-symbols {
194+
position: absolute;
195+
cursor: pointer;
196+
z-index: var(--site-z-base);
197+
left: var(--inset);
198+
top: 50%;
199+
font-size: 1em;
200+
user-select: none;
201+
translate: 0 -50%;
202+
transition: transform 300ms ease;
203+
transform: rotate(0);
204+
transform-origin: center;
205+
}
206+
207+
&:hover>span {
208+
color: initial;
209+
}
210+
}
211+
212+
&:not([open])>summary .line {
213+
>span {
214+
opacity: 0.5;
215+
}
216+
217+
>span::after {
218+
content: '';
219+
}
220+
}
221+
222+
&[open] {
223+
&::before {
224+
display: block;
225+
}
226+
227+
>summary {
228+
cursor: auto;
229+
230+
span.material-symbols {
231+
transform: rotate(90deg);
232+
}
233+
}
234+
}
235+
236+
>* {
237+
margin: 0;
238+
}
239+
}
240+
}
241+
242+
&.show-line-numbers.show-folding-ranges code {
243+
--folding-inset: 2.5em;
244+
245+
span.line {
246+
&[data-line] {
247+
&::before {
248+
margin-right: 3rem;
249+
}
152250
}
153251
}
154252
}

site/lib/src/components/common/wrapped_code_block.dart

Lines changed: 66 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:jaspr/jaspr.dart';
66

77
import '../../util.dart';
88
import 'client/copy_button.dart';
9+
import 'material_icon.dart';
910

1011
/// A rendered code block with support for syntax highlighting,
1112
/// line highlighting, filenames, language specifying,
@@ -19,6 +20,7 @@ final class WrappedCodeBlock extends StatelessComponent {
1920
this.highlightLines = const {},
2021
this.addedLines = const {},
2122
this.removedLines = const {},
23+
this.foldingRanges = const [],
2224
this.languagesToHide = const {'plaintext', 'console'},
2325
this.tag,
2426
this.initialLineNumber = 1,
@@ -35,6 +37,7 @@ final class WrappedCodeBlock extends StatelessComponent {
3537
final Set<int> highlightLines;
3638
final Set<int> addedLines;
3739
final Set<int> removedLines;
40+
final List<FoldingRange> foldingRanges;
3841
final Set<String> languagesToHide;
3942
final CodeBlockTag? tag;
4043
final int initialLineNumber;
@@ -43,14 +46,67 @@ final class WrappedCodeBlock extends StatelessComponent {
4346

4447
@override
4548
Component build(BuildContext context) {
49+
final children = <int, Component>{
50+
for (var lineIndex = 0; lineIndex < content.length; lineIndex += 1)
51+
lineIndex: span(
52+
classes: [
53+
'line',
54+
if (highlightLines.contains(lineIndex + 1)) 'highlighted-line',
55+
if (removedLines.contains(lineIndex + 1)) 'removed-line',
56+
if (addedLines.contains(lineIndex + 1)) 'added-line',
57+
].toClasses,
58+
attributes: {
59+
if (showLineNumbers)
60+
'data-line': '${initialLineNumber + lineIndex}',
61+
},
62+
[
63+
switch (content[lineIndex]) {
64+
// Add a zero-width space when empty
65+
// so that the line isn't collapsed to 0 height.
66+
final line when line.isEmpty => span(
67+
styles: const Styles(
68+
userSelect: UserSelect.none,
69+
),
70+
[text('\u200b')],
71+
),
72+
final lineSpans => span(lineSpans),
73+
},
74+
text('\n'),
75+
],
76+
),
77+
};
78+
79+
if (foldingRanges.isNotEmpty) {
80+
for (final (:start, :end, :level, :open) in foldingRanges) {
81+
final foldingSummary = children.remove(start - 1);
82+
final foldedChildren = [
83+
for (var i = start + 1; i <= end; i += 1) ?children.remove(i - 1),
84+
];
85+
86+
children[start - 1] = details(
87+
open: open,
88+
styles: Styles(raw: {'--level': '$level'}),
89+
[
90+
summary(
91+
classes: 'fold-summary',
92+
[
93+
const MaterialIcon('keyboard_arrow_right'),
94+
foldingSummary ?? text('...'),
95+
],
96+
),
97+
...foldedChildren,
98+
],
99+
);
100+
}
101+
}
102+
46103
return div(
47104
classes: 'code-block-wrapper language-$language',
48105
[
49106
if (title case final title?)
50-
div(
51-
classes: 'code-block-header',
52-
[text(title)],
53-
),
107+
div(classes: 'code-block-header', [
108+
text(title),
109+
]),
54110
div(
55111
classes: [
56112
'code-block-body',
@@ -71,47 +127,14 @@ final class WrappedCodeBlock extends StatelessComponent {
71127
pre(
72128
classes: [
73129
if (showLineNumbers) 'show-line-numbers',
130+
if (foldingRanges.isNotEmpty) 'show-folding-ranges',
74131
'opal',
75132
].toClasses,
76133
attributes: {'tabindex': '0'},
77134
[
78-
code(
79-
[
80-
for (
81-
var lineIndex = 0;
82-
lineIndex < content.length;
83-
lineIndex += 1
84-
)
85-
span(
86-
classes: [
87-
'line',
88-
if (highlightLines.contains(lineIndex + 1))
89-
'highlighted-line',
90-
if (removedLines.contains(lineIndex + 1))
91-
'removed-line',
92-
if (addedLines.contains(lineIndex + 1)) 'added-line',
93-
].toClasses,
94-
attributes: {
95-
if (showLineNumbers)
96-
'data-line': '${initialLineNumber + lineIndex}',
97-
},
98-
[
99-
switch (content[lineIndex]) {
100-
// Add a zero-width space when empty
101-
// so that the line isn't collapsed to 0 height.
102-
final line when line.isEmpty => span(
103-
styles: const Styles(
104-
userSelect: UserSelect.none,
105-
),
106-
[text('\u200b')],
107-
),
108-
final lineSpans => span(lineSpans),
109-
},
110-
text('\n'),
111-
],
112-
),
113-
],
114-
),
135+
code([
136+
for (var i = 0; i < content.length; i += 1) ?children[i],
137+
]),
115138
],
116139
),
117140
if (textToCopy case final textToCopy?)
@@ -151,3 +174,5 @@ enum CodeBlockTag {
151174
_ => throw ArgumentError('Unknown tag for code blocks: $tag'),
152175
};
153176
}
177+
178+
typedef FoldingRange = ({int start, int end, int level, bool open});

site/lib/src/extensions/code_block_processor.dart

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,17 @@ final class CodeBlockProcessor implements PageExtension {
8888
);
8989
}
9090

91-
final diffResult = isDiff ? _processDiffLines(lines) : null;
92-
final linesWithDiffRemoved = diffResult?.lines ?? lines;
93-
final addedLines = diffResult?.addedLines ?? const <int>{};
94-
final removedLines = diffResult?.removedLines ?? const <int>{};
91+
final isFolding = metadata.containsKey('foldable');
92+
93+
final diffResult = isDiff
94+
? _processDiffLines(lines)
95+
: (lines: lines, addedLines: <int>{}, removedLines: <int>{});
96+
final foldingResult = isFolding
97+
? _processFoldingLines(diffResult.lines)
98+
: (lines: diffResult.lines, foldingRanges: <FoldingRange>[]);
9599

96100
final codeLines = _removeHighlights(
97-
linesWithDiffRemoved,
101+
foldingResult.lines,
98102
skipHighlighting,
99103
);
100104
final processedContent = _highlightCode(
@@ -117,8 +121,9 @@ final class CodeBlockProcessor implements PageExtension {
117121
},
118122
title: title,
119123
highlightLines: _parseNumbersAndRanges(rawHighlightLines),
120-
addedLines: addedLines,
121-
removedLines: removedLines,
124+
addedLines: diffResult.addedLines,
125+
removedLines: diffResult.removedLines,
126+
foldingRanges: foldingResult.foldingRanges,
122127
tag: tag != null ? CodeBlockTag.parse(tag) : null,
123128
initialLineNumber: initialLineNumber ?? 1,
124129
showLineNumbers: showLineNumbers,
@@ -441,6 +446,65 @@ final class CodeBlockProcessor implements PageExtension {
441446
removedLines: removedLines,
442447
);
443448
}
449+
450+
/// Processes lines for folding mode, extracting folding range line markers.
451+
///
452+
/// Lines equal to '[*' are marked as the start of an open folding range.
453+
/// Lines equal to '[* -' are marked as the start of a closed folding range.
454+
/// Lines equal to '*]' are marked as the end of a folding range.
455+
/// The folding markers are removed from the lines.
456+
({
457+
List<String> lines,
458+
List<FoldingRange> foldingRanges,
459+
})
460+
_processFoldingLines(List<String> lines) {
461+
final foldingRanges = <FoldingRange>[];
462+
final processedLines = <String>[];
463+
final foldingStack = <({int start, bool open})>[];
464+
465+
for (var lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
466+
final line = lines[lineIndex];
467+
468+
// To account for removal of the folding marker lines.
469+
final lineIndexCorrection =
470+
(foldingRanges.length * 2) + foldingStack.length;
471+
472+
if (line.trim() == '[*') {
473+
foldingStack.add((
474+
start: lineIndex + 1 - lineIndexCorrection,
475+
open: true,
476+
));
477+
// Skip adding this line to processed lines.
478+
continue;
479+
} else if (line.trim() == '[* -') {
480+
foldingStack.add((
481+
start: lineIndex + 1 - lineIndexCorrection,
482+
open: false,
483+
));
484+
// Skip adding this line to processed lines.
485+
continue;
486+
} else if (line.trim() == '*]') {
487+
if (foldingStack.isNotEmpty) {
488+
final (:start, :open) = foldingStack.removeLast();
489+
foldingRanges.add((
490+
start: start,
491+
end: lineIndex - lineIndexCorrection,
492+
level: foldingStack.length,
493+
open: open,
494+
));
495+
}
496+
// Skip adding this line to processed lines.
497+
continue;
498+
}
499+
500+
processedLines.add(line);
501+
}
502+
503+
return (
504+
lines: processedLines,
505+
foldingRanges: foldingRanges,
506+
);
507+
}
444508
}
445509

446510
@immutable

0 commit comments

Comments
 (0)